[
  {
    "path": ".dockerignore",
    "content": "# 版本控制\n.git\n.github\n\n# 构建产物（builder 阶段重新生成）\n.next\nnode_modules\n\n# 测试\ncoverage\ntests\nvitest.config.ts\ndocker-compose.test.yml\n\n# 运行时数据\nlogs\ndata\ncertificates\nbackups\nuploads\n\n# IDE 和 AI 工具\n.vscode\n.cursor\n.claude\n.gemini\n.agent\n.shared\n.artifacts\n\n# 数据库文件\n*.db\n*.db-journal\nprisma/data\n\n# 临时文件\n.DS_Store\n*.tsbuildinfo\n.tmp-old-snapshot-*\n\n# 环境变量\n.env\n.env.local\n.env.*.local\n.env.test\n\n# 文档和杂项\n*.md\n*.py\ndebug-request.json\nAGENTS.md\ndocs\nagent\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"extends\": \"next/core-web-vitals\",\n  \"rules\": {\n    \"@typescript-eslint/no-explicit-any\": \"off\",\n    \"@typescript-eslint/no-unused-vars\": \"warn\",\n    \"@next/next/no-img-element\": \"warn\",\n    \"react-hooks/exhaustive-deps\": \"warn\"\n  }\n}\n\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# vercel\n.vercel\n\n# IDE and AI tools\n.vscode/\n.idea/\n.claude/\n.cursor/\n.gemini/\n.artifacts/\n.agent/\n.shared/\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n/src/generated/prisma\n\n# logs\n/logs\n*.log\n\n# environment variables\n.env\n.env.local\n.env.test\n.env.*.local\ndocker-logs/\n\n# database\n*.db\n*.db-journal\nprisma/data/\n\n# uploads (user data)\nuploads/\n/data/\n\ncertificates\n\n# local temporary snapshots for old-version verification\n.tmp-old-snapshot-*/\n\n# temporary files\ntmp/\n\n# internal AI agent config (not for public)\nAGENTS.md\nagent/\n\n# internal docs (not for public)\ndocs/\n\n# GitHub CI workflows (internal)\n# .github/ — 只保留 workflows，忽略其他\n.github/*\n!.github/workflows/"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/usr/bin/env sh\nset -e\n\nnpm run verify:commit\n"
  },
  {
    "path": ".husky/pre-push",
    "content": "#!/usr/bin/env sh\nset -e\n\nnpm run verify:push\n"
  },
  {
    "path": ".nvmrc",
    "content": "22.14.0\n"
  },
  {
    "path": ".tmp_check_task.ts",
    "content": "import { prisma } from '@/lib/prisma'\n\nconst id = 'a3cbc6d3-8720-4584-addd-e2bc4ace7759'\n\nasync function main() {\n  const t = await prisma.task.findUnique({\n    where: { id },\n    select: {\n      id: true,\n      type: true,\n      userId: true,\n      projectId: true,\n      payload: true,\n      errorMessage: true,\n      createdAt: true,\n    },\n  })\n  console.log(JSON.stringify(t, null, 2))\n\n  if (!t) return\n\n  const pref = await prisma.userPreference.findUnique({\n    where: { userId: t.userId },\n    select: {\n      analysisModel: true,\n      customProviders: true,\n      customModels: true,\n    },\n  })\n  console.log('userPreference', JSON.stringify(pref, null, 2))\n}\n\nmain()\n  .catch((err) => {\n    console.error(err)\n    process.exit(1)\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog / 更新日志\n\nAll notable changes to this project will be documented in this file.\n\n---\n\n## [v0.2] - 2026-02-28\n\n### ✨ 新功能\n- 增加 OpenAI 兼容图片、视频格式支持\n\n### 🐛 修复\n- 修复默认模型配置后项目模型需要二次选择的问题\n- 修复部分情况 resolution 无法读取的问题\n- 修复模型链路为 LangGraph\n- 修复默认参数无选择问题\n- 修复关闭计费依然触发计费问题\n- 修复 openai-compatible 被误判为原生 OpenAI 推理问题\n- 修复 JSON 解析失败问题\n\n### ⚙️ 优化\n- 修改为默认计费 off\n- 增强提示词 JSON 格式限制\n\n---\n\n## [v0.2.1] - 2026-02-28\n\n### 🐛 修复\n- 修复 AI 生成内容语言不跟随网站语言设置的问题\n- 修复前端 API 请求未携带 Accept-Language header 导致 locale 回退到浏览器默认语言\n---\n\n## [v0.1] - 2026-02-27\n\n### 🎉 首次发布\n- 项目初始开源版本\n"
  },
  {
    "path": "Dockerfile",
    "content": "# ==================== Stage 1: Dependencies ====================\nFROM node:20-alpine AS deps\nWORKDIR /app\n\nCOPY package.json package-lock.json ./\nCOPY prisma ./prisma\nRUN npm ci\n\n# ==================== Stage 2: Build ====================\nFROM node:20-alpine AS builder\nWORKDIR /app\n\nCOPY --from=deps /app/node_modules ./node_modules\nCOPY . .\n\n# Prisma generate + Next.js build\nRUN npm run build\n\n# ==================== Stage 3: Production ====================\nFROM node:20-alpine AS runner\nWORKDIR /app\n\nENV NODE_ENV=production\n\n# Install tini for proper signal handling\nRUN apk add --no-cache tini\n\n# node_modules（含 devDeps，因为 npm run start 需要 concurrently + tsx）\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY --from=builder /app/package.json ./package.json\n\n# Next.js 构建产物\nCOPY --from=builder /app/.next ./.next\nCOPY --from=builder /app/public ./public\n\n# Prisma schema（db push 需要）\nCOPY --from=builder /app/prisma ./prisma\n\n# Worker 和 Watchdog 源码（tsx 运行 TypeScript）\nCOPY --from=builder /app/src ./src\nCOPY --from=builder /app/scripts ./scripts\nCOPY --from=builder /app/lib ./lib\n\n# 定价和配置标准\nCOPY --from=builder /app/standards ./standards\n\n# 国际化 + 配置文件\nCOPY --from=builder /app/messages ./messages\nCOPY --from=builder /app/tsconfig.json ./tsconfig.json\nCOPY --from=builder /app/next.config.ts ./next.config.ts\nCOPY --from=builder /app/middleware.ts ./middleware.ts\nCOPY --from=builder /app/postcss.config.mjs ./postcss.config.mjs\n\n# 运行日志目录 + 空 .env（tsx --env-file=.env 需要文件存在，实际 env 由 docker-compose 注入）\nRUN mkdir -p /app/logs && touch /app/.env\n\nEXPOSE 3000 3010\n\nENTRYPOINT [\"/sbin/tini\", \"--\"]\nCMD [\"npm\", \"run\", \"start\"]\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <a href=\"https://www.waoowaoo.com/\">\n    <img src=\"images/cta-banner.png\" alt=\"🚀 探索 AI 影视的下一代创作流 | 立即加入 waoowaoo 在线网页版内测候补\" width=\"800\">\n  </a>\n</p>\n\n<p align=\"center\">\n  <img src=\"public/banner.png\" alt=\"waoowaoo\" width=\"600\">\n</p>\n\n<h1 align=\"center\">waoowaoo AI 影视 Studio</h1>\n\n<p align=\"center\">\n  一款基于 AI 技术的短剧/漫画视频制作工具，支持从小说文本自动生成分镜、角色、场景，并制作成完整视频。\n</p>\n\n<p align=\"center\">\n  <a href=\"README_en.md\">English</a> · <a href=\"https://www.waoowaoo.com/\">加入内测候补</a> · <a href=\"https://github.com/saturndec/waoowaoo/issues\">反馈问题</a>\n</p>\n\n> [!IMPORTANT]\n> ⚠️ **测试版声明**：本项目目前处于测试初期阶段，由于暂时只有我一个人开发，存在部分 bug 和不完善之处。我们正在快速迭代更新中，**欢迎进群反馈问题和需求，及时关注项目更新！目前更新会非常频繁，后续会增加大量新功能以及优化效果，我们的目标是成为行业最强AI工具！**\n\n<img src=\"images/dab6b4105e3260f37ba2d5f536dce259.jpg\" width=\"30%\">\n\n---\n\n## ✨ 功能特性\n\n- 🎬 **AI 剧本分析** — 自动解析小说，提取角色、场景、剧情\n- 🎨 **角色 & 场景生成** — AI 生成一致性人物和场景图片\n- 📽️ **分镜视频制作** — 自动生成分镜头并合成视频\n- 🎙️ **AI 配音** — 多角色语音合成\n- 🌐 **多语言支持** — 中文 / 英文界面，右上角一键切换\n\n---\n\n## 🚀 快速开始\n\n**前提条件**：安装 [Docker Desktop](https://docs.docker.com/get-docker/)\n\n### 方式一：拉取预构建镜像（最简单）\n\n无需克隆仓库，下载即用：\n\n```bash\n# 下载 docker-compose.yml\ncurl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml\n\n# 启动所有服务\ndocker compose up -d\n```\n\n> ⚠️ 当前为测试版，版本间数据库不兼容。升级请先清除旧数据：\n\n```bash\ndocker compose down -v\ndocker rmi ghcr.io/saturndec/waoowaoo:latest\ncurl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml\ndocker compose up -d\n```\n\n> 启动后请**清空浏览器缓存**并重新登录，避免旧版本缓存导致异常。\n\n### 方式二：克隆仓库 + Docker 构建（完全控制）\n\n```bash\ngit clone https://github.com/saturndec/waoowaoo.git\ncd waoowaoo\ndocker compose up -d\n```\n\n更新版本：\n```bash\ngit pull\ndocker compose down && docker compose up -d --build\n```\n\n### 方式三：本地开发模式（开发者）\n\n```bash\ngit clone https://github.com/saturndec/waoowaoo.git\ncd waoowaoo\n\n# 复制环境变量配置文件（必须在 npm install 之前完成）\ncp .env.example .env\n# ⚠️ 编辑 .env，填入你的 AI API Key（NEXTAUTH_URL 默认已是 http://localhost:3000，无需修改）\n\nnpm install\n\n# 只启动基础设施\n# 注意：docker-compose.yml 将服务映射到非标准端口，.env.example 已按此预设\nmysql:13306  redis:16379  minio:19000\ndocker compose up mysql redis minio -d\n\n# 初始化数据库表结构（首次必须执行，跳过会导致启动后报错）\nnpx prisma db push\n\n# 启动开发服务器\nnpm run dev\n```\n\n> [!WARNING]\n> 跳过 `npx prisma db push` 会导致所有数据库表不存在，启动后报错 `The table 'tasks' does not exist`。请务必先运行此命令再启动开发服务器。\n\n---\n\n访问 [http://localhost:13000](http://localhost:13000)（方式一、二）或 [http://localhost:3000](http://localhost:3000)（方式三）开始使用！\n\n> 首次启动会自动完成数据库初始化，无需任何额外配置。\n\n> [!TIP]\n> **如果遇到网页卡顿**：HTTP 模式下浏览器可能限制并发连接。可安装 [Caddy](https://caddyserver.com/docs/install) 启用 HTTPS：\n> ```bash\n> caddy run --config Caddyfile\n> ```\n> 然后访问 [https://localhost:1443](https://localhost:1443)\n\n---\n\n## 🔧 API 配置\n\n启动后进入**设置中心**配置 AI 服务的 API Key，内置配置教程。\n\n> 💡 **注意**：目前仅推荐使用各服务商官方 API，第三方兼容格式（OpenAI Compatible）尚不完善，后续版本会持续优化。\n\n---\n\n## 📦 技术栈\n\n- **框架**: Next.js 15 + React 19\n- **数据库**: MySQL + Prisma ORM\n- **队列**: Redis + BullMQ\n- **样式**: Tailwind CSS v4\n- **认证**: NextAuth.js\n\n---\n\n## 📦 页面功能预览\n\n![4f7b913264f7f26438c12560340e958c67fa833a](https://github.com/user-attachments/assets/fa0e9c57-9ea0-4df3-893e-b76c4c9d304b)\n![67509361cbe6809d2496a550de5733b9f99a9702](https://github.com/user-attachments/assets/f2fb6a64-5ba8-4896-a064-be0ded213e42)\n![466e13c8fd1fc799d8f588c367ebfa24e1e99bf7](https://github.com/user-attachments/assets/09bbff39-e535-4c67-80a9-69421c3b05ee)\n![c067c197c20b0f1de456357c49cdf0b0973c9b31](https://github.com/user-attachments/assets/688e3147-6e95-43b0-b9e7-dd9af40db8a0)\n\n---\n\n## 🤝 参与方式\n\n本项目由核心团队独立维护。欢迎你通过以下方式参与：\n\n- 🐛 提交 [Issue](https://github.com/saturndec/waoowaoo/issues) 反馈 Bug\n- 💡 提交 [Issue](https://github.com/saturndec/waoowaoo/issues) 提出功能建议\n- 🔧 提交 Pull Request 供参考 — 我们会认真审阅每一个 PR 的思路，但最终由团队自行实现修复，不会直接合并外部 PR\n\n---\n\n**Made with ❤️ by waoowaoo team**\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=saturndec/waoowaoo&type=date&legend=top-left)](https://www.star-history.com/#saturndec/waoowaoo&type=date&legend=top-left)\n"
  },
  {
    "path": "README_en.md",
    "content": "<p align=\"center\">\n  <img src=\"public/banner.png\" alt=\"waoowaoo\" width=\"600\">\n</p>\n\n<h1 align=\"center\">waoowaoo AI Video Studio</h1>\n\n<p align=\"center\">\n  An AI-powered tool for creating short drama / comic videos — automatically generates storyboards, characters, and scenes from novel text, then assembles them into complete videos.\n</p>\n\n<p align=\"center\">\n  <a href=\"README.md\">中文文档</a> · <a href=\"https://www.waoowaoo.com/\">Join Waitlist</a> · <a href=\"https://github.com/saturndec/waoowaoo/issues\">Report Bug</a>\n</p>\n\n> [!IMPORTANT]\n> **Beta Notice**: This project is currently in its early beta stage. As it is currently a solo-developed project, some bugs and imperfections are to be expected. We are iterating rapidly — please stay tuned for frequent updates! We are committed to rolling out a massive roadmap of new features and optimizations, with the ultimate goal of becoming the top-tier solution in the industry. Your feedback and feature requests are highly welcome!\n\n---\n\n## ✨ Features\n\n- 🎬 **AI Script Analysis** — Parse novels, extract characters, scenes & plot automatically\n- 🎨 **Character & Scene Generation** — Consistent AI-generated character and scene images\n- 📽️ **Storyboard Video** — Auto-generate shots and compose into complete videos\n- 🎙️ **AI Voiceover** — Multi-character voice synthesis\n- 🌐 **Bilingual UI** — Chinese / English, switch in the top-right corner\n\n---\n\n## 🚀 Quick Start\n\n**Prerequisites**: Install [Docker Desktop](https://docs.docker.com/get-docker/)\n\n### Method 1: Pull Pre-built Image (Easiest)\n\nNo need to clone the repository. Just download and run:\n\n```bash\n# Download docker-compose.yml\ncurl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml\n\n# Start all services\ndocker compose up -d\n```\n\n> ⚠️ This is a beta version. Database is not compatible between versions. To upgrade, clear old data first:\n\n```bash\ndocker compose down -v\ndocker rmi ghcr.io/saturndec/waoowaoo:latest\ncurl -O https://raw.githubusercontent.com/saturndec/waoowaoo/main/docker-compose.yml\ndocker compose up -d\n```\n\n> After starting, please **clear your browser cache** and log in again to avoid issues caused by stale cache.\n\n### Method 2: Clone & Docker Build (Full Control)\n\n```bash\ngit clone https://github.com/saturndec/waoowaoo.git\ncd waoowaoo\ndocker compose up -d\n```\n\nTo update:\n```bash\ngit pull\ndocker compose down && docker compose up -d --build\n```\n\n### Method 3: Local Development (For Developers)\n\n```bash\ngit clone https://github.com/saturndec/waoowaoo.git\ncd waoowaoo\n\n# Copy environment config (must be done before npm install)\ncp .env.example .env\n# ⚠️ Edit .env to fill in your AI API Keys (NEXTAUTH_URL defaults to http://localhost:3000, no change needed)\n\nnpm install\n\n# Start infrastructure only\ndocker compose up mysql redis minio -d\n\n# Run database migration\nnpx prisma db push\n\n# Start development server\nnpm run dev\n```\n\n---\n\nVisit [http://localhost:13000](http://localhost:13000) (Method 1 & 2) or [http://localhost:3000](http://localhost:3000) (Method 3) to get started!\n\n> The database is initialized automatically on first launch — no extra configuration needed.\n\n> [!TIP]\n> **If you experience lag**: HTTP mode may limit browser connections. Install [Caddy](https://caddyserver.com/docs/install) for HTTPS:\n> ```bash\n> caddy run --config Caddyfile\n> ```\n> Then visit [https://localhost:1443](https://localhost:1443)\n\n---\n\n## 🔧 API Configuration\n\nAfter launching, go to **Settings** to configure your AI service API keys. A built-in guide is provided.\n\n> 💡 **Note**: Currently only official provider APIs are recommended. Third-party compatible formats (OpenAI Compatible) are not yet fully supported and will be improved in future releases.\n\n---\n\n## 📦 Tech Stack\n\n- **Framework**: Next.js 15 + React 19\n- **Database**: MySQL + Prisma ORM\n- **Queue**: Redis + BullMQ\n- **Styling**: Tailwind CSS v4\n- **Auth**: NextAuth.js\n\n---\n\n## 📦 Preview\n\n![4f7b913264f7f26438c12560340e958c67fa833a](https://github.com/user-attachments/assets/fa0e9c57-9ea0-4df3-893e-b76c4c9d304b)\n![67509361cbe6809d2496a550de5733b9f99a9702](https://github.com/user-attachments/assets/f2fb6a64-5ba8-4896-a064-be0ded213e42)\n![466e13c8fd1fc799d8f588c367ebfa24e1e99bf7](https://github.com/user-attachments/assets/09bbff39-e535-4c67-80a9-69421c3b05ee)\n![c067c197c20b0f1de456357c49cdf0b0973c9b31](https://github.com/user-attachments/assets/688e3147-6e95-43b0-b9e7-dd9af40db8a0)\n\n---\n\n## 🤝 Contributing\n\nThis project is maintained by the core team. You're welcome to contribute by:\n\n- 🐛 Filing [Issues](https://github.com/saturndec/waoowaoo/issues) — report bugs\n- 💡 Filing [Issues](https://github.com/saturndec/waoowaoo/issues) — propose features\n- 🔧 Submitting Pull Requests as references — we review every PR carefully for ideas, but the team implements fixes internally rather than merging external PRs directly\n\n---\n\n**Made with ❤️ by waoowaoo team**\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=saturndec/waoowaoo&type=date&legend=top-left)](https://www.star-history.com/#saturndec/waoowaoo&type=date&legend=top-left)\n"
  },
  {
    "path": "caddyfile",
    "content": "# HTTPS 反向代理（在主机上运行，非 Docker 内）\n# 启动方式: caddy run --config Caddyfile\n#\n# 用法:\n#   1. docker compose up -d    (启动 App + MySQL + Redis)\n#   2. caddy run --config Caddyfile  (启动 HTTPS 代理)\n#   3. 打开 https://localhost:4443 或 https://your-ip:4443\n#\n# 修改下方 IP 为你的局域网 IP（ifconfig en0 查看）\n# 例如: localhost:1443, https://192.168.x.x:1443 {\n\nlocalhost:1443 {\n    handle /admin/queues* {\n        reverse_proxy localhost:13010\n    }\n    handle {\n        reverse_proxy localhost:13000\n    }\n    tls internal\n}"
  },
  {
    "path": "debug-request.json",
    "content": "{\n  \"model\": \"doubao-seedream-4-0-250828\",\n  \"prompt\": \"Lily and Olivia in 医院病房_日夜, medium shot, dramatic lighting, American comic style\",\n  \"sequential_image_generation\": \"disabled\",\n  \"response_format\": \"url\",\n  \"size\": \"1080x1920\",\n  \"stream\": false,\n  \"watermark\": false\n}"
  },
  {
    "path": "docker-compose.test.yml",
    "content": "services:\n  mysql:\n    image: mysql:8.0\n    container_name: waoowaoo-test-mysql\n    environment:\n      MYSQL_ROOT_PASSWORD: root\n      MYSQL_DATABASE: waoowaoo_test\n      MYSQL_ROOT_HOST: \"%\"\n    ports:\n      - \"3307:3306\"\n    command:\n      - \"--default-authentication-plugin=mysql_native_password\"\n      - \"--sql_mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION\"\n    healthcheck:\n      test: [\"CMD-SHELL\", \"mysqladmin ping -h 127.0.0.1 -uroot -proot\"]\n      interval: 5s\n      timeout: 5s\n      retries: 30\n      start_period: 15s\n\n  redis:\n    image: redis:7-alpine\n    container_name: waoowaoo-test-redis\n    ports:\n      - \"6380:6379\"\n    command: [\"redis-server\", \"--appendonly\", \"no\"]\n    healthcheck:\n      test: [\"CMD\", \"redis-cli\", \"ping\"]\n      interval: 5s\n      timeout: 5s\n      retries: 30\n      start_period: 5s\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  # ==================== MySQL ====================\n  mysql:\n    image: mysql:8.0\n    container_name: waoowaoo-mysql\n    restart: unless-stopped\n    environment:\n      MYSQL_ROOT_PASSWORD: waoowaoo123\n      MYSQL_DATABASE: waoowaoo\n      MYSQL_ROOT_HOST: \"%\"\n    ports:\n      - \"13306:3306\"\n    volumes:\n      - mysql_data:/var/lib/mysql\n    command:\n      - \"--default-authentication-plugin=mysql_native_password\"\n      - \"--sql_mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION\"\n    healthcheck:\n      test: [\"CMD-SHELL\", \"mysqladmin ping -h 127.0.0.1 -uroot -pwaoowaoo123\"]\n      interval: 5s\n      timeout: 5s\n      retries: 30\n      start_period: 15s\n\n  # ==================== Redis ====================\n  redis:\n    image: redis:7-alpine\n    container_name: waoowaoo-redis\n    restart: unless-stopped\n    ports:\n      - \"16379:6379\"\n    volumes:\n      - redis_data:/data\n    command: [\"redis-server\", \"--appendonly\", \"yes\"]\n    healthcheck:\n      test: [\"CMD\", \"redis-cli\", \"ping\"]\n      interval: 5s\n      timeout: 5s\n      retries: 30\n      start_period: 5s\n\n  # ==================== MinIO ====================\n  minio:\n    image: minio/minio:RELEASE.2025-02-28T09-55-16Z\n    container_name: waoowaoo-minio\n    restart: unless-stopped\n    environment:\n      MINIO_ROOT_USER: minioadmin\n      MINIO_ROOT_PASSWORD: minioadmin\n    command: server /data --console-address \":9001\"\n    ports:\n      - \"19000:9000\"\n      - \"19001:9001\"\n    volumes:\n      - minio_data:/data\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http://127.0.0.1:9000/minio/health/live\"]\n      interval: 5s\n      timeout: 5s\n      retries: 30\n      start_period: 10s\n\n  # ==================== App (Next.js + Workers) ====================\n  app:\n    image: ghcr.io/saturndec/waoowaoo:latest\n    container_name: waoowaoo-app\n    restart: unless-stopped\n    environment:\n      # 数据库（指向容器内部 MySQL，用服务名 mysql 而非 localhost）\n      DATABASE_URL: \"mysql://root:waoowaoo123@mysql:3306/waoowaoo\"\n      # Redis（指向容器内部 Redis，用服务名 redis）\n      REDIS_HOST: redis\n      REDIS_PORT: \"6379\"\n      REDIS_USERNAME: \"\"\n      REDIS_PASSWORD: \"\"\n      REDIS_TLS: \"\"\n      # 存储：默认 MinIO（S3 兼容）\n      STORAGE_TYPE: minio\n      MINIO_ENDPOINT: \"http://minio:9000\"\n      MINIO_REGION: \"us-east-1\"\n      MINIO_BUCKET: \"waoowaoo\"\n      MINIO_ACCESS_KEY: \"minioadmin\"\n      MINIO_SECRET_KEY: \"minioadmin\"\n      MINIO_FORCE_PATH_STYLE: \"true\"\n      # 外部访问地址（浏览器实际访问）\n      NEXTAUTH_URL: \"http://localhost:13000\"\n      # 容器内自调用地址（服务端 fetch 自己的 API / 文件）\n      INTERNAL_APP_URL: \"http://127.0.0.1:3000\"\n      NEXTAUTH_SECRET: \"waoowaoo-default-secret-2026\"\n      # 内部密钥\n      CRON_SECRET: \"waoowaoo-docker-cron-secret\"\n      INTERNAL_TASK_TOKEN: \"waoowaoo-docker-task-token\"\n      API_ENCRYPTION_KEY: \"waoowaoo-opensource-fixed-key-2026\"\n      # Worker 配置\n      WATCHDOG_INTERVAL_MS: \"30000\"\n      TASK_HEARTBEAT_TIMEOUT_MS: \"90000\"\n      QUEUE_CONCURRENCY_IMAGE: \"50\"\n      QUEUE_CONCURRENCY_VIDEO: \"50\"\n      QUEUE_CONCURRENCY_VOICE: \"20\"\n      QUEUE_CONCURRENCY_TEXT: \"50\"\n      # Bull Board\n      BULL_BOARD_HOST: \"0.0.0.0\"\n      BULL_BOARD_PORT: \"3010\"\n      BULL_BOARD_BASE_PATH: \"/admin/queues\"\n      BULL_BOARD_USER: \"\"\n      BULL_BOARD_PASSWORD: \"\"\n      # 日志\n      LOG_UNIFIED_ENABLED: \"true\"\n      LOG_LEVEL: \"INFO\"\n      LOG_FORMAT: \"json\"\n      LOG_DEBUG_ENABLED: \"false\"\n      LOG_AUDIT_ENABLED: \"true\"\n      LOG_SERVICE: \"waoowaoo\"\n      LOG_REDACT_KEYS: \"password,token,apiKey,apikey,authorization,cookie,secret,access_token,refresh_token\"\n      # 计费\n      BILLING_MODE: \"OFF\"\n      # 流式\n      LLM_STREAM_EPHEMERAL_ENABLED: \"true\"\n    ports:\n      - \"13000:3000\"\n      - \"13010:3010\"\n    volumes:\n      - ./data:/app/data\n      - ./docker-logs:/app/logs\n    depends_on:\n      mysql:\n        condition: service_healthy\n      redis:\n        condition: service_healthy\n      minio:\n        condition: service_healthy\n    command: >\n      sh -c \"\n        npx prisma db push --skip-generate &&\n        (sleep 5 && echo '' &&\n        echo '╔══════════════════════════════════════════════════╗' &&\n        echo '║            waoowaoo is ready!                    ║' &&\n        echo '║                                                  ║' &&\n        echo '║  HTTP:  http://localhost:13000                    ║' &&\n        echo '║                                                  ║' &&\n        echo '║  For HTTPS, run Caddy on host:                   ║' &&\n        echo '║  caddy run --config Caddyfile                    ║' &&\n        echo '║  Then open: https://localhost:1443                ║' &&\n        echo '╚══════════════════════════════════════════════════╝' &&\n        echo '') &\n        npm run start\n      \"\n\nvolumes:\n  mysql_data:\n  redis_data:\n  minio_data:\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import { dirname } from \"path\";\nimport { fileURLToPath } from \"url\";\nimport { FlatCompat } from \"@eslint/eslintrc\";\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst compat = new FlatCompat({\n  baseDirectory: __dirname,\n});\n\nconst eslintConfig = [\n  ...compat.extends(\"next/core-web-vitals\", \"next/typescript\"),\n  {\n    ignores: [\n      \"node_modules/**\",\n      \".agent/**\",\n      \".next/**\",\n      \"out/**\",\n      \"build/**\",\n      \"coverage/**\",\n      \"next-env.d.ts\",\n    ],\n  },\n  {\n    files: [\"src/**/*.{ts,tsx}\"],\n    ignores: [\"src/components/ui/icons/**\"],\n    rules: {\n      \"no-restricted-imports\": [\n        \"error\",\n        {\n          paths: [\n            {\n              name: \"lucide-react\",\n              message: \"Import icons through '@/components/ui/icons' only.\",\n            },\n          ],\n        },\n      ],\n      \"no-restricted-syntax\": [\n        \"error\",\n        {\n          selector: \"JSXOpeningElement[name.name='svg']\",\n          message:\n            \"Use AppIcon or icons module components instead of inline <svg>.\",\n        },\n      ],\n    },\n  },\n];\n\nexport default eslintConfig;\n"
  },
  {
    "path": "extract_chinese.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\n提取React/TypeScript代码中的硬编码中文字符串\n\"\"\"\nimport re\nimport os\nfrom pathlib import Path\nimport json\n\ndef extract_chinese_strings(file_path):\n    \"\"\"提取文件中的中文字符串\"\"\"\n    try:\n        with open(file_path, 'r', encoding='utf-8') as f:\n            content = f.read()\n    except:\n        return []\n    \n    results = []\n    \n    # 匹配JSX/TSX中的中文字符串\n    # 1. {' 中文 '} 或 {\"中文\"}\n    pattern1 = r'\\{\\s*[\\'\"]([^\\'\"\\{\\}]*[\\u4e00-\\u9fff]+[^\\'\"\\{\\}]*)[\\'\\\"]\\s*\\}'\n    # 2. >中文< \n    pattern2 = r'\\>([^<\\>]*[\\u4e00-\\u9fff]+[^<\\>]*)\\<'\n    # 3. placeholder=\"中文\" 等属性\n    pattern3 = r'(?:placeholder|title|alt|value|defaultValue|confirmText|cancelText|message)\\s*=\\s*[\\'\"]([^\\'\\\"]*[\\u4e00-\\u9fff]+[^\\'\\\"]*)[\\'\"]'\n    # 4. 字符串默认值 = '中文'\n    pattern4 = r'=\\s*[\\'\"]([^\\'\\\"]*[\\u4e00-\\u9fff]+[^\\'\\\"]*)[\\'\"]'\n    \n    for pattern in [pattern1, pattern2, pattern3, pattern4]:\n        matches = re.finditer(pattern, content)\n        for match in matches:\n            chinese_text = match.group(1).strip()\n            if chinese_text and len(chinese_text) > 0:\n                # 跳过注释\n                line_num = content[:match.start()].count('\\n') + 1\n                line = content.split('\\n')[line_num - 1]\n                if '//' in line and line.index('//') < line.find(chinese_text):\n                    continue\n                results.append({\n                    'text': chinese_text,\n                    'line': line_num,\n                    'category': 'unknown'\n                })\n    \n    # 去重\n    seen = set()\n    unique_results = []\n    for r in results:\n        key = f\"{r['text']}_{r['line']}\"\n        if key not in seen:\n            seen.add(key)\n            unique_results.append(r)\n    \n    return unique_results\n\ndef scan_directory(base_path,exclude_patterns=['test-ui']):\n    \"\"\"扫描目录中的所有TSX/TS文件\"\"\"\n    all_findings = {}\n    \n    for root, dirs, files in os.walk(base_path):\n        # 排除特定目录\n        dirs[:] = [d for d in dirs if d not in exclude_patterns and not d.startswith('.')]\n        \n        for file in files:\n            if file.endswith(('.tsx', '.ts')):\n                file_path = os.path.join(root, file)\n                relative_path = os.path.relpath(file_path, base_path)\n                \n                findings = extract_chinese_strings(file_path)\n                if findings:\n                    all_findings[relative_path] = findings\n    \n    return all_findings\n\nif __name__ == '__main__':\n    base_dir = 'src'\n    results = scan_directory(base_dir)\n    \n    # 输出结果\n    total = 0\n    for file_path, findings in sorted(results.items()):\n        if findings:\n            print(f\"\\n## {file_path} ({len(findings)} strings)\")\n            for finding in findings[:10]:  # 只显示前10个\n                print(f\"  Line {finding['line']}: {finding['text'][:60]}\")\n            total += len(findings)\n            if len(findings) > 10:\n                print(f\"  ... and {len(findings) - 10} more\")\n    \n    print(f\"\\n\\n总计: {len(results)} 个文件, {total} 处硬编码中文\")\n"
  },
  {
    "path": "lib/prompts/character-reference/character_image_to_description.en.txt",
    "content": "# Character Image To Description Prompt\n\nAnalyze the provided character image and write one detailed English visual description for image generation.\n\n## Required content\nInclude all of the following:\n1. Gender and approximate age range\n2. Hair style and hair color\n3. Face shape and facial features\n4. Body build and silhouette\n5. Clothing style and clothing details\n6. Accessories and signature details\n7. Overall style keywords\n\n## Missing-part completion\nIf the image only shows part of the body, infer the missing parts consistently.\n- Missing lower body: infer matching pants/skirt style\n- Missing shoes: infer shoes that fit the outfit\n- Missing accessory details: infer a few reasonable accessories\n\n## Forbidden content\nDo not mention:\n- Skin color\n- Eye color\n- Facial expression\n- Pose or action\n- Background\n\n## Output format\nReturn one plain English paragraph only (about 120-220 words).\nDo not return markdown, bullets, titles, or JSON.\n"
  },
  {
    "path": "lib/prompts/character-reference/character_image_to_description.zh.txt",
    "content": "# 图片反推角色描述提示词\n\n请分析这张角色图片，生成一段详细的角色外貌描述（用于 AI 图片生成）。\n\n## 输出要求\n\n生成一段完整的角色视觉描述，包含以下要素：\n\n1. 性别和年龄段（如：约二十五岁的男性）\n2. 发型发色（如：黑色短发、微卷的棕色长发）\n3. 脸型五官特征（如：剑眉星目、高鼻梁、薄唇）\n4. 体态身材（如：身形修长、体格健壮）\n5. 服装风格（如：深蓝色西装、白色衬衫、皮质腰带）\n6. 配饰特征（如：左手戴银色手表、胸前别金色胸针）\n7. 整体气质关键词（如：精英气质、禁欲系、高冷、温柔暖男）\n\n## 缺失内容补齐规则\n\n如果参考图只展示了部分身体（如上半身、头像），请根据已有信息合理推断并补全：\n- **缺少下半身**：根据上衣风格推断裤装/裙装类型（如西装上衣配深蓝色西裤、休闲上衣配牛仔裤）\n- **缺少鞋子**：根据整体穿搭风格推断鞋款（如正装配皮鞋、休闲装配运动鞋或帆布鞋）\n- **缺少配饰细节**：根据角色气质合理添加配饰（如商务风配手表、休闲风配手环）\n\n## 禁止描写\n\n- 皮肤颜色\n- 眼睛颜色\n- 表情\n- 动作\n- 背景\n- 姿势\n\n## 输出格式\n\n一段连贯的描述文字，约200-300字，直接可用于图片生成提示词。\n只返回描述文字，不要有任何标题、序号或其他格式。\n"
  },
  {
    "path": "lib/prompts/character-reference/character_reference_to_sheet.en.txt",
    "content": "# Reference Image To Character Sheet Prompt (img2img)\n\nUse the provided reference image to extract face traits, hairstyle, body shape, and outfit structure.\n\n## Style priority\nFollow the user-selected style instruction as the highest-priority rule.\nUse reference images only to preserve character identity traits (face, hairstyle, body shape, outfit structure), and do not let reference style override the requested style.\nOnly if no explicit style instruction is provided, you may preserve the original visual style.\n\n## Generation rules\n1. Ignore the original image color cast and lighting defects\n2. Use clean, soft studio lighting\n3. Keep natural, aesthetically correct body proportions\n4. Do not copy blur, noise, compression artifacts, or defects\n5. Output must be clear, sharp, and production-quality\n6. Character expression should be neutral and calm, looking at camera\n\n## Missing-part completion\nIf only half body or partial body is visible, infer and complete hidden parts consistently:\n- Infer matching lower-body clothing\n- Infer suitable footwear\n- Infer missing hands/arms in a natural way\n"
  },
  {
    "path": "lib/prompts/character-reference/character_reference_to_sheet.zh.txt",
    "content": "# 参考图转角色设定图提示词（图生图模式）\n\n基于提供的参考图片，提取角色的面部五官特征、发型、体型和服装款式作为参考。\n\n## 画风优先级规则\n\n画风由用户选择的风格指令决定，严格遵循风格指令进行生成。\n参考图仅用于保持角色身份特征（五官、发型、体型、服装结构），不能覆盖用户指定画风。\n仅在未提供风格指令时，才可参考原图画风。\n\n## 生成规则\n\n1. 忽略原图的具体色调和光线\n2. 使用自然柔和的摄影棚灯光\n3. 绘制正常美观的人体比例\n4. 不要复制原图的画质、模糊、噪点或瑕疵\n5. 生成的图像必须清晰锐利、细节丰富、专业品质\n6. 角色表情应为自然平静的中性表情，目光正视镜头\n\n## 缺失部位自动补齐\n\n如果参考图是半身或部分身体，请根据服装风格和人物特征合理补全未露出的部位：\n- **缺少下半身**：根据上衣风格推断并绘制匹配的裤装/裙装\n- **缺少脚部**：根据整体穿搭风格添加合适的鞋款\n- **缺少手部/手臂**：根据姿态合理补全\n- 保持整体风格一致，确保补全的部分与可见部分协调统一\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_acting_direction.en.txt",
    "content": "You are an experienced Acting Director.\nYour task is to generate acting notes for each character in each panel.\n\nCore input:\n- Total panel count: {panel_count}\n- Panels JSON:\n{panels_json}\n- Character info:\n{characters_info}\n\nRequirements:\n1. Treat each panel independently. The same character can have different emotional states across panels.\n2. Adapt performance style to panel.scene_type (daily / emotion / action / epic / suspense).\n3. For each character, write one concise visual acting instruction including:\n   - emotional state (visible, not abstract)\n   - facial expression details\n   - body language / posture\n   - micro action and gaze direction\n4. Use only observable descriptions. Avoid abstract words like \"sad\" without visual evidence.\n\nOutput format (JSON array only):\n[\n  {\n    \"panel_number\": 1,\n    \"characters\": [\n      {\n        \"name\": \"Character Name\",\n        \"acting\": \"One-sentence visual acting direction\"\n      }\n    ]\n  }\n]\n\nStrict constraints:\n1. Return JSON only, no markdown.\n2. Array length must equal {panel_count}.\n3. Each character object must contain only \"name\" and \"acting\".\n4. Keep character names exactly consistent with panel input.\n5. ⚠️ JSON SAFETY: All quotation marks in text (\"\"''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_acting_direction.zh.txt",
    "content": "你是一位经验丰富的表演指导(Acting Director)。你的任务是为一组分镜中的**每个镜头**设计角色的表演细节。\n\n【核心职责】\n\n分析整组分镜后，为每个镜头中的角色用一句话描述完整的表演指令，包含：\n- 情绪状态与强度\n- 面部表情细节\n- 肢体语言与姿态\n- 微动作与视线\n\n【重要】每个镜头的表演必须是独立的！\n- 同一角色在不同镜头可能有不同情绪变化\n- 表演风格匹配 scene_type（日常/情感/动作/史诗/悬疑）\n\n【表演风格匹配 scene_type】\n\n**daily（日常）**：自然松弛，微表情为主，动作幅度小\n**emotion（情感）**：细腻层次，眼神戏份重，情绪渐进\n**action（动作）**：爆发力强，动作干脆，表情夸张\n**epic（史诗）**：庄重仪式感，姿态端正，动作缓慢有力\n**suspense（悬疑）**：紧绷警觉，肢体僵硬，眼神游移\n\n【表演描述词库】\n\n**表情**：眼眶泛红、眉头紧锁、嘴角上扬、目光闪躲、瞳孔收缩、嘴唇颤抖、咬紧牙关\n**肢体**：握紧拳头、身体前倾、双手交握、肩膀耸起、转身背对、后退一步\n**微动作**：轻轻眨眼、咽口水、深呼吸、手指轻颤、舔嘴唇、胸口起伏\n\n【⚠️ 禁止规则】\n\n1. 禁止抽象情绪词：悲伤、愤怒、紧张 → 改用可见表现\n2. 禁止身份称呼：母亲、父亲 → 改用角色名\n\n【输出格式】\n\n返回JSON数组，每个镜头一个对象，每个角色只有 name + acting 两个字段：\n\n[\n  {\n    \"panel_number\": 1,\n    \"characters\": [\n      {\n        \"name\": \"李凤华\",\n        \"acting\": \"嘴角微扬眼神柔和地看向景笙，身体微微前倾，双手自然垂放，轻轻眨眼\"\n      },\n      {\n        \"name\": \"景笙\",\n        \"acting\": \"面带微笑但眼神略显疏离，站姿笔直双手背后，轻轻点头\"\n      }\n    ]\n  },\n  {\n    \"panel_number\": 2,\n    \"characters\": [\n      {\n        \"name\": \"李凤华\",\n        \"acting\": \"眉头轻皱嘴唇紧抿，双手交握在身前肩膀微耸，目光低垂看向地面，手指轻微交缠\"\n      }\n    ]\n  },\n  {\n    \"panel_number\": 3,\n    \"characters\": [\n      {\n        \"name\": \"李凤华\",\n        \"acting\": \"眼眶泛红泪水打转嘴唇颤抖，身体微微发抖双手攥紧衣角，快速眨眼忍住泪水转头避开对方视线\"\n      }\n    ]\n  }\n]\n\n【输入数据】\n\n分镜数据（共 {panel_count} 个镜头）：\n{panels_json}\n\n角色信息：\n{characters_info}\n\n【严格要求】\n\n1. 只返回JSON数组，不要有markdown代码块标记\n2. 数组长度必须等于输入的镜头数量（{panel_count}个）\n3. 每个角色只有 name 和 acting 两个字段\n4. acting 用一句话描述完整表演（表情+肢体+微动作+视线）\n5. 角色名必须与输入分镜中的 characters 完全一致\n6. 所有描述必须是可视化的，禁止抽象情绪词\n7. 根据 scene_type 调整表演风格\n8. 情绪弧线连贯，前后镜头有合理递进\n9. ⚠️ JSON安全：所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"\n\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_character_profile.en.txt",
    "content": "You are a casting and character-asset analyst.\nAnalyze the input text and produce structured character profiles for visual production.\n\nInput text:\n{input}\n\nExisting character library info:\n{characters_lib_info}\n\nGoals:\n1. Identify characters that should appear visually.\n2. Exclude pure background extras and abstract entities.\n3. Build profile fields needed for downstream visual generation.\n4. Capture naming/alias mapping, especially first-person references.\n\nExtraction rules:\n1. Include characters that speak, act, or significantly drive plot.\n2. Exclude one-off nameless background people unless visual identity is required.\n3. Resolve aliases/titles (e.g., \"my husband\", \"boss\", \"I\") to canonical names when possible.\n4. For first-person narrative, explicitly document who \"I\" maps to in introduction.\n\nField rules:\n- role_level: one of S/A/B/C/D based on narrative importance\n- costume_tier: 1-5 based on social identity (not role_level)\n- expected_appearances: always include at least one initial appearance\n- introduction: include identity, relationship mapping, and common address mapping\n\nOutput format (JSON only):\n{\n  \"characters\": [\n    {\n      \"name\": \"Canonical Name\",\n      \"aliases\": [\"alias 1\", \"alias 2\"],\n      \"introduction\": \"Role, perspective mapping, relationships, and naming aliases\",\n      \"gender\": \"male/female/other\",\n      \"age_range\": \"young adult\",\n      \"role_level\": \"S\",\n      \"archetype\": \"character archetype\",\n      \"personality_tags\": [\"tag1\", \"tag2\"],\n      \"era_period\": \"modern/fantasy/historical/sci-fi\",\n      \"social_class\": \"elite/middle/common\",\n      \"occupation\": \"occupation or none\",\n      \"costume_tier\": 3,\n      \"suggested_colors\": [\"color1\", \"color2\"],\n      \"primary_identifier\": \"signature visual marker\",\n      \"visual_keywords\": [\"keyword1\", \"keyword2\"],\n      \"expected_appearances\": [\n        { \"id\": 1, \"change_reason\": \"initial appearance\" }\n      ]\n    }\n  ],\n  \"new_characters\": [\n    {\n      \"name\": \"Canonical Name\",\n      \"aliases\": [\"alias 1\", \"alias 2\"],\n      \"introduction\": \"Role, perspective mapping, relationships, and naming aliases\",\n      \"gender\": \"male/female/other\",\n      \"age_range\": \"young adult\",\n      \"role_level\": \"S\",\n      \"archetype\": \"character archetype\",\n      \"personality_tags\": [\"tag1\", \"tag2\"],\n      \"era_period\": \"modern/fantasy/historical/sci-fi\",\n      \"social_class\": \"elite/middle/common\",\n      \"occupation\": \"occupation or none\",\n      \"costume_tier\": 3,\n      \"suggested_colors\": [\"color1\", \"color2\"],\n      \"primary_identifier\": \"signature visual marker\",\n      \"visual_keywords\": [\"keyword1\", \"keyword2\"],\n      \"expected_appearances\": [\n        { \"id\": 1, \"change_reason\": \"initial appearance\" }\n      ]\n    }\n  ],\n  \"updated_characters\": [\n    {\n      \"name\": \"Existing Canonical Name\",\n      \"updated_introduction\": \"Updated intro with newly discovered mapping\",\n      \"updated_aliases\": [\"new alias 1\", \"new alias 2\"]\n    }\n  ]\n}\n\nStrict constraints:\n1. JSON only.\n2. If no valid character is found, return empty arrays.\n3. Keep all strings in English.\n4. ⚠️ JSON SAFETY: All quotation marks in text (\"\"''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_character_profile.zh.txt",
    "content": "你是专业的\"选角指导\"。请基于提供的文本（小说、剧本或混合格式），分析并输出所有需要制作形象的角色档案信息。\n\n【你的职责】\n- 识别需要在画面中出现的角色\n- 根据剧情发展和角色身份判断每个角色的重要性层级\n- 分析角色的性格和背景\n- 输出结构化的角色档案（供后续视觉生成使用）\n- ⚠️ 分析角色之间的关系、称呼映射，生成角色介绍（introduction）\n\n【筛选规则 - 精准提取模式】\n\n✅【必须提取的角色】：\n   - 剧本人物行中列出的角色\n   - 有台词且参与剧情互动的角色\n   - 贯穿故事主线的核心人物\n   - 对剧情有实际推动作用的配角\n   - 在画面中需要出镜的角色\n\n❌【不提取的角色】：\n   - 无名无特征的纯路人（如\"人群中的某人\"）\n   - 仅被提及但从未出场的角色\n   - 没有台词也没有互动的背景人物\n   - 意境描述中的虚构存在（如\"命运\"、\"死神的化身\"）\n\n📋【判断标准】：\n   问自己：这个角色是否需要制作形象图？是否在画面中有实际出镜？\n   如果答案是否定的，则不提取。\n\n【角色介绍 introduction 规则 ⭐重要】\n\n每个角色必须有 introduction 字段，用于帮助后续 AI 正确识别角色。包含：\n\n1. **叙述视角映射**：\n   - 如果是第一人称叙述，明确说明\"我\"对应此角色\n   - 示例：\"本角色是故事主角，小说以第一人称'我'叙述\"\n\n2. **角色身份定位**：\n   - 描述角色在故事中的身份（主角/配角/反派等）\n   - 示例：\"女主角，公司秘书\"\n\n3. **角色关系**：\n   - 与其他主要角色的关系\n   - 示例：\"林墨的妻子，张三的女儿\"\n\n4. **称呼映射**：\n   - 其他角色对此角色的常用称呼\n   - 示例：\"被林墨称呼为'老婆'、'晴晴'，被张三称呼为'闺女'\"\n\n示例 introduction：\n\"故事主角，小说以第一人称'我'叙述，真名林墨。苏晴的丈夫，张三的女婿。被苏晴称呼为'老公'、'墨哥'，被下属称呼为'林总'。\"\n\n【角色重要性层级判断规则】\n\n⚠️ 重要：根据角色在剧情中的戏份和身份地位来判断，不是根据外表华丽程度！\n\nS级（绝对主角）：\n   - 故事的核心视角人物，剧情围绕其展开\n   - 第一人称叙述中的\"我\"通常是S级\n   - 判断依据：戏份最重、出场最多、剧情主线与其紧密相关\n\nA级（核心配角）：\n   - 与主角有大量互动的重要角色\n   - 男二号、女二号、主要反派等\n   - 判断依据：对主线剧情有重大影响、戏份仅次于主角\n\nB级（重要配角）：\n   - 多次出场、有名有姓、推动某条支线剧情\n   - 判断依据：有一定戏份、对剧情有贡献\n\nC级（次要角色）：\n   - 偶尔出场、戏份较少但有具体形象\n   - 判断依据：需要出镜但戏份不多\n\nD级（群众演员）：\n   - 有短暂出镜需求的小角色\n   - 判断依据：仅在个别场景出现\n\n【服装华丽度层级 costume_tier】\n\n⚠️ 服装华丽度由角色的社会身份和剧情设定决定，不是由重要性决定！\n   - 主角可以是朴素穿着（如穷学生主角=tier 2）\n   - 配角可以是华丽服装（如富家公子配角=tier 5）\n\n5级（皇室/顶奢级）：皇室成员、顶级富豪，服装极致华丽，有精美的刺绣、镶嵌或定制剪裁。\n4级（贵族/精英级）：贵族、企业家，服装精致考究，使用高档面料和精致细节。\n3级（专业/品质级）：中产阶级、专业人士，服装得体有品，剪裁讲究。\n2级（日常/普通级）：普通人、学生，服装简洁日常，款式普通但整洁。\n1级（朴素/统一级）：平民、底层劳动者，服装朴素统一，基础款式，功能性为主。\n\n【角色原型 archetype 参考词库】\n\n正派角色可以选择：霸道总裁、高冷学霸、温柔暖男、励志少年、贤惠女主、独立女强人、忠诚护卫等。\n\n反派角色可以选择：心机婊、白莲花、阴险反派、疯批美人、复仇者等。\n\n其他类型：傲娇公主、病娇、腹黑、毒舌、话痨、冷面热心、闷骚等。\n\n【性格标签 personality_tags 参考词库】\n\n气质类标签：高冷、温柔、阳光、忧郁、神秘、妩媚、清冷、热情\n\n性格类标签：腹黑、傲娇、毒舌、话痨、闷骚、直爽、圆滑、固执\n\n态度类标签：自信、自卑、孤僻、合群、叛逆、顺从\n\n【视觉关键词 visual_keywords 参考词库】\n\n风格类关键词：精英气质、街头潮流、学院风、复古优雅、运动活力、文艺气息、冷淡极简\n\n特征类关键词：病弱感、禁欲系、狼狗系、奶狗系、御姐范、萝莉感、大叔味\n\n【色彩建议规则】\n\n根据角色类型选择合适的色彩：\n\n正派主角适合白色、蓝色、金色或浅色系，传达正义和光明感。\n\n反派角色适合黑色、暗红、深紫或暗色系，营造神秘或压迫感。\n\n温柔角色适合米白、淡粉、浅绿等柔和色，体现温暖亲和。\n\n冷酷角色适合黑色、灰色、深蓝等冷色调，强调距离感。\n\n活泼角色适合橙色、黄色等亮色系，展现活力和热情。\n\n【辨识标志设计规则】\n\n为S级和A级角色设计一眼就能认出的标志性特征：\n\n面部标志：眼角泪痣、剑眉、刀疤、胎记等独特面部特征。\n\n发型标志：白发、挑染、独特发型、发带等醒目的头发特征。\n\n服装标志：永远穿红色、标志性围巾、招牌外套等固定的服装元素。\n\n配饰标志：家传戒指、从不摘下的项链、拐杖等标志性物品。\n\n【子形象筛选规则 - 识别视觉外观变化 ⭐重要】\n\n分析原文中角色是否有多个视觉形态，输出到 expected_appearances 字段。\n\n✅ 需要记录的子形象（视觉上可见的变化）：\n   - 衣着变化：换装、更换正装/休闲装、穿戴盔甲等\n   - 年龄变化：穿越、回忆场景中的年轻/年老状态\n   - 特殊装扮：出浴（围浴巾）、冒充他人的装扮\n   - 发型改变：剪头、编发、盘发、披发等持续性外观变化\n\n❌ 不需要记录的（非视觉或临时状态）：\n   - 情绪/心理状态：生气、开心、难过、紧张\n   - 健康状态：生病、发烧（除非有明显视觉特征如绷带）\n   - 临时动作：跑步、跳跃、战斗姿势\n   - 模糊描述：\"蒙上了一层阴影\"\"眼神变了\"等抽象描述\n   - 临时特效/光影状态：散发光芒、身上发光、气场外放、浑身火焰、佛光环绕、金光闪闪等后期可添加的特效\n   - 战斗技能释放：发功、运功、施法、放大招、释放法术等技能状态\n   - 一次性瞬间状态：被打飞、摔倒、中招、受击等不持续的状态\n\n⚠️ 判断标准：\n   - 如果一个状态无法通过换装来体现，就不需要记录\n   - 如果一个状态是通过后期特效（如发光、粒子、光环、火焰等）来表现的，不需要记录\n   - 如果一个状态只持续几秒而非整个场景，不需要记录\n   - 只有持续性的、需要重新制作人物形象图的外观变化才需要记录\n\n📋 expected_appearances 格式：\n   - 每个角色必须至少有一个 id=1 的\"初始形象\"\n   - 如有换装/年龄变化等，添加 id=2, 3... 的子形象\n   - change_reason 简要说明变化原因（如\"出浴状态\"、\"战斗装束\"、\"年老回忆\"）\n\n【已有资产库】\n\n⚠️ 重要：请仔细阅读已有角色的介绍，判断新发现的角色名是否与已有角色是同一人！\n\n{characters_lib_info}\n\n【输出格式 - 支持新增和更新】\n\n只返回JSON，禁止任何markdown标记或注释。\n\n输出包含两个数组：\n- new_characters: 新发现的角色\n- updated_characters: 需要更新介绍的已有角色（如发现了新的称呼、关系、或真名）\n\n{\n  \"new_characters\": [\n    {\n      \"name\": \"角色名\",\n      \"aliases\": [\"别名1\", \"别名2\"],\n      \"introduction\": \"角色介绍：身份定位、叙述视角映射、与其他角色的关系、常用称呼\",\n      \"gender\": \"男/女\",\n      \"age_range\": \"约二十五岁\",\n      \"role_level\": \"S/A/B/C/D\",\n      \"archetype\": \"角色原型（如霸道总裁）\",\n      \"personality_tags\": [\"高冷\", \"腹黑\"],\n      \"era_period\": \"现代都市/古代唐朝/未来科幻\",\n      \"social_class\": \"上层精英/中产/平民\",\n      \"occupation\": \"企业家/学生/无\",\n      \"costume_tier\": 5,\n      \"suggested_colors\": [\"深蓝\", \"金色\"],\n      \"primary_identifier\": \"眼角泪痣（仅S/A级需要）\",\n      \"visual_keywords\": [\"精英气质\", \"禁欲系\"],\n      \"expected_appearances\": [\n        {\"id\": 1, \"change_reason\": \"初始形象\"},\n        {\"id\": 2, \"change_reason\": \"换装/特殊状态的原因（如有）\"}\n      ]\n    }\n  ],\n  \"updated_characters\": [\n    {\n      \"name\": \"已有角色名（必须与资产库中的名字完全一致）\",\n      \"updated_introduction\": \"更新后的角色介绍（补充新发现的关系、称呼、真名等）\",\n      \"updated_aliases\": [\"新发现的别名1\", \"新发现的别名2\"]\n    }\n  ]\n}\n\n【更新规则】\n\n⚠️ 什么情况下应该更新已有角色（放入 updated_characters）：\n\n1. **发现真名**：之前只有\"我\"，现在发现\"我\"的真名是\"林墨\"\n   → 更新 introduction 说明映射，添加 updated_aliases: [\"林墨\"]\n\n2. **发现新称呼**：之前不知道别人怎么称呼这个角色，现在发现有人叫他\"林总\"\n   → 更新 introduction 添加称呼信息，添加 updated_aliases: [\"林总\"]\n\n3. **发现新关系**：之前不知道角色间的关系，现在发现苏晴是林墨的妻子\n   → 更新双方的 introduction 添加关系信息\n\n4. **不要重复创建**：如果发现\"林墨\"其实就是已有的\"我\"，不要创建新角色，而是更新\"我\"的介绍和别名\n\n【严格要求】\n1. 只返回JSON，不得有其他文字\n2. role_level 必须是 S/A/B/C/D 之一\n3. costume_tier 必须是 1-5 的整数\n4. S/A 级角色必须有 primary_identifier\n5. personality_tags 至少2个，最多5个\n6. suggested_colors 2-3个颜色\n7. introduction 必填，描述角色身份、关系、称呼映射\n8. 如果发现已有角色的新信息，放入 updated_characters 而不是创建新角色\n9. updated_characters 中的 name 必须与已有资产库中的名字完全一致\n10. expected_appearances 必填，至少包含 id=1 的初始形象\n11. 只有持续性视觉变化才添加子形象，临时特效/情绪/动作不添加\n12. 输出必须是**严格合法的JSON**：字符串中不能出现原始换行/回车/制表符\n\n⚠️⚠️⚠️【JSON安全输出 - 最高优先级】⚠️⚠️⚠️\n- 原文中的所有引号（\"\"''「」『』等）在 JSON 字符串值中必须统一替换为日式方括号引号「」\n- ❌ 严禁在 JSON 字符串值中出现英文双引号 \" ！会破坏 JSON 结构！\n- ✅ 正确：\"introduction\":\"他被称为「弼马温」\"\n- ❌ 错误：\"introduction\":\"他被称为\"弼马温\"\" ← 内部裸引号破坏JSON\n- 如果字符串内确实需要英文双引号，必须用 \\\" 转义\n\n【原文内容】\n{input}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_character_visual.en.txt",
    "content": "You are a character visual designer.\nGenerate image-ready appearance descriptions from character profiles.\n\nCharacter profiles JSON:\n{character_profiles}\n\nRules:\n1. Keep identity consistency with profile fields.\n2. Convert personality/social identity into visual details (face, hair, outfit, accessories).\n3. Support both human and non-human characters.\n4. Respect era_period and costume_tier.\n5. If primary_identifier exists, include it clearly in each main description.\n6. Do not include expression, action, background, or plot narration.\n7. Do not include skin color, eye color, or lip color.\n\nAppearance strategy:\n- Initial appearance should be complete and self-contained.\n- Additional appearances should focus on visual changes indicated by change_reason.\n- Provide 3 alternative description lines per appearance.\n\nOutput format (JSON only):\n{\n  \"characters\": [\n    {\n      \"name\": \"Character Name\",\n      \"appearances\": [\n        {\n          \"id\": 0,\n          \"change_reason\": \"initial appearance\",\n          \"descriptions\": [\n            \"description variant 1\",\n            \"description variant 2\",\n            \"description variant 3\"\n          ]\n        }\n      ]\n    }\n  ]\n}\n\nStrict constraints:\n1. JSON only.\n2. Keep names exactly aligned with input profiles.\n3. Each descriptions array must contain at least 3 valid English strings.\n4. ⚠️ JSON SAFETY: All quotation marks in text (\"\"''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_character_visual.zh.txt",
    "content": "你是专业的\"角色视觉设计师\"。根据角色档案信息，生成详细的人物外貌描述（用于AI图片生成）。\n\n【你的职责】\n- 根据角色档案生成对应的外貌描述\n- 确保核心角色有明显的视觉辨识度\n- 体现角色性格和身份的视觉特征\n- 服装华丽度由角色身份决定，与重要性无关\n\n【角色类型灵活处理规则】\n\n⚠️ 角色不一定是人类！请根据原文判断角色的实际形态：\n\n**人类角色**：按照下方的面部、发型、体态、服装规范描述\n\n**非人类角色**（动物、神话生物、知名形象等）：\n- 描述开头必须以角色名/物种名开始\n- 根据角色实际形态自由描述外观特征，不受人类模板限制\n- 保持角色的核心辨识特征\n\n示例：\n- 孙悟空 → \"孙悟空，身穿虎皮裙，头戴紧箍咒金环，手持如意金箍棒，毛发金黄蓬松，尖耳竖立，眼神机灵狡黠...\"\n- 蜗牛 → \"蜗牛，背负螺旋形褐色硬壳，壳面有细密纹路，两只细长触角顶端有圆形眼点，身体柔软半透明...\"\n- 龙 → \"东方神龙，鳞片金红交错闪烁，龙须飘逸，鹿角威严分叉，蛇身盘旋腾空，四爪锋利如钩...\"\n- 拟人化动物 → \"狐狸精，保留尖耳毛尾的狐狸特征，身着红色丝绸长裙，九条白色蓬松尾巴在身后舒展...\"\n\n【视觉层级规范】\n\n⚠️ 核心原则：服装华丽度由角色的社会身份和剧情设定决定，不是由重要性等级决定！\n\nS级角色：\n  - 描述长度180-220字\n  - 必须有极高的视觉辨识度和\"主角气质\"\n  - 服装风格由角色身份决定（穷学生可以穿简单校服，但五官气质必须出众）\n\nA级角色：\n  - 描述长度150-180字\n  - 有明显的个人特色和记忆点\n  - 服装风格由角色身份决定\n\nB级角色：\n  - 描述长度120-150字\n  - 有基本的辨识特征\n  - 服装风格符合其社会身份\n\nC级角色：\n  - 描述长度80-120字\n  - 简洁但完整的形象描述\n\nD级角色：\n  - 描述长度50-80字\n  - 基础形象即可\n\n【服装华丽度 costume_tier 对照】\n\n⚠️ 由角色的社会阶层和剧情身份决定，与role_level无关！\n\n5级（皇室/顶奢级）：皇室成员、顶级富豪等，服装有刺绣、镶嵌、定制剪裁、稀有面料。\n4级（贵族/精英级）：贵族、企业家等，高档面料、精致细节、品质配饰。\n3级（专业/品质级）：中产阶级、专业人士，得体剪裁、有设计感。\n2级（日常/普通级）：普通人，简洁日常的款式。\n1级（朴素/统一级）：平民、学生等，基础款式、功能性为主。\n\n【辨识标志应用规则】\n\n如果角色档案中有 primary_identifier，必须在描述中明确体现：\n\n示例：\n- primary_identifier: \"眼角泪痣\" → 描述中必须出现 \"眼角一颗小巧泪痣\"\n- primary_identifier: \"左耳银色耳钉\" → 描述中必须出现 \"左耳佩戴一枚银色耳钉\"\n\n【色彩应用规则】\n\n根据 suggested_colors 选择服装和配饰的主色调：\n- 第一个颜色：主色调（外套/主要服装）\n- 第二个颜色：辅色调（内搭/配饰）\n- 第三个颜色（如有）：点缀色（小配饰/图案）\n\n【性格到视觉的转化规则】\n\n高冷性格的角色应该用利落剪裁、深色调、极简配饰来体现。\n\n温柔性格的角色应该用柔和色调、流畅线条、圆润配饰来体现。\n\n活泼性格的角色应该用亮色系、轻快材质、趣味配饰来体现。\n\n腹黑性格的角色应该用深色内搭、精致细节、不经意的奢华来体现。\n\n傲娇性格的角色应该用华丽但有距离感、高档但不张扬的设计来体现。\n\n叛逆性格的角色应该用皮革金属元素、不对称设计、街头风来体现。\n\n【描述规范】\n\n1. 必须包含（按优先级顺序）：\n\n   🎭 **面部特征（最重要！必须详细）**：\n   - 脸型：瓜子脸、鹅蛋脸、方脸、长脸等具体脸型\n   - 五官组合：眼睛、鼻子、嘴巴、眉毛的形状和特点\n   - 眼睛：双眼皮/单眼皮、眼型、大小\n   - 鼻子：高挺、小巧、笔直、精致等\n   - 嘴唇：薄厚、形状（小巧、丰润）\n   - 眉毛：浓淡、形状（剑眉、柳叶眉）\n   - 独特记号：痣（位置）、雀斑、小疤痕等\n\n   💇 **发型描写（必须详细）**：\n   - 发色：乌黑、深棕、栗色、金棕等\n   - 发长：齐耳短发、及肩、过肩、及腰\n   - 发型：自然披散、高马尾、低马尾、丸子头、盘发、寸头、中分、偏分、背头\n   - 发质：柔顺、自然卷、微卷、蓬松、服帖\n   - 刘海：齐刘海、空气刘海、无刘海、中分刘海、侧分刘海、碎发刘海\n\n   👤 **体态**：\n   - 身形：修长、健硕、纤细、匀称\n   - 身高感：高挑、娇小、适中\n\n   👔 **服装配饰**：\n   - 上衣：款式、材质、配色、细节\n   - 下装：裤子/裙子的款式\n   - 鞋子：款式、颜色（必填！）\n   - 配饰：根据层级添加\n\n⚠️ **主角吸引力要求（关键！）**：\n- S级角色：必须长相出众、五官精致、有独特魅力和气质\n- A级角色：必须长相精致、有吸引力、给人好感\n- 面部和发型描写至少占总描述的40%篇幅\n- 禁止用\"普通\"、\"平凡\"、\"不起眼\"、\"其貌不扬\"等词\n- 主角要有明显的外貌优势（如：剑眉星目、五官立体、轮廓分明等）\n\n2. 禁止描写：\n   ❌ 皮肤颜色（如白皙、小麦色）\n   ❌ 眼睛颜色（如黑色瞳孔）\n   ❌ 唇色（如红润）\n   ❌ 表情、姿态、动作\n   ❌ 背景、环境\n   ❌ 情绪形容词\n   ❌ 抽象气质（如\"气场强大\"）\n   ❌ 不确定描述（如\"可能\"、\"或\"）\n\n3. 可以描写：\n   ✅ 皮肤质感（光滑/粗糙）\n   ✅ 独特标记（雀斑/疤痕/纹身）\n   ✅ 头发颜色\n   ✅ 服装颜色\n\n【年代一致性】\n\n根据 era_period 选择符合时代的服装：\n- 古代：汉服、唐装、宋制等，禁止现代元素\n- 近代（民国）：长衫、旗袍、中山装\n- 现代：西装、休闲装、时装\n- 未来：科技感服装、机能风\n\n【子形象规则】\n\n根据输入的 expected_appearances 生成对应的形象描述：\n\n主形象（id=0）必须是完整描述，包含：\n- 所有基础特征（面部、眼睛、头发、体型等）\n- 初始服装/配饰的完整描述\n- 靴子必填\n\n子形象（id>=1）只描述视觉变化部分，因为会基于主形象图片进行改图：\n- 换装：只写新服装、靴子\n- 年龄变化：写外观差异（皑纹、白发等）\n- 特殊状态：出浴、战斗装等\n- 禁止重复描述面部、体型等基础特征（这些由主形象图片提供）\n\n示例：\n- 主形象（id=0）：\"男性，约二十五岁，剑眉星目，高挺鼻梁，身材高挑健硕。黑色短发利落后梳。身穿深蓝色锦缎长袍，腰系玉带，脚踏黑色皮质长靴。\"\n- 出浴状态（id=1）：\"湿漉漉的头发向后拢去，上半身赤裸，下半身围着白色浴巾，赤脚。\"\n- 战斗装束（id=2）：\"换上黑色劲装，脚蹬厚底战靴。\"\n\n\n【输出格式】\n\n只返回JSON，禁止任何markdown标记：\n\n{\n  \"characters\": [\n    {\n      \"name\": \"角色名\",\n      \"appearances\": [\n        {\n          \"id\": 0,\n          \"descriptions\": [\n            \"完整外貌描述1（按层级要求的字数）\",\n            \"完整外貌描述2（不同风格）\",\n            \"完整外貌描述3（不同风格）\"\n          ],\n          \"change_reason\": \"初始形象\"\n        }\n      ]\n    }\n  ]\n}\n\n【严格要求】\n1. 描述长度必须符合角色层级要求\n2. S/A级角色的辨识标志必须出现在描述中\n3. 服装华丽度必须与 costume_tier 匹配\n4. 三条描述可以自由发挥细节，但整体形象保持一致，不要有过大差异\n5. 每条描述必须包含鞋子\n6. 只返回JSON，不得有其他文字\n7. ⚠️ JSON安全：所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"\n\n【输入数据】\n\n角色档案：\n{character_profiles}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_cinematographer.en.txt",
    "content": "You are a cinematography planner.\nFor each panel, generate a concise photography rule package.\n\nInputs:\n- Panel count: {panel_count}\n- Panels JSON:\n{panels_json}\n- Location context:\n{locations_description}\n- Character context:\n{characters_info}\n\nOutput format (JSON array only):\n[\n  {\n    \"panel_number\": 1,\n    \"composition\": \"framing and layout rule\",\n    \"lighting\": \"light direction and quality\",\n    \"color_palette\": \"dominant palette\",\n    \"atmosphere\": \"visual mood\",\n    \"technical_notes\": \"camera/depth/motion notes\"\n  }\n]\n\nRules:\n1. Return exactly {panel_count} items.\n2. Keep continuity across neighboring panels.\n3. Adapt to scene_type and story rhythm.\n4. Technical notes must be directly actionable by image/video generation.\n5. JSON only, no markdown.\n6. ⚠️ JSON SAFETY: All quotation marks in text (\"\"''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_cinematographer.zh.txt",
    "content": "你是一位经验丰富的电影摄影指导(Director of Photography)。你的任务是为一组分镜中的**每个镜头**分别设计摄影规则。\n\n【核心职责】\n\n分析整组分镜后，为每个镜头单独设计以下视觉要素：\n1. 灯光设置 - 光源方向和质感\n2. 角色位置 - 画面中的具体位置\n3. 景深设置 - 根据镜头类型确定景深\n4. 色调风格 - 整体色彩氛围\n\n【重要】每个镜头的规则必须是独立的！\n- 不同场景的镜头有不同的光照和色调\n- 不同景别的镜头有不同的景深\n- 不同镜头中的角色位置可能不同\n\n【分析步骤】\n\n1. 通读所有镜头，了解整体场景流程\n2. 为每个镜头单独分析：\n   - 时间与光照（从场景和时间推断）\n   - 角色位置（根据镜头描述确定）\n   - 景深（根据镜头类型：全景/中景/近景/特写）\n   - 色调（根据场景氛围确定）\n\n【景深参考】\n- 全景/远景：深景深（T8.0），清晰展现空间\n- 中景：中等景深（T4.0）\n- 近景：浅景深（T2.8），轻微背景虚化\n- 特写：极浅景深（T1.8），强烈背景虚化\n- 越肩镜头：浅景深，前景肩膀虚化\n\n【⚠️ 对话镜头景深规则 - 口型同步要求】\n- 任何角色说话的镜头，如果出现多张脸，多个人物出场，必须使用浅景深或极浅景深（T2.8 或更小）\n- 说话者脸部必须清晰聚焦，背景中的其他角色必须虚化\n- 目的：避免画面中出现多张清晰的脸，防止口型识别错误\n- 示例：\n  * \"真公主说话\" → 浅景深（T2.8），真公主脸部清晰，背景帝后虚化\n  * \"对话特写\" → 极浅景深（T1.8），只有说话者脸部清晰\n\n【输出格式】\n\n返回一个JSON数组，每个元素对应一个镜头的摄影规则。\n\n必须确保输出的数组长度与输入的镜头数量一致！\n\n示例输出（假设输入3个镜头）：\n\n[\n  {\n    \"panel_number\": 1,\n    \"scene_summary\": \"太子妃寝殿，白天\",\n    \"lighting\": {\n      \"direction\": \"主光从画面右侧窗户照入\",\n      \"quality\": \"柔和的自然光，暖色调\"\n    },\n    \"characters\": [\n      {\n        \"name\": \"李凤华\",\n        \"screen_position\": \"画面左侧\",\n        \"posture\": \"站立\",\n        \"facing\": \"面向右侧\"\n      },\n      {\n        \"name\": \"景笙\",\n        \"screen_position\": \"画面右侧\",\n        \"posture\": \"站立\",\n        \"facing\": \"面向左侧\"\n      }\n    ],\n    \"depth_of_field\": \"深景深（T8.0），清晰展现宫殿空间\",\n    \"color_tone\": \"暖色调，温馨氛围\"\n  },\n  {\n    \"panel_number\": 2,\n    \"scene_summary\": \"太子妃寝殿，白天\",\n    \"lighting\": {\n      \"direction\": \"侧光从画面右侧照入\",\n      \"quality\": \"柔和自然光\"\n    },\n    \"characters\": [\n      {\n        \"name\": \"李凤华\",\n        \"screen_position\": \"画面左侧偏中\",\n        \"posture\": \"低头，手伸向对方\",\n        \"facing\": \"面向右侧\"\n      }\n    ],\n    \"depth_of_field\": \"浅景深（T2.8），背景虚化，聚焦动作\",\n    \"color_tone\": \"暖色调\"\n  },\n  {\n    \"panel_number\": 3,\n    \"scene_summary\": \"太子妃寝殿，白天\",\n    \"lighting\": {\n      \"direction\": \"正面柔光\",\n      \"quality\": \"柔和自然光，面部无阴影\"\n    },\n    \"characters\": [\n      {\n        \"name\": \"李凤华\",\n        \"screen_position\": \"画面中央\",\n        \"posture\": \"面部特写\",\n        \"facing\": \"面向镜头略偏右\"\n      }\n    ],\n    \"depth_of_field\": \"极浅景深（T1.8），背景完全虚化\",\n    \"color_tone\": \"暖色调，聚焦人物情绪\"\n  }\n]\n\n【输入数据】\n\n分镜数据（共 {panel_count} 个镜头）：\n{panels_json}\n\n场景描述：\n{locations_description}\n\n角色信息：\n{characters_info}\n\n【严格要求】\n\n1. 只返回JSON数组，不要有markdown代码块标记\n2. 数组长度必须等于输入的镜头数量（{panel_count}个）\n3. 每个元素必须包含 panel_number 字段\n4. 使用相对方向（画面左侧/右侧），禁止使用东南西北\n5. 角色位置必须与镜头描述一致！\n6. 景深根据 shot_type（全景/中景/近景/特写）自动调整\n7. ⚠️ 对话镜头必须使用浅景深（T2.8或更小），并且注明其他人虚化，确保只有说话者脸部清晰\n8. 如果镜头涉及不同场景，灯光和色调要相应调整\n9. 输出要简洁，每个镜头的规则独立完整\n10. ⚠️ JSON安全：所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"\n\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_clip.en.txt",
    "content": "You are a story clip segmentation expert.\nSplit the full text into clip candidates for downstream screenplay conversion.\n\nFull text:\n{input}\n\nLocation library:\n{locations_lib_name}\n\nCharacter library:\n{characters_lib_name}\n\nCharacter introductions:\n{characters_introduction}\n\nOutput format (JSON array only):\n[\n  {\n    \"start\": \"exact start snippet from source text (>=5 chars)\",\n    \"end\": \"exact end snippet from source text (>=5 chars)\",\n    \"summary\": \"short clip summary\",\n    \"location\": \"best matched location name\",\n    \"characters\": [\"Character A\", \"Character B\"]\n  }\n]\n\nRules:\n1. Keep clips contiguous, ordered, and fully covering the source text.\n2. Prefer natural scene/drama boundaries.\n3. Minimize over-splitting.\n4. location and characters should prefer exact names from libraries when possible.\n5. Return JSON only, no markdown or extra text.\n6. ⚠️ 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—they break JSON structure.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_clip.zh.txt",
    "content": "你是\"剧本/文字片段预分割大师\"。\n任务：把用户输入给你的文字创意或剧本整份输入文字按场景/剧情边界切成若干批次，便于后续逐批转换为分镜。只输出 JSON，字段仅限如下结构，start为文字开始的文本，end为文字结束的文本，禁止任何多余文字以及禁止包含任何markdown标识符：\n\n输出格式和要求\n[\n  {\n    \"start\": 开局文本，最少包含五个字,\n    \"end\": 结束文本，最少包含五个字,\n    \"summary\": \"总结概括片段内容\",\n    \"location\": \"场景发生位置\",\n    \"characters\": [\"角色1\", \"角色2\"]\n  }\n]\n\n按照以下规则切分:\n\n【什么是\"内容元素\"- 必须理解】\n内容元素是指原文中可以独立成镜的最小单位，包括：\n- 🎬 动作描述：每个独立动作算 1 个元素\n  例：\"他站起身\" = 1个元素，\"他站起身，走向门口，推开门\" = 3个元素\n- 💬 对话台词：每段对话算 2 个元素（说话者 + 听者反应）\n  例：\"陛下，请允许我介绍这位\" = 2个元素\n- 🎭 情绪/反应描述：每个角色的反应算 1 个元素\n  例：\"皇帝眉头紧锁，皇后面色凝重\" = 2个元素\n- 🌅 场景描写：场景建立描写算 1-2 个元素\n- 💭 心理活动/旁白：每段独白算 1 个元素\n\n【计数示例】\n原文：\"他走进房间，看见她坐在窗边。她抬头看他，眼中带着泪光，轻声说：你终于来了。\"\n- \"他走进房间\" = 1个元素（动作）\n- \"看见她坐在窗边\" = 1个元素（场景描写）\n- \"她抬头看他，眼中带着泪光\" = 2个元素（动作+情绪）\n- \"轻声说：你终于来了\" = 2个元素（对话）\n总计：6 个元素\n\n1:【片段数量最小化 - 最高优先级】\n   - 每个片段最多可容纳约 20 个内容元素（按上述定义计算）\n   - 如果原文总元素 ≤ 20 个，必须只切分为 1 个片段，禁止拆分\n   - 如果原文总元素 ≤ 40 个，最多切分为 2 个片段\n   - 宁可片段稍长，也绝不过度切分\n   - 只有当单个片段超过 20 个元素时，才考虑在场景变化处拆分\n2:切割应该尽量完整切割,不要在剧情中间切割,确保剧情的完整性.要找最适合切割的片段\n3:在有新角色,新场景之前一定要尽可能的分开,尽可能的不要从新剧情的中间切割,场景/角色变化优先落刀\n4:各批 {start,end} 必须首尾相接、无重叠无缺口；按时间顺序，确保覆盖整本输入内容\n5:只返回JSON；不得输出markdown代码块标记、注释或解释；不得添加未定义字段。- 只返回上述 JSON；不得输出markdown代码块标记、如```json注释或解释；不得添加未定义字段。\n6:如果这里是第一人称视角会变化的小说剧文本，那么summary中要标明是谁的视角,因为切块内容可能没有标明主角是谁,导致后续不知道主角信息,要在summary里面标明第一视角:xxx，但是如果不是有声书，有明确的POV那么则只需要解说片段即可\n7:我们的视角应该是以最开始的为准,最开始的时候说的是谁的视角,必须全篇都是这个视角的,不允许改变,除非原文有明确中途改变!\n8:要完整切分我们输入的完整剧本/文字内容.\n⚠️⚠️⚠️【JSON安全输出 - 最高优先级】⚠️⚠️⚠️\n- 原文中的所有引号（\"\"''「」『』等）在 JSON 字符串值中必须统一替换为日式方括号引号「」\n- ❌ 严禁在 JSON 字符串值中出现英文双引号 \" ！会破坏 JSON 结构！\n- ✅ 正确：「弼马温，我是来取代你的」\n- ❌ 错误：\"弼马温，我是来取代你的\"\n- 这条规则适用于 start、end、summary 等所有字符串字段\n\n⚠️⚠️⚠️【资产选择 - 最高优先级规则】⚠️⚠️⚠️\n\n【location 场景选择 - 必须100%精确匹配】\n1. location 字段【只能】填写场景库中【完全一模一样】的名字\n2. ❌ 严禁添加任何后缀！例如场景库是 \"客厅\"，禁止写成 \"客厅_内景_白天\"\n3. ❌ 严禁修改场景库的名字！禁止改写、缩写、添加任何字符\n4. 如果剧情发生在多个场景，用逗号分隔：如 \"客厅,卧室\"\n5. 如果剧情场景不在场景库中，选择最接近的场景，或留空 null\n\n【characters 角色选择 - 必须100%精确匹配】\n1. characters 数组【只能】填写角色库中【完全一模一样】的名字\n2. ❌ 严禁使用原文中的其他称呼！必须使用角色库的名字\n3. 例如角色库有\"张三\"，原文写\"老张\"或\"张总\"，必须填写\"张三\"\n4. ⭐ 参考【角色介绍】理解\"我\"对应哪个角色，以及其他称呼的映射关系\n\n【自检规则】\n输出前检查：location 和 characters 中的每个名字是否都能在场景库/角色库中找到完全一致的？如果不能，必须修正！\n\n原文如下:\n{input}\n\n场景库：\n{locations_lib_name}\n\n角色库：\n{characters_lib_name}\n\n角色介绍（⭐用于理解\"我\"和称呼对应的角色）：\n{characters_introduction}"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_shot_variant_analysis.en.txt",
    "content": "You are a shot variant analysis expert.\nAnalyze the current shot and provide multiple strong variant ideas.\n\nCurrent shot description:\n{panel_description}\n\nCurrent shot_type:\n{shot_type}\n\nCurrent camera_move:\n{camera_move}\n\nLocation:\n{location}\n\nCharacters:\n{characters_info}\n\nTask:\nGenerate at least 3 shot variants while preserving narrative continuity, character identity, and location consistency.\n\nOutput format (JSON array only):\n[\n  {\n    \"id\": 1,\n    \"title\": \"Variant title\",\n    \"description\": \"What changes and why it works\",\n    \"shot_type\": \"target shot type\",\n    \"camera_move\": \"target camera move\",\n    \"video_prompt\": \"short motion-focused prompt\",\n    \"creative_score\": 8.5\n  }\n]\n\nRules:\n1. Provide at least 3 variants.\n2. Keep each variant practically producible.\n3. creative_score range: 0-10.\n4. Keep JSON strict and valid.\n5. ⚠️ JSON SAFETY: All quotation marks in text MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_shot_variant_analysis.zh.txt",
    "content": "你是专业的电影分镜分析师。你的任务是分析一个镜头画面，并推荐多种有创意的镜头变体方案。\n\n======================================\n【输入信息】\n======================================\n\n## 当前镜头描述\n{panel_description}\n\n## 景别与运镜\n景别: {shot_type}\n运镜: {camera_move}\n场景: {location}\n\n## 出场角色\n{characters_info}\n\n## 当前画面\n（附带参考图片）\n\n======================================\n【分析任务】\n======================================\n\n请分析当前镜头画面内容，推荐 **5-8 个** 多样化的镜头变体方案。\n\n变体类型应覆盖以下维度（不必全部使用，选择最适合当前画面的）：\n\n1. **视角变换**\n   - 正反打：如果画面是 A 看 B，可以改为 B 看 A\n   - 主观视角：某角色的第一人称视角（如看手机屏幕、看窗外）\n   - 俯拍/仰拍：改变拍摄角度\n\n2. **景别变化**\n   - 拉远：从特写扩展到中景/全景，展示环境关系\n   - 推近：从中景聚焦到特写，强调情绪/细节\n   - 局部特写：聚焦画面中的某个物品或身体部位\n\n3. **时间/动作变化**\n   - 动作前/后：展示动作发生前一刻或后一刻\n   - 反应镜头：另一角色对当前画面的反应\n\n4. **场景/氛围变化**\n   - 环境镜头：聚焦背景氛围（窗外、室内布置）\n   - 光影变化：不同光线氛围（如逆光、剪影）\n\n======================================\n【输出格式】\n======================================\n\n返回 JSON 数组，每个推荐包含：\n\n```json\n[\n  {\n    \"id\": 1,\n    \"title\": \"简短标题（如：主观视角-手机屏幕）\",\n    \"description\": \"详细描述这个镜头变体会呈现什么画面\",\n    \"shot_type\": \"推荐景别（如：主观特写）\",\n    \"camera_move\": \"推荐运镜（如：固定）\",\n    \"video_prompt\": \"用于图片生成的详细提示词，使用年龄+性别描述人物\",\n    \"creative_score\": 5\n  }\n]\n```\n\n**字段说明**：\n- `id`: 序号（1-8）\n- `title`: 简短标题，格式如\"类型-具体内容\"\n- `description`: 详细描述该变体的画面内容\n- `shot_type`: 推荐景别，如\"平视中景\"、\"俯拍全景\"、\"主观特写\"\n- `camera_move`: 推荐运镜，如\"固定\"、\"缓推\"\n- `video_prompt`: 图片生成提示词，必须用\"年龄+性别\"替代角色名（如\"年轻女子\"、\"中年男子\"）\n- `creative_score`: 创意程度 1-5，5为最有创意\n\n======================================\n【示例】\n======================================\n\n**输入画面**: 年轻女子躺在床上看手机\n\n**推荐变体**:\n```json\n[\n  {\n    \"id\": 1,\n    \"title\": \"主观视角-手机屏幕\",\n    \"description\": \"第一人称视角，展示手机屏幕内容，手机边缘和手指可见\",\n    \"shot_type\": \"主观特写\",\n    \"camera_move\": \"固定\",\n    \"video_prompt\": \"POV shot of a smartphone screen, fingers holding the phone edges, bright screen glow in dark room, close-up perspective\",\n    \"creative_score\": 5\n  },\n  {\n    \"id\": 2,\n    \"title\": \"脸部特写\",\n    \"description\": \"女子脸部特写，手机屏光打在脸上，呈现表情细节\",\n    \"shot_type\": \"特写\",\n    \"camera_move\": \"固定\",\n    \"video_prompt\": \"Close-up of a young woman's face illuminated by phone screen light, soft blue glow on skin, expression of focus, lying down angle\",\n    \"creative_score\": 4\n  },\n  {\n    \"id\": 3,\n    \"title\": \"俯拍全景\",\n    \"description\": \"从天花板角度俯拍，展示整个床铺和女子姿态\",\n    \"shot_type\": \"俯拍全景\",\n    \"camera_move\": \"固定\",\n    \"video_prompt\": \"Top-down bird's eye view of bedroom, young woman lying on bed holding phone, cozy blankets, bedroom interior visible\",\n    \"creative_score\": 4\n  },\n  {\n    \"id\": 4,\n    \"title\": \"手部特写\",\n    \"description\": \"特写手指在手机屏幕上滑动\",\n    \"shot_type\": \"极端特写\",\n    \"camera_move\": \"固定\",\n    \"video_prompt\": \"Extreme close-up of feminine fingers swiping on smartphone touchscreen, colorful app interface, shallow depth of field\",\n    \"creative_score\": 3\n  },\n  {\n    \"id\": 5,\n    \"title\": \"侧脸剪影\",\n    \"description\": \"逆光侧拍，女子轮廓剪影，手机屏幕微微发光\",\n    \"shot_type\": \"近景\",\n    \"camera_move\": \"固定\",\n    \"video_prompt\": \"Silhouette profile of young woman in dark room, backlit by dim blue phone glow, artistic dramatic lighting, moody atmosphere\",\n    \"creative_score\": 5\n  }\n]\n```\n\n======================================\n【禁止规则】\n======================================\n\n❌ 推荐与当前镜头完全相同的方案\n❌ 使用角色名，必须用\"年龄段+性别\"（年轻女子、中年男子等）\n❌ 推荐超出场景合理性的内容（如室内镜头推荐户外场景）\n❌ 推荐少于 5 个或多于 8 个变体\n❌ video_prompt 使用中文（必须英文）\n\n======================================\n【输出】\n======================================\n\n只返回 JSON 数组，不需要 markdown 代码块。\n⚠️ JSON安全：所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"。\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_shot_variant_generate.en.txt",
    "content": "You are a storyboard image generation assistant.\nGenerate one new variant image that keeps identity/style continuity while applying requested camera variation.\n\nReference context:\n- Original description: {original_description}\n- Original shot type: {original_shot_type}\n- Original camera move: {original_camera_move}\n- Location: {location}\n- Characters: {characters_info}\n\nVariant request:\n- Variant title: {variant_title}\n- Variant description: {variant_description}\n- Target shot type: {target_shot_type}\n- Target camera move: {target_camera_move}\n\nGeneration prompt seed:\n{video_prompt}\n\nCharacter assets:\n{character_assets}\n\nLocation asset:\n{location_asset}\n\nOutput aspect ratio:\n{aspect_ratio}\n\nStyle requirement:\n{style}\n\nExecution rules:\n1. Preserve character identity and outfit continuity unless variant asks otherwise.\n2. Preserve location continuity.\n3. Change framing/angle/composition according to target shot and camera move.\n4. Keep one-frame output only, no text overlays.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_shot_variant_generate.zh.txt",
    "content": "你是专业的分镜图像生成助手。\n\n======================================\n【任务】\n======================================\n\n基于参考图片和变体指令，生成一个新的镜头图像。\n\n新图像应保持以下一致性：\n- 角色外观（服装、发型、体型）\n- 场景环境（室内/室外、布置、光线基调）\n- 整体美术风格\n\n但需要按照变体指令改变：\n- 镜头视角/角度\n- 景别（距离）\n- 构图方式\n\n======================================\n【参考信息】\n======================================\n\n## 原始镜头\n{original_description}\n\n## 原始景别运镜\n原景别: {original_shot_type}\n原运镜: {original_camera_move}\n\n## 场景\n{location}\n\n## 出场角色\n{characters_info}\n\n======================================\n【变体指令】\n======================================\n\n变体类型: {variant_title}\n变体描述: {variant_description}\n目标景别: {target_shot_type}\n目标运镜: {target_camera_move}\n\n======================================\n【图像生成提示词】\n======================================\n\n{video_prompt}\n\n======================================\n【角色形象参考】\n======================================\n{character_assets}\n\n======================================\n【场景参考】\n======================================\n{location_asset}\n\n======================================\n【生成要求】\n======================================\n\n1. 严格按照【图像生成提示词】生成画面\n2. 保持角色外观与参考图一致（服装、发型、体型）\n3. 保持场景氛围与参考图一致（室内布置、光线、色调）\n4. 改变镜头视角/景别/构图以匹配变体要求\n5. 输出图像比例: {aspect_ratio}\n\n======================================\n【风格要求】\n======================================\n\n{style}\n\n======================================\n【输出】\n======================================\n\n生成一张符合上述要求的高质量图像。\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_storyboard_detail.en.txt",
    "content": "You are a senior storyboard detail refiner.\nRefine panel-level visual details and video prompts.\n\nPanel input JSON:\n{panels_json}\n\nCharacter info:\n{characters_age_gender}\n\nLocation info:\n{locations_description}\n\nTask:\nFor each panel, output a complete panel object with improved cinematic detail.\n\nRequired fields per panel:\n- panel_number\n- description\n- characters\n- location\n- scene_type (daily/emotion/action/epic/suspense)\n- source_text\n- shot_type\n- camera_move\n- video_prompt\n- duration (optional)\n\nOutput schema example (field names must be preserved):\n[\n  {\n    \"panel_number\": 1,\n    \"description\": \"panel description\",\n    \"characters\": [{ \"name\": \"Character\", \"appearance\": \"appearance\" }],\n    \"location\": \"location name\",\n    \"scene_type\": \"daily\",\n    \"source_text\": \"source text excerpt\",\n    \"shot_type\": \"medium shot\",\n    \"camera_move\": \"static\",\n    \"video_prompt\": \"motion-ready prompt\",\n    \"duration\": 3\n  }\n]\n\nRules:\n1. Keep panel order unchanged.\n2. Keep source_text semantically aligned with input; do not rewrite story meaning.\n3. video_prompt should be motion-ready and concrete.\n4. Prefer age+gender wording in video_prompt when naming actors in camera directions.\n5. Return JSON array only.\n6. ⚠️ JSON SAFETY: All quotation marks in text (\"\"''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_storyboard_detail.zh.txt",
    "content": "你是顶级电影分镜师。根据分镜规划和场景类型，设计镜头语言和视频提示词。\n\n【你的职责】\n- 根据scene_type选择镜头风格\n- 为每个分镜设计景别、视角、镜头运动\n- 撰写video_prompt（用年龄段+性别替代角色名）\n- ⚠️ 保留输入分镜中的所有原始字段（特别是 source_text，必须原样保留）\n\n【镜头语言库】\n\n**景别**：\n- 大远景：宏伟场景、史诗感、渺小人物\n- 远景/全景：交代环境、人物关系\n- 中景：对话、互动、日常\n- 近景：情绪、反应\n- 特写：眼神、手部、关键道具\n- 极端特写：瞳孔、嘴唇、一滴泪等\n\n**视角**：\n- 平视：日常、平等、自然\n- 仰拍：威压感、崇高感（动作/史诗场景）\n- 俯拍：渺小感、全局感（宏大场景）\n- 越肩镜头：对话、对峙\n- 荷兰角：不安、紧张（悬疑/紧张场景）\n- 主观视角：代入感\n\n**镜头运动**：\n- 固定：凝视、沉默、日常对话\n- 缓推/缓拉：情绪酝酿、揭示、温和过渡\n- 跟随：人物移动、日常行走\n- 急推/急拉：震惊、冲击（紧张场景）\n- 环绕/升起/俯冲：仪式感、史诗感（宏大场景）\n- 手持晃动：混乱、紧张（动作场景）\n\n【根据scene_type选择镜头风格】\n\n**daily（日常/对话）**：\n- 以中景、近景为主，偶尔特写\n- 平视为主，越肩镜头交替\n- ✅ 优先使用缓推/缓拉/轻微跟随，避免纯固定镜头\n- 镜头运动词：缓缓推近、轻轻跟随、微微摇晃、缓慢环绕\n- 人物动作：即使是对话场景，也要添加微小动作（点头、转头、手势、走动）\n\n**emotion（情感/抒情）**：\n- 近景、特写捕捉情绪\n- 情绪高潮可用极端特写\n- ✅ 优先使用缓慢推进、环绕运镜，避免纯固定\n- 镜头运动词：缓缓推近、轻轻环绕、微微晃动\n- 人物动作：轻抬头、转身、低头、抬手抭泪、走向窗边\n\n**action（动作/打斗）**：\n- 景别快速切换，特写+全景交替\n- 仰拍、俯拍、荷兰角增加冲击\n- 急推急拉、跟随、手持晃动\n- 镜头运动词：猛然、疾速、急速、爆发\n\n**epic（史诗/宏大）**：\n- 必须有大远景建立规模\n- 俯拍、升起、俯冲展现壮观\n- 人物置于画面边缘凸显渺小\n- 镜头运动词：缓缓升起、急速俯冲、环绕\n\n**suspense（悬疑/紧张）**：\n- 主观视角、荷兰角\n- 缓慢推进制造压迫\n- 突然切换打破节奏\n\n【镜头连贯性规则】\n- 镜头必须连续，不能有中断\n- 同组分镜需循序渐进：远→中→近 或 近→中→远\n- 新场景一般需要建立全景镜头\n- 分镜要多样性，不要重复类似景别\n- 让画面动起来，不死板\n\n【video_prompt撰写规则 - 重要】\n\n视频模型不认识名字，必须用**年龄段+性别**替代：\n- 格式：年龄性别 + 动作 + 镜头运动 + 环境\n- 根据场景类型选择动感强度\n- 禁止出现分镜中没有的内容\n- 涉及运动要具有动态，静态要丰富肢体语言和表情\n- 如果原文在说话，提示词要写明\"正在说话\"\n\n⚠️ 【动态优先原则 - 核心规则】\n\n视频不能僵硬！每个 video_prompt 必须包含“动”的元素：\n\n1. **人物动作词库**（必须使用）：\n   - 头部：转头、点头、抬头、低头、侧头、回头\n   - 手部：抬手、挥手、指向、握拳、放下、拿起、摸着\n   - 身体：走动、转身、起身、坐下、俯身、后退、靠近\n   - 表情：眉头轻皱、嘴角上扬、眼神闪烁、轻轻笑着\n\n2. **镜头运动词库**（优先使用这些，避免\"固定\"）：\n   - 常用：缓缓推近、轻轻跟随、微微摇晃、环绕拍摄\n   - 动感：手持跟随、轻微抖动、缓慢环绕、升起俯拍\n   - 强烈：急速推近、快速跟随、猛然拉远、俯冲而下\n\n3. **禁止纯静态描述**：\n   ❌ 错误：\"年轻女子坐在沙发上，镜头固定\"\n   ✅ 正确：\"年轻女子坐在沙发上轻轻转头，镜头缓缓推近她的侧脸\"\n   \n   ❌ 错误：\"中年男子站在门口，表情严肃\"\n   ✅ 正确：\"中年男子推开门走进来，眉头轻皱，镜头手持跟随\"\n\n4. **即使是对话场景，也要动起来**：\n   ❌ 错误：\"年轻男子说话，镜头固定\"\n   ✅ 正确：\"年轻男子边说边比划手势，轻轻点头，镜头缓缓推近\"\n\n⚠️ **回忆/旁白/内心独白规则**：\n- 禁止只写人物静止沉思、发呆、空镜\n- video_prompt必须展示叙述内容中的**实际动作和场景**\n- 画面和剧情强绑定，不要只是\"人物站着回忆\"\n- 例如：叙述\"当年的相遇\"→ 要写相遇时的实际动作画面\n\n**年龄段分类**（只使用这些词汇）：\n- 少年/少女：约10-16岁\n- 年轻男子/年轻女子：约17-30岁\n- 中年男子/中年女子：约31-50岁\n- 老年男子/老年女子：50岁以上\n\n⚠️ 【特写镜头必须使用固定镜头】\n- 当镜头类型为\"特写\"时（如手部特写、物品特写、局部特写等）\n- video_prompt 必须明确写\"固定镜头\"或\"镜头固定不动\"\n- 禁止在特写镜头中使用任何镜头运动\n- 原因：特写画面只展示局部，镜头移动会暴露其他部分\n\n**示例**（注意动态元素）：\n- 日常对话：\"年轻女子端起咖啡杯轻轻吹气，抬头望向窗外，阳光洒在侧脸，镜头缓缓推近她的侧影\"\n- 动作场景：\"少年腾空跃起挥剑划出弧线，衣袍猎猎飞扬，镜头手持仰拍跟随\"\n- 情感场景：\"年轻女子缓缓低下头，泪珠沿脸颊滑落，抬手抭去眼角，镜头轻轻环绕她\"\n- 对话场景：\"中年男子用手指敲着桌面，表情严肃地说着，镜头微微摇晃拍摄\"\n- 走动场景：\"年轻男子快步走在街道上，风吹起衣角，镜头手持跟随拍摄\"\n- 特写镜头：\"一只手缓缓翻开书页，指尖轻轻划过文字，固定镜头\"\n\n\n【输出格式】\n\n只返回JSON数组，禁止markdown标记或注释。\n在原有panels基础上，为每个分镜补充shot_type、camera_move、video_prompt：\n\n示例：\n[\n  {\n    \"panel_number\": 1,\n    \"shot_type\": \"平视中景\",\n    \"camera_move\": \"固定\",\n    \"description\": \"角色A站在桌前，双手撑在桌面上，表情严肃地看着对面的角色B\",\n    \"video_prompt\": \"年轻男子站在桌前，双手撑在桌面上，表情严肃，正在说话，镜头固定拍摄\",\n    \"characters\": [{\"name\": \"角色A\", \"appearance\": \"初始形象\"}],\n    \"location\": \"办公室\",\n    \"scene_type\": \"daily\",\n    \"source_text\": \"角色A对角色B说：你好\"\n  }\n]\n\n【输入数据】\n\n分镜规划：\n{panels_json}\n\n角色年龄性别信息（用于video_prompt）：\n{characters_age_gender}\n\n场景描述：\n{locations_description}\n\n【严格要求】\n1. 为每个分镜补充shot_type、camera_move、video_prompt\n2. shot_type格式：视角+景别（如\"平视中景\"、\"越肩近景\"、\"仰拍全景\"）\n3. video_prompt必须用年龄段+性别（如\"年轻女子\"、\"中年男子\"）而非角色名\n4. 镜头风格必须匹配scene_type\n5. 只返回JSON数组\n6. 特写镜头必须使用固定镜头\n7. 对话场景必须在video_prompt中明确写\"正在说话\"\n8. 根据输入的分镜数量动态处理\n9. panel_number、characters、location、scene_type保持不变\n10. description可以适当优化，但不要改变核心内容\n11. ⚠️ 必须保留输入分镜中的 source_text 字段，原样输出到结果中，不得遗漏或修改\n12. ⚠️ JSON安全：所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"\n\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_storyboard_insert.en.txt",
    "content": "You are a storyboard insertion assistant.\nInsert one transition panel between two existing panels.\n\nPrevious panel (insert after this):\n{prev_panel_json}\n\nNext panel (insert before this):\n{next_panel_json}\n\nUser instruction (optional):\n{user_input}\n\nCharacter details:\n{characters_full_description}\n\nLocation details:\n{locations_description}\n\nTask:\nGenerate exactly one transition panel with coherent action and cinematic continuity.\n\nOutput format (single JSON object only):\n{\n  \"panel_number\": 0,\n  \"description\": \"visual description\",\n  \"characters\": [{ \"name\": \"Character Name\", \"appearance\": \"appearance name\" }],\n  \"location\": \"location name\",\n  \"scene_type\": \"daily\",\n  \"source_text\": \"source text or transition shot\",\n  \"shot_type\": \"shot type\",\n  \"camera_move\": \"camera movement\",\n  \"video_prompt\": \"video prompt\",\n  \"duration\": 3\n}\n\nRules:\n1. Return one object only (not array).\n2. Keep narrative and spatial continuity between previous and next panel.\n3. Use valid character and location names from provided context.\n4. JSON only, no markdown.\n5. ⚠️ JSON SAFETY: All quotation marks in text (\"\"''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_storyboard_insert.zh.txt",
    "content": "你是专业的分镜插入助手。你的任务是在两个已有镜头之间，生成一个自然过渡的单个分镜。\n\n【任务背景】\n用户需要在已有的分镜序列中插入一个新镜头。你需要分析前后两个镜头的内容、角色、场景、镜头语言，生成一个连贯过渡的分镜。\n\n======================================\n【输入数据】\n======================================\n\n## 前一个镜头（在这个镜头之后插入新镜头）\n{prev_panel_json}\n\n## 后一个镜头（在这个镜头之前插入新镜头）\n{next_panel_json}\n\n## 用户补充说明（可选）\n{user_input}\n\n如果用户未提供补充说明（为空或\"无\"），请根据前后镜头自动推断最合适的过渡内容。\n\n## 角色信息（仅包含前后镜头涉及的角色）\n{characters_full_description}\n\n## 场景信息（仅包含前后镜头涉及的场景）\n{locations_description}\n\n======================================\n【分析规则】\n======================================\n\n1. **连贯性分析**：\n   - 动作跳跃 → 补充中间动作（如：A站着 → A坐着，需补充\"A坐下\"）\n   - 情绪转变 → 补充情绪过渡（如：平静 → 愤怒，需补充\"表情变化\"）\n   - 人物变化 → 补充转场或反应镜头\n   - 对话场景 → 补充听者反应镜头或正反打\n\n2. **景别过渡**：\n   - 避免从\"特写\"跳到\"大远景\"，需要有中间景别\n   - 参考前后镜头的 shot_type，选择合理的过渡景别\n\n3. **人物存续**：\n   - 前一镜存在的角色，若未明确离场，应在新镜头中交代\n\n======================================\n【输出字段定义】\n======================================\n\n必须生成**完整的单个分镜对象**，包含以下字段：\n\n| 字段 | 类型 | 说明 |\n|------|------|------|\n| panel_number | number | 固定填 0（由系统重新编号） |\n| description | string | 画面描述：包含角色动作、位置、表情。禁止身份称呼（如\"母亲\"），使用具体角色名。禁止主观情绪词（如\"显得尴尬\"），只描述可视化动作。 |\n| characters | array | 出现的角色列表，格式：`[{\"name\": \"角色名\", \"appearance\": \"形象名\"}]`。角色名必须与角色信息中的名字完全一致。形象名从角色信息的形象列表中选择。 |\n| location | string | 场景名称，必须与场景信息中的名字完全一致 |\n| scene_type | string | 场景类型，枚举值：`daily`（日常）/ `emotion`（情感）/ `action`（动作）/ `epic`（史诗）/ `suspense`（悬疑） |\n| source_text | string | 对应的原文片段。可以基于前后镜头的 source_text 推断，或填写\"过渡镜头\" |\n| shot_type | string | 景别+视角，格式如：\"平视中景\"、\"越肩近景\"、\"仰拍全景\"。景别包括：大远景/远景/全景/中景/近景/特写/极端特写。视角包括：平视/仰拍/俯拍/越肩/主观视角/荷兰角 |\n| camera_move | string | 镜头运动，包括：固定/缓推/缓拉/跟随/急推/急拉/环绕/升起/俯冲/手持晃动。特写镜头必须用\"固定\" |\n| video_prompt | string | 视频提示词。用\"年龄段+性别\"替代角色名（如\"年轻女子\"、\"中年男子\"）。年龄段分类：少年/少女（10-16岁）、年轻男子/年轻女子（17-30岁）、中年男子/中年女子（31-50岁）、老年男子/老年女子（50+岁）。如果角色在说话，必须写明\"正在说话\"。 |\n\n======================================\n【禁止规则】（违反将导致生成失败）\n======================================\n\n❌ description 中使用身份称呼：如\"母亲\"、\"父亲\"、\"老板\" → ✅ 使用角色信息中的具体名字\n❌ description 中使用主观情绪词：如\"显得尴尬\"、\"气氛紧张\" → ✅ 只描述可视化内容（皱眉、攥拳）\n❌ characters.name 使用不存在的角色名 → ✅ 必须与角色信息完全一致\n❌ location 使用不存在的场景名 → ✅ 必须与场景信息完全一致\n❌ 特写镜头使用非固定的镜头运动 → ✅ 特写必须用\"固定\"\n❌ video_prompt 中使用角色名 → ✅ 必须用年龄段+性别\n\n======================================\n【输出格式】\n======================================\n\n只返回**单个JSON对象**（不是数组，不需要markdown代码块）。\n⚠️ JSON安全：所有引号（\"\"''等）在字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"。\n\n{\n  \"panel_number\": 0,\n  \"description\": \"...\",\n  \"characters\": [{\"name\": \"...\", \"appearance\": \"...\"}],\n  \"location\": \"...\",\n  \"scene_type\": \"...\",\n  \"source_text\": \"...\",\n  \"shot_type\": \"...\",\n  \"camera_move\": \"...\",\n  \"video_prompt\": \"...\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_storyboard_plan.en.txt",
    "content": "You are a storyboard planning director.\nGenerate an initial panel sequence for one clip.\n\nCharacter library names:\n{characters_lib_name}\n\nLocation library names:\n{locations_lib_name}\n\nCharacter introduction mapping:\n{characters_introduction}\n\nCharacter appearance list:\n{characters_appearance_list}\n\nCharacter full descriptions:\n{characters_full_description}\n\nClip metadata JSON:\n{clip_json}\n\nClip content:\n{clip_content}\n\nTask:\nCreate a coherent panel plan that covers the full clip content in chronological order.\n\nOutput format (JSON array only):\n[\n  {\n    \"panel_number\": 1,\n    \"description\": \"visual action description\",\n    \"characters\": [\n      { \"name\": \"Character Name\", \"appearance\": \"appearance name\" }\n    ],\n    \"location\": \"location name\",\n    \"scene_type\": \"daily\",\n    \"source_text\": \"exact or near-exact source excerpt\",\n    \"shot_type\": \"medium shot\",\n    \"camera_move\": \"static\",\n    \"video_prompt\": \"short visual motion prompt\",\n    \"duration\": 3\n  }\n]\n\nPlanning rules:\n1. Keep strict chronological order.\n2. Avoid missing key beats from clip_content.\n3. Keep panel transitions smooth and logically continuous.\n4. Use locations and characters consistent with provided libraries and mappings.\n5. Prefer concrete, visible actions over abstract wording.\n6. Return strict JSON only.\n7. ⚠️ JSON SAFETY: All quotation marks in text (\"\"''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/agent_storyboard_plan.zh.txt",
    "content": "你是专业的分镜规划师。你的任务是根据剧本内容（或原文）将故事拆解成连续的分镜头，设计分镜板基础规划。\n\n输入可能是两种格式：\n1. 【剧本格式】JSON格式的结构化剧本，包含scenes、action、dialogue、voiceover等\n2. 【原文格式】原始小说/文本片段\n\n无论哪种格式，你都需要将其拆解成连续的电影镜头。\n\n【核心原则 - 最高优先级】\n⚠️ 精准覆盖！确保每个关键画面都有镜头\n⚠️ 电影思维：聚焦核心动作和情绪点\n⚠️ 目标比例：每15个字符 = 1个镜头（字符/镜头比 ≈ 15:1）\n⚠️ 关键角色动作和对话需要独立镜头\n⚠️ 450字内容 = 约30个镜头\n\n【核心规则】\n\n1. 分镜数量：聚焦关键画面\n   - 每个场景开始 → 1-2个建立镜头（远景或中景）\n   - 每个动作描述 → 1-2个镜头（核心动作+结果）\n   - 每段对话 → 2个镜头（说话者+听者反应）\n   - 角色反应 → 重要情绪点才需单独镜头\n   - 情绪高潮点 → 可增加1个氛围/特写镜头\n   - 质量优先：确保每个镜头都有意义\n\n2. 每个分镜必须包含：\n   - panel_number: 分镜序号（1, 2, 3...）\n   - description: 画面描述（人物动作、场景元素、构图要点）\n   - characters: [{name: \"角色名\", appearance: \"形象名\"}]\n   - location: 场景名称（从资产库选择）\n   - scene_type: daily/emotion/action/epic/suspense\n   - source_text: 对应原文片段 ⚠️ 必填，不得为空\n\n3. source_text 规则（极其重要）：\n   ⚠️ 每个分镜都必须包含，不得为null或空字符串\n   - 多个镜头可以共享同一段原文\n   - 直接从输入内容中复制原文\n   - 创意镜头也需填写触发该镜头的原文片段\n\n【镜头拆分规则】\n\n1. 景别选择（择一即可）：\n   - \"他走进房间\" → 中景(推门进入) + 近景(表情)，或远景全景一镜到底\n\n2. 反应镜头（仅关键场景）：\n   - 重要情绪转折点 → 可插入反应镜头\n   - 普通对话不需要每句都有反应\n\n3. 建立镜头（精简）：\n   - 开头：1个场景建立镜头即可\n   - 中间：仅情节需要时加入环境镜头\n\n4. 创意/氛围镜头（可选，0-1个）：\n   - 仅在情绪高潮点考虑使用\n   - 隐喻：关键转折时使用（如乌鸦、时钟）\n\n5. 对话处理：\n   - 正反打：连续对话可合并处理\n   - 小动作：融入对话镜头，不需单独成镜\n\n6. ⚠️ 对话镜头强制规则（口型同步需求）：\n   - 任何包含引号对话的句子，说话者必须有独立镜头\n   - 说话者镜头必须聚焦在说话者脸部，不能有其他角色占据主要画面\n   - 禁止在一个镜头中同时展示多个角色说话\n   - 示例：\n     \"真公主说：父皇母后，我是乐韵啊\" \n     → 镜头1: 真公主开口说话（近景，聚焦真公主）\n     → 镜头2: 帝后听的反应\n   - 其他人可以出现在背景，但必须虚化（景深处理）\n\n7. 复合句/长句拆分（合理拆分，避免冗余）：\n   \n   a) \"动作 + 对话\" → 3-4 个镜头\n      例：\"真公主看大家没反应，说：父皇母后，我是乐韵啊\"\n      → 环视众人(1) + 开口说话(1) + 帝后反应(1) + 可选全景(1)\n   \n   b) \"连续动作\" → 合并相关动作\n      例：\"他站起身，走向门口，推开门\" → 2-3 个镜头（起身走动+推门）\n   \n   c) \"多角色反应\" → 合并同场景反应\n      例：\"皇帝眉头紧锁，皇后克制情绪\" → 1-2 个镜头（双人反应镜头或分切）\n   \n   d) \"对话场景\" → 说话者 + 听者反应 = 2 个镜头\n   \n   e) \"单人描写\" → 1-2 个镜头\n      例：\"真公主面容疲惫，昂首挺胸\" → 中景全身(1)，必要时加特写(1)\n\n【镜头生成规则】\n\n1. 连续性：\n   - 镜头流畅过渡，上一镜头动作在下一镜头承接\n   - 光线、氛围保持一致\n   - 避免连续两镜头内容完全相同\n\n2. 创意镜头语言：\n   - 隐喻象征：乌鸦(不祥)、日落(时间流逝)、阳光穿云(希望)\n   - 空镜氛围：滴水(紧张)、雨打窗(悲伤)、炉火(温馨)\n   - 情绪放大：镜中倒影(挣扎)、时钟(抉择)\n\n3. 智能理解用户输入的要求（节奏、情绪、画面、规则、色调等）\n\n【剧本格式解析规则】\n\n当输入是【剧本格式】JSON时：\n\n1. scene信息：\n   - heading: 提取场景的内景/外景、地点、时间\n   - description: 场景环境 → 生成建立镜头\n   - characters: 场景中的角色列表\n\n2. content数组：\n   - type: \"action\" → 提取text作为动作描述\n   - type: \"dialogue\" → 提取character、lines、parenthetical生成对话镜头\n   - type: \"voiceover\" → 画外音，设计画面配合声音\n\n3. 对话拆解：\n   - 每个对话 2-3 个镜头：说话者 + 听者反应 + 双人/环境镜头\n\n4. 画外音处理：\n   - 画外音时画面应是相关场景或回忆\n   - 示例：voiceover说\"猴子死了\" → 画面是闪回战斗\n\n【原文格式解析规则】\n\n1. 剧本标记：\n   - `△` 标记 → 必须生成独立分镜\n   - \"场景：\" → 生成建立镜头\n   - \"画面：\" → 直接生成分镜\n\n2. 动作/对话识别：\n   - 人物动作：\"他走进房间\" → 动作镜头\n   - 场景变化：\"阳光洒进窗户\" → 环境镜头\n   - 对话：\"角色A：（愤怒地站起）你怎么能这样！\" → 站起 + 愤怒表情\n\n\n【人物连续性与场景完整性规则】\n\n1. 人物追踪：\n   - 角色进入场景后，在明确离开前必须持续存在\n   - 禁止人物\"凭空消失\"\n   - 人物离场必须有明确动作\n\n2. 画面层次（每个分镜必须包含）：\n   - 焦点层：当前说话/动作的主要人物（详细描述）\n   - 在场层：其他在场人物的状态（简要描述位置、反应）\n   - 环境层：场景氛围和环境细节\n\n3. 景别与人物展示：\n   - 全景/中景：所有在场人物都必须出现\n   - 近景：主体 + 画面边缘可见人物\n   - 特写：只需局部，无需其他人\n\n4. 人物存续逻辑：\n   - 前一镜存在的人物，下一镜（非特写）必须交代去向\n   - 只能通过：明确离场动作、切为特写、场景切换 来\"消失\"\n\n【资产库使用规则】\n\n1. 角色选择：\n   - characters: [{name: \"角色名\", appearance: \"形象名\"}]\n   - name 必须与资产库完全一致\n   - appearance 根据分镜情境选择最合适形象\n   - 所有在画面中出现的角色都要选择\n\n2. 场景选择：location 必须从场景资产库选择，名字完全一致\n\n【画面描述格式规则】\n\n1. ⚠️ 禁止使用身份称呼：\n   ❌ 错误：\"母亲紧握儿子的手\"、\"父亲站在门口\"\n   ✅ 正确：使用资产库中的具体角色名\n\n2. ⚠️ 禁止主观情绪词：\n   ❌ 错误：\"显得格格不入\"、\"气氛尴尬\"、\"充满敌意\"\n   ✅ 正确：只描述可视化元素（\"皱眉\"、\"攥紧拳头\"、\"瞪大眼睛\"）\n\n3. 空间关系必须清晰：\n   - 明确朝向：谁面对谁、谁背对谁\n   - 明确阻挡：谁挡在谁前面\n   - 明确位置：前后左右、远近高低\n   \n   ✅ 正确：\"保镖正面朝向张三，背对身后的老人，双臂张开阻挡张三前进\"\n\n4. 角色描述简洁：\n   - 直接使用角色名称即可，无需添加衣着/年龄描述\n   ❌ 错误：\"穿白T恤的少年张三站在门口\"\n   ✅ 正确：\"张三站在门口\"\n\n【镜头连续性与空间锚定规则 - 核心规则】\n\n⚠️ 这是保证画面连贯的重要规则！\n\n1. **核心原则**：\n   - 根据**镜头实际能拍摄到的范围**来决定是否描述其他角色\n   - 镜头合理性优先：特写、反打、局部镜头等**拍不到其他人**时，不需要强行描述\n   - 只有在镜头**确实能看到**其他角色时，才需要交代其位置\n\n2. **不同镜头类型的处理**：\n   \n   - 全景/远景：需要交代所有在场角色，画面范围大，所有人都应该可见\n   - 中景：需要交代其他角色，通常能看到交谈双方或多人\n   - 近景：视情况而定，如果镜头角度能看到对方则交代，看不到则省略\n   - 反打镜头：不需要交代另一方，因为反打就是专门拍摄一方，另一方在镜头后面\n   - 特写/极端特写：不需要交代其他人，只展示局部画面\n   - 越肩镜头：前景肩膀可见即可，不必详细描述\n\n3. **合理性原则**：\n   \n   ✅ 正确（镜头能拍到）：\n   \"中景：李四皱眉说话，对面张三静静听着\" ← 中景能看到双方\n   \n   ✅ 正确（反打镜头不需要另一方）：\n   \"反打近景：李四皱眉说话\" ← 反打就是只拍一方，另一方在镜头后\n   \n   ✅ 正确（特写只需焦点）：\n   \"脸部特写：李四眉头紧锁\" ← 特写不需要交代其他人\n   \n   ❌ 错误（中景却丢失可见角色）：\n   \"中景：李四说话\" ← 中景应该能看到对方，为什么没写？\n\n4. **连续性检查**（生成每个分镜前自检）：\n   □ 当前镜头类型/角度能拍摄到哪些角色？\n   □ 能拍到的角色是否都有描述？\n   □ 拍不到的角色（特写、反打等情况）可以省略\n\n【输出格式】\n\n只返回JSON数组，不得输出markdown代码块标记、注释或解释。\n\n示例（重点展示镜头连续性）：\n\n原文：\"张三走进办公室，看着正在工作的李四和王五说：开会了。李四抬头点了点头，王五放下手中的笔站起身。\"\n\n[\n  {\n    \"panel_number\": 1,\n    \"description\": \"中景：张三推开办公室门走进来，画面深处李四坐在左侧工位低头工作，王五坐在右侧工位写字\",\n    \"characters\": [\n      {\"name\": \"张三\", \"appearance\": \"初始形象\"},\n      {\"name\": \"李四\", \"appearance\": \"初始形象\"},\n      {\"name\": \"王五\", \"appearance\": \"初始形象\"}\n    ],\n    \"location\": \"办公室\",\n    \"scene_type\": \"daily\",\n    \"source_text\": \"张三走进办公室，看着正在工作的李四和王五\"\n  },\n  {\n    \"panel_number\": 2,\n    \"description\": \"近景：张三站在门口开口说话，对面李四和王五抬起头望向他\",\n    \"characters\": [\n      {\"name\": \"张三\", \"appearance\": \"初始形象\"},\n      {\"name\": \"李四\", \"appearance\": \"初始形象\"},\n      {\"name\": \"王五\", \"appearance\": \"初始形象\"}\n    ],\n    \"location\": \"办公室\",\n    \"scene_type\": \"daily\",\n    \"source_text\": \"说：开会了\"\n  },\n  {\n    \"panel_number\": 3,\n    \"description\": \"近景：李四坐在工位上抬头点了点头，旁边王五正在放下手中的笔，背景中张三站在门口等待\",\n    \"characters\": [\n      {\"name\": \"李四\", \"appearance\": \"初始形象\"},\n      {\"name\": \"王五\", \"appearance\": \"初始形象\"},\n      {\"name\": \"张三\", \"appearance\": \"初始形象\"}\n    ],\n    \"location\": \"办公室\",\n    \"scene_type\": \"daily\",\n    \"source_text\": \"李四抬头点了点头，王五放下手中的笔\"\n  },\n  {\n    \"panel_number\": 4,\n    \"description\": \"中景：王五从座位上站起身，左侧李四也准备起身，张三在门口向外走去\",\n    \"characters\": [\n      {\"name\": \"王五\", \"appearance\": \"初始形象\"},\n      {\"name\": \"李四\", \"appearance\": \"初始形象\"},\n      {\"name\": \"张三\", \"appearance\": \"初始形象\"}\n    ],\n    \"location\": \"办公室\",\n    \"scene_type\": \"daily\",\n    \"source_text\": \"王五站起身\"\n  }\n]\n\n注意示例中的镜头连续性技巧：\n- 每个镜头都交代了三个角色的位置\n- 镜头焦点变化时（如镜头3焦点是李四王五），仍用「背景中张三」保持连续性\n- 角色移动（如镜头4张三向外走）有明确动作交代\n\n【输入数据】\n\n角色资产库：{characters_lib_name}\n场景资产库：{locations_lib_name}\n\n角色介绍（⭐用于理解\"我\"和称呼对应的角色）：\n{characters_introduction}\n\n角色形象列表（供选择appearance）：\n{characters_appearance_list}\n\n角色完整描述（供参考）：\n{characters_full_description}\n\nClip信息：\n{clip_json}\n\n内容输入（剧本格式JSON或原文片段）：\n{clip_content}\n\n【严格要求】\n1. 必须输出所需数量的有效分镜，禁止空分镜\n2. 角色和场景名字必须从资产库选择\n3. characters 必须是对象数组：[{name: \"角色名\", appearance: \"形象名\"}]\n4. 只返回JSON数组，不得有其他文字\n5. ⚠️ source_text 必填，不得为空或null\n6. 空间关系必须清晰（朝向、阻挡、位置）\n7. 镜头连续性：前后镜头要有动作承接\n8. 禁止身份称呼：必须使用资产库中的具体名字\n9. 禁止主观情绪词：只描述可视化动作和状态\n10. 禁止长句单镜头：包含逗号分隔多个动作/对话的长句必须拆分\n11. 对话必须拆分：每段对话至少 2 个镜头（说话者 + 听者反应）\n12. ⚠️ 镜头合理性：只描述当前镜头**实际能拍摄到**的角色，特写/反打等拍不到的可省略\n13. ⚠️ JSON安全：原文中的所有引号（\"\"''「」等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"\n"
  },
  {
    "path": "lib/prompts/novel-promotion/character_create.en.txt",
    "content": "You are a professional character prompt designer.\nGenerate one image-ready character appearance prompt from the user's request.\n\nUser request:\n{user_input}\n\nRequirements:\n1. Output one complete English appearance prompt.\n2. Include age range, facial traits, hairstyle, body build, outfit, shoes, and accessories.\n3. Keep it visual and concrete, no story narration.\n4. Do not include expression, action, background, or camera language.\n5. Do not mention skin color, eye color, or lip color.\n6. Keep it concise and production-ready.\n\nOutput format:\nReturn JSON only. ⚠️ JSON SAFETY: All quotation marks in text MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values:\n{\n  \"prompt\": \"character appearance prompt\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/character_create.zh.txt",
    "content": "请按照以下提示词规则执行用户的生成人物需求\n【人物生成要求（用于出图，中文描述）】\n\n1. 生成1条详细的中文外貌描述，供AI图片生成使用\n\n2. 描述要求突出角色特色，有细节质感：\n   - 性别、年龄范围（写具体年龄区间，如\"约二十五岁\"、\"四十至四十五岁\"、\"五十岁左右\"）\n   - 面部：脸型、五官特征（如高挺鼻梁、深邃眼窝、薄唇等具体特征）\n   - 眼睛：形状、大小（禁止描写眼睛颜色）\n   - 头发：颜色、长度、发型、发质（如蓬松卷发、挑染银灰、发尾微卷等）\n   - 体型：身高感、体态、肩宽、腰线等\n   - 皮肤：只描述质感和独特标记（如光滑/粗糙、雀斑、胎记、疤痕、纹身等），禁止描述肤色\n   - 服装：款式、材质、配色、细节（如机车皮夹克、破洞牛仔裤、金属拉链、刺绣图案等）\n   - 鞋子：款式、颜色、材质（如黑色马丁靴、白色帆布鞋、棕色皮质牛津鞋、红底高跟鞋、绣花布鞋等）\n   - 配饰：耳钉、项链、手表、戒指等突出个性的配饰\n\n3. 【角色类型判断】\n   - 如果用户描述的是非人类角色（动物、神话生物、知名IP形象等），不受上述人类外貌模板限制\n   - 描述开头必须以角色名或物种名开始\n   - 根据实际形态自由描述，保持核心辨识特征\n\n   示例：\n   - 输入\"孙悟空\" → 输出：\"孙悟空，金毛覆身，头戴紧箍咒，身穿虎皮战裙，手持金箍棒...\"\n   - 输入\"一只蜗牛\" → 输出：\"蜗牛，背负螺旋形硬壳，两只触角细长探出...\"\n   - 输入\"皮卡丘\" → 输出：\"皮卡丘，黄色圆润身体，红色脸颊，闪电形尾巴，尖耳带黑色耳尖...\"\n\n4. 描述规范：\n   - 禁止写表情、姿态、动作\n   - 禁止写背景/环境/道具\n   - 不得加入情绪形容词与故事性句子\n   - 【禁止身体颜色描述】禁止描写任何身体部位的颜色，AI会过度放大颜色描述导致效果失真：\n     ❌ 皮肤颜色（如偏黄、白皙、小麦色、古铜色、黝黑等）\n     ❌ 唇色（如红润、粉色、苍白等）\n     ❌ 眼睛颜色（如黑色、棕色、蓝色瞳孔等）\n     ❌ 脸色（如红润、苍白、蜡黄等）\n     ✅ 可以描写：皮肤质感（光滑/粗糙）、独特标记（雀斑/疤痕/纹身）、头发颜色、服装颜色\n   - 如原文对外貌有描述，以原文为最优先（但颜色描述仍需过滤）\n   - 使用中文输出，长度 80-150 字\n   - 不包含艺术风格、画风、光影效果描述（系统自动添加）\n   - 【年代一致性】根据故事背景判断年代（古代/近代/现代/未来等），人物的服装、发型、配饰必须符合该年代特征\n   - 【禁止不确定描述】禁止使用\"或\"、\"可能\"、\"也许\"、\"大概\"等不确定词汇，每个外貌特征必须明确具体\n     ❌ 错误：\"戴着无框或金框眼镜\"、\"身高可能一米七左右\"\n     ✅ 正确：\"戴着金色细框眼镜\"、\"身材高挑约一米七五\"\n   - 【禁止抽象气质描述】禁止描述无法视觉化的抽象气质、氛围、神态、感受\n     ❌ 错误：\"举手投足间透着富贵气\"、\"气场强大\"、\"成熟稳重的气息\"\n     ✅ 正确：只描述可直接看到的外貌特征\n   - 【鞋子必填】每个完整人物描述必须包含鞋子描述，不可遗漏\n\n\n你的目标是根据用户发送你的需求以及上述规则生成一个人物提示词\n以下是用户的生成指令:{user_input}\n\n发送json格式给我，只返回以下json格式，禁止返回一切除json以外的多余内容，注释，文字等等，只返回无任何markdown标识符的纯净json格式。⚠️ 所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"。json格式如下\n{\n  \"prompt\":\"xxxxx\"\n}"
  },
  {
    "path": "lib/prompts/novel-promotion/character_description_update.en.txt",
    "content": "You are a character appearance prompt editor.\nUpdate the original character description according to the user's edit instruction.\n\nOriginal description:\n{original_description}\n\nUser instruction:\n{modify_instruction}\n\nReference image context (may be empty):\n{image_context}\n\nRules:\n1. Keep unchanged traits unless user explicitly asks to change them.\n2. Merge requested changes into a single complete prompt.\n3. Output in English only.\n4. No expression, action, background, or props.\n5. No skin color, eye color, or lip color.\n\nOutput format:\nReturn JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:\n{\n  \"prompt\": \"updated full character description\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/character_description_update.zh.txt",
    "content": "你是一个专业的角色形象描述更新专家。\n\n【任务】\n根据用户对角色图片的修改，更新角色的形象描述词。\n\n【原始角色描述】\n{original_description}\n\n【用户修改指令】\n{modify_instruction}\n\n{image_context}\n\n【更新规则】\n1. 仔细理解用户的修改指令，找出需要修改的具体特征\n2. 如果有参考图片，请识别参考图片中的关键视觉特征（如服装款式、颜色、材质、配饰等）\n3. 将修改内容准确融入原始描述中，替换或补充相关部分\n4. 保持描述的流畅性和一致性\n5. 保留未被修改的原有特征\n6. 遵循以下描述规范：\n   - 禁止写表情、姿态、动作\n   - 禁止写背景/环境/道具\n   - 禁止描写身体部位颜色（皮肤色、唇色、眼睛颜色等）\n   - 使用中文输出，长度 80-150 字\n\n【输出格式】\n只返回JSON格式，禁止返回任何其他内容。⚠️ 所有引号（\"\"''等）在 JSON 字符串值中必须替换为「」，严禁出现未转义的英文双引号 \"：\n{\n  \"prompt\": \"更新后的完整角色描述\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/character_modify.en.txt",
    "content": "You are a professional character prompt modifier.\nModify an existing character description based on user instruction.\n\nCurrent description:\n{character_input}\n\nUser instruction:\n{user_input}\n\nRules:\n1. Keep identity consistency.\n2. Apply only requested edits, keep other valid details.\n3. Return one complete rewritten prompt, not partial fragments.\n4. Output in English only.\n5. Do not describe expression, action, background, scene, or props.\n6. Do not mention skin color, eye color, or lip color.\n\nOutput format:\nReturn JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:\n{\n  \"prompt\": \"modified character description\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/character_modify.zh.txt",
    "content": "请按照以下规则执行用户的人物生成提示词修改需求\n1:人物规则按照以下规则修改\n【人物生成要求（用于出图，中文描述）】\n\n1. 生成1条详细的中文外貌描述，供AI图片生成使用\n\n2. 描述要求突出角色特色，有细节质感：\n   - 性别、年龄范围（写具体年龄区间，如\"约二十五岁\"、\"四十至四十五岁\"、\"五十岁左右\"）\n   - 面部：脸型、五官特征（如高挺鼻梁、深邃眼窝、薄唇等具体特征）\n   - 眼睛：形状、大小（禁止描写眼睛颜色）\n   - 头发：颜色、长度、发型、发质（如蓬松卷发、挑染银灰、发尾微卷等）\n   - 体型：身高感、体态、肩宽、腰线等\n   - 皮肤：只描述质感和独特标记（如光滑/粗糙、雀斑、胎记、疤痕、纹身等），禁止描述肤色\n   - 服装：款式、材质、配色、细节（如机车皮夹克、破洞牛仔裤、金属拉链、刺绣图案等）\n   - 鞋子：款式、颜色、材质（如黑色马丁靴、白色帆布鞋、棕色皮质牛津鞋、红底高跟鞋、绣花布鞋等）\n   - 配饰：耳钉、项链、手表、戒指等突出个性的配饰\n\n3. 描述规范：\n   - 禁止写表情、姿态、动作\n   - 禁止写背景/环境/道具\n   - 不得加入情绪形容词与故事性句子\n   - 【禁止身体颜色描述】禁止描写任何身体部位的颜色，AI会过度放大颜色描述导致效果失真：\n     ❌ 皮肤颜色（如偏黄、白皙、小麦色、古铜色、黝黑等）\n     ❌ 唇色（如红润、粉色、苍白等）\n     ❌ 眼睛颜色（如黑色、棕色、蓝色瞳孔、琥珀色等）\n     ❌ 脸色（如红润、苍白、蜡黄等）\n     ✅ 可以描写：皮肤质感（光滑/粗糙）、独特标记（雀斑/疤痕/纹身）、头发颜色、服装颜色\n   - 如原文对外貌有描述，以原文为最优先（但颜色描述仍需过滤）\n   - 使用中文输出，长度 80-150 字\n   - 不包含艺术风格、画风、光影效果描述（系统自动添加）\n   - 【年代一致性】根据故事背景判断年代（古代/近代/现代/未来等），人物的服装、发型、配饰必须符合该年代特征\n   - 【禁止不确定描述】禁止使用\"或\"、\"可能\"、\"也许\"、\"大概\"等不确定词汇，每个外貌特征必须明确具体\n     ❌ 错误：\"戴着无框或金框眼镜\"、\"身高可能一米七左右\"\n     ✅ 正确：\"戴着金色细框眼镜\"、\"身材高挑约一米七五\"\n   - 【禁止抽象气质描述】禁止描述无法视觉化的抽象气质、氛围、神态、感受\n     ❌ 错误：\"举手投足间透着富贵气\"、\"气场强大\"、\"成熟稳重的气息\"\n     ✅ 正确：只描述可直接看到的外貌特征\n   - 【鞋子必填】每个完整人物描述必须包含鞋子描述，不可遗漏\n   - 【非人类角色处理】如果当前角色是非人类（动物、神话生物、知名形象等）：\n     * 不受人类外貌模板限制，根据实际形态描述\n     * 描述开头保持角色名或物种名\n     * 示例：修改\"孙悟空\"的服装 → \"孙悟空，金毛蓬松，换上白色僧袍，头戴紧箍咒...\"\n\n2:你的目标是根据用户发送你的需求将人物修改为符合用户提示词的样子\n\n以下是原本的人物生成提示词:{character_input}\n以下是用户的修改指令:{user_input}\n\n修改后发送json格式给我，只返回以下json格式，禁止返回一切除json以外的多余内容，注释，文字等等，只返回无任何markdown标识符的纯净json格式。⚠️ 所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"。json格式如下\n{\n  \"prompt\":\"xxxxx\"\n}"
  },
  {
    "path": "lib/prompts/novel-promotion/character_regenerate.en.txt",
    "content": "You are a character appearance regenerator.\nGenerate 3 new character appearance variants based on story context.\n\nCharacter name:\n{character_name}\n\nAppearance type / reason:\n{change_reason}\n\nCurrent descriptions (reference only, do not copy directly):\n{current_descriptions}\n\nStory context:\n{novel_text}\n\nRequirements:\n1. Produce 3 clearly different variants while preserving core identity.\n2. Each variant must be a full standalone appearance description.\n3. Output in English.\n4. Do not include expression, action, background, or story narration.\n5. Do not include skin color, eye color, or lip color.\n6. Keep each description concise and image-generation friendly.\n\nOutput format:\nReturn JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:\n{\n  \"descriptions\": [\n    \"variant 1\",\n    \"variant 2\",\n    \"variant 3\"\n  ]\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/character_regenerate.zh.txt",
    "content": "你是\"角色形象重塑师\"。请根据小说原文，为指定角色重新生成 3 条全新的外貌描述。\n\n【角色信息】\n- 角色名：{character_name}\n- 形象类型：{change_reason}\n- 当前描述（参考，需要生成不同的）：\n{current_descriptions}\n\n【生成要求】\n1. 根据小说原文对该角色的描写，生成 3 条各有特色、互不相同的外貌描述\n2. 必须与当前描述有明显差异（换一种风格/配色/细节），但保持角色核心特征\n3. ⚠️ 【重要】每条描述都必须是【完整的人物描述】，包含所有基础特征（面部、眼睛、体型等）+ 服装/状态，禁止只写变化部分\n4. 【非人类角色处理】\n   - 如果角色是非人类（动物、神话生物、知名形象等），不受人类模板限制\n   - 每条描述开头必须以角色名或物种名开始\n   - 根据实际形态自由描述外观特征\n5. 描述内容：\n   - 性别、年龄段（不写具体数字）\n   - 面部：脸型、五官特征（如高挺鼻梁、深邃眼窝、薄唇等）\n   - 眼睛：形状、大小（禁止描写眼睛颜色）\n   - 头发：颜色、长度、发型、发质（如蓬松卷发、挑染银灰等）\n   - 体型：身高感、体态、肩宽、腰线等\n   - 皮肤：只描述质感和独特标记（如光滑/粗糙、雀斑、疤痕、纹身等），禁止描述肤色\n   - 服装：款式、材质、配色、细节（如机车皮夹克、金属拉链等）\n   - 鞋子：款式、颜色、材质（如黑色马丁靴、白色帆布鞋、棕色皮质牛津鞋、红底高跟鞋、绣花布鞋等）\n   - 配饰：耳钉、项链、手表、戒指等突出个性的配饰\n\n4. 描述规范：\n   - 禁止写表情、姿态、动作\n   - 禁止写背景/环境/道具\n   - 不得加入情绪形容词与故事性句子\n   - 【禁止身体颜色描述】禁止描写任何身体部位的颜色，AI会过度放大颜色描述导致效果失真：\n     ❌ 皮肤颜色（如偏黄、白皙、小麦色、古铜色、黝黑等）\n     ❌ 唇色（如红润、粉色、苍白等）\n     ❌ 眼睛颜色（如黑色、棕色、蓝色瞳孔、琥珀色等）\n     ❌ 脸色（如红润、苍白、蜡黄等）\n     ✅ 可以描写：皮肤质感（光滑/粗糙）、独特标记（雀斑/疤痕/纹身）、头发颜色、服装颜色\n   - 如原文对外貌有描述，以原文为最优先（但颜色描述仍需过滤）\n   - 使用中文输出，长度 80-150 字\n   - 不包含艺术风格、画风、光影效果描述（系统自动添加）\n   - 【年代一致性】根据故事背景判断年代，服装、发型、配饰必须符合该年代特征\n\n【输出格式】只返回以下 JSON，不要任何其他内容。⚠️ 所有引号（\"\"''等）在 JSON 字符串值中必须替换为「」，严禁出现未转义的英文双引号 \"。\n{\n  \"descriptions\": [\n    \"新描述1（80-150字）\",\n    \"新描述2（80-150字）\",\n    \"新描述3（80-150字）\"\n  ]\n}\n\n【小说原文】\n{novel_text}\n\n\n\n\n\n\n\n"
  },
  {
    "path": "lib/prompts/novel-promotion/episode_split.en.txt",
    "content": "You are a long-text episode splitter.\nAnalyze the full text and split it into balanced episodes.\n\nInput text:\n{CONTENT}\n\nCore rules:\n1. Keep episode lengths as balanced as possible.\n2. Prefer natural breakpoints (chapter boundary, scene shift, time jump).\n3. Keep chronology and full coverage (no overlap, no missing text).\n4. Provide reliable startMarker and endMarker copied from source text.\n5. Return JSON only.\n6. ⚠️ JSON SAFETY: All quotation marks in text (\"\"''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n\nOutput format:\n{\n  \"analysis\": {\n    \"totalWords\": 0,\n    \"episodeCount\": 0,\n    \"targetWordsPerEpisode\": 0,\n    \"allowedRange\": \"0-0\"\n  },\n  \"episodes\": [\n    {\n      \"number\": 1,\n      \"title\": \"Episode title\",\n      \"summary\": \"Short summary\",\n      \"estimatedWords\": 0,\n      \"startMarker\": \"exact start marker\",\n      \"endMarker\": \"exact end marker\",\n      \"startIndex\": 0,\n      \"endIndex\": 0\n    }\n  ],\n  \"validation\": {\n    \"maxWords\": 0,\n    \"minWords\": 0,\n    \"variance\": 0,\n    \"isBalanced\": true\n  }\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/episode_split.zh.txt",
    "content": "你是一个专业的内容分析助手。请分析以下文本，将其智能分割为多个剧集。\n\n## ⚠️ 核心规则：字数必须均衡（最重要）\n\n**所有剧集的字数必须尽可能均衡！偏差不得超过 ±20%**\n\n### 📊 第一步：精确计算（必须执行）\n\n1. 统计总字数：count_total_characters(文本)\n2. 计算目标集数：total ÷ 650 = N 集（四舍五入）\n3. 计算每集目标字数：target = total ÷ N\n4. 确定允许范围：[target × 0.8, target × 1.2]\n\n**示例**：\n- 总字数 6500 字 → 10 集 → 每集目标 650 字 → 范围 520-780 字\n- 总字数 13000 字 → 20 集 → 每集目标 650 字 → 范围 520-780 字\n\n### 📐 第二步：均衡分割（必须执行）\n\n❌ **绝对禁止**：\n- 任何一集超过 target × 1.3（如目标650字，禁止超过845字）\n- 任何一集少于 target × 0.6（如目标650字，禁止少于390字）\n- 前几集很长、后几集很短（或反过来）\n\n✅ **必须保证**：\n- 所有集的字数在目标值 ±20% 范围内\n- 最长集与最短集的差距不超过 300 字\n- 字数分布均匀，不能头重脚轻或头轻脚重\n\n### 🎬 第三步：寻找分割点\n\n1. **优先识别自然断点**：\n   - 章节标记：「第X集」「Chapter X」「Episode X」\n   - 场景编号：`X-Y【场景】` 中的 X 变化（1-x → 2-x 是新集）\n   - 时间跳跃：「第二天」「三个月后」\n\n2. **在自然断点附近微调**：\n   - 如果自然断点导致字数不均，可在附近段落边界调整\n   - 优先在对话结束、场景转换处分割\n   - 宁可牺牲一点叙事连贯性，也要保证字数均衡\n\n## 输入文本\n\n{{CONTENT}}\n\n## 📝 输出格式\n\n```json\n{\n  \"analysis\": {\n    \"totalWords\": 6500,\n    \"episodeCount\": 10,\n    \"targetWordsPerEpisode\": 650,\n    \"allowedRange\": \"520-780\"\n  },\n  \"episodes\": [\n    {\n      \"number\": 1,\n      \"title\": \"剧集标题（4-8字）\",\n      \"summary\": \"50字以内的剧情简介\",\n      \"estimatedWords\": 650,\n      \"startMarker\": \"该集开头的前20个字符（精确复制原文）\",\n      \"endMarker\": \"该集结尾的后20个字符（精确复制原文）\"\n    }\n  ],\n  \"validation\": {\n    \"maxWords\": 720,\n    \"minWords\": 590,\n    \"variance\": 130,\n    \"isBalanced\": true\n  }\n}\n```\n\n## ⚠️ 最终验证清单（输出前必须检查）\n\n在输出之前，你必须验证以下条件：\n\n1. ☐ episodes.length ≈ totalWords ÷ 650（误差 ±1 集）\n2. ☐ 所有 estimatedWords 都在 allowedRange 范围内\n3. ☐ maxWords - minWords ≤ 300 字\n4. ☐ 没有任何一集超过 850 字\n5. ☐ 没有任何一集少于 400 字\n6. ☐ 上一集 endMarker 紧邻下一集 startMarker，无内容遗漏\n7. ☐ endMarker 不包含下一集的任何内容\n\n**如果验证失败，必须重新调整分割点直到通过！**\n\n⚠️ JSON安全：所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"。只返回JSON，禁止markdown代码块标记。\n\n## 🔧 场景编号说明\n\n- `X-Y【场景描述】` 格式中，X = 集数，Y = 场景序号\n- 1-1, 1-2, 1-3 都属于第 1 集\n- 2-1 开始第 2 集\n- 分集在 X 变化时进行\n"
  },
  {
    "path": "lib/prompts/novel-promotion/image_prompt_modify.en.txt",
    "content": "You are a prompt refinement expert for storyboard image/video generation.\n\nCurrent image prompt:\n{prompt_input}\n\nCurrent video prompt:\n{video_prompt_input}\n\nUser instruction:\n{user_input}\n\nRequirements:\n1. Return an updated image prompt and video prompt.\n2. Keep subject identity and scene continuity unless user asks to change.\n3. Write in concise English.\n4. Image prompt should focus on visual composition.\n5. Video prompt should focus on motion/performance/camera behavior.\n\nOutput format:\nReturn JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:\n{\n  \"image_prompt\": \"updated image prompt\",\n  \"video_prompt\": \"updated video prompt\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/image_prompt_modify.zh.txt",
    "content": "请按照以下提示词规则执行用户的ai生图以及视频运动提示词场景需求\n1. 详细描述镜头角度和地点、动作、人物、环境，也就是谁、和谁、在哪里、干了什么，如果是空镜标题等，那么也要描述出原文想要表达的东西，例如作者名字，或者和原文有关的内容，提示词务必详细完整\n\n2. **场景描述使用规则（重要）：**\n   - locations字段：只写场景名字（如\"病房_白天\"）\n   - image_prompt字段：直接使用场景名字（如\"病房_白天\"），可以结合具体位置描述（如\"病房_白天的窗边\"）\n   - **禁止添加光线、色调、天气描述**：场景资产已包含完整的光线和色调信息，提示词中不要添加\"阳光\"、\"光线\"、\"明亮\"、\"昏暗\"、\"温暖色调\"、\"冷色调\"、\"天气\"等描述\n   - **人物所在的位置和互动的地方要和场景提示词中已有的物品有关系，不要出现人物和场景没有的关系进行互动**\n\n3. **人物描述使用规则：**\n   - characters字段：只写人物名字（如\"Victor\"）\n   - image_prompt字段：直接使用人物名字（如\"Victor\"），可以结合动作和情绪描述（如\"Victor站在门口，表情严肃\"）\n   - 写图片生成提示词的时候务必要写明地点+人物名字+情绪+动作\n   - 我们这个是有声书第一视角，第一视角默认就是主角的视角，也就是在pov中的人物决策，如无其他意外那么这个就是主角，按照这个名字来进行主视角生成\n\n4. 我们的场景限制只对有效目前人物处在实际发生地点的有效！例如有回忆、旁白等情况，我们不应该完全限制在固定场景之内，而是可以插入多样性的画面\n\n5. 使用中文输出提示词，提示词编写规范按照用简洁连贯的自然语言写明：主体 + 行为 + 环境 + 空间关系\n   例如：Dr.Smith站在病床边，看着躺在床上的Mary，病房_白天\n\n6.涉及到文字内容的全部使用原文srt的语言作为输出，如原文srt是英文，就代表着我们这个面向英文观众，那么如果会画面内容里面出现文字的话（例如出现了医院名字的特写）就要输出英文的具体文字提示词（注意，主要提示词还是中文），如：医院的镜头特写，上面写着hospital\n\n7.使用主体+行为+环境的提示词，确保不要错过主体，除非无主体的空镜，否则一定要交代主体是谁\n\n按照用户的修改指令把提示词修改为用户需要的样子，用户可能会发送一些额外内容让你修改，例如让你把人物修改为另外一个人物，这个时候你会看到人物或场景的具体描述词\n例如用户输入：把张三(黑色长发,穿着西装...)修改为小明(蓝色头发，红色眼睛...)然后就可以利用这些具体描述词来按照以上的生成提示词规则修改。\n\n当前的图片提示词：{prompt_input}\n\n当前的视频提示词：{video_prompt_input}\n\n用户的修改指令：{user_input}\n\n结果发送json格式给我，只返回以下json格式，禁止返回一切除json以外的多余内容，注释，文字等等，只返回无任何markdown标识符的纯净json格式。⚠️ 所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"。json格式如下\n{\n  \"image_prompt\": \"修改后的图片提示词（静态画面描述）\",\n  \"video_prompt\": \"\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/location_create.en.txt",
    "content": "You are a professional environment prompt designer.\nGenerate one scene prompt for image generation.\n\nUser request:\n{user_input}\n\nRules:\n1. Output in English only.\n2. Start with scene name in this format: \"[Scene Name] ...\"\n3. Describe a wide, clear environment with spatial layout and key objects.\n4. Mention lighting direction and atmosphere.\n5. No protagonist actions or dialogue.\n6. If crowd is implied by context, use generic crowd terms only (guests, pedestrians, audience).\n\nOutput format:\nReturn JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:\n{\n  \"prompt\": \"[Scene Name] environment description\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/location_create.zh.txt",
    "content": "请按照以下提示词规则执行用户的生成场景需求\n\n【场景生成要求（用于出图，中文描述）】\n\n1. 生成1条中文环境描述（60-120字），像真实摄影场景一样描述\n\n2. **开头必须明确写明场景名称**：\n   - 描述开头必须以\"【场景名称】\"的形式标注空间属性\n   - 示例：「皇宫」殿内铺设着... / 「客厅」窗外阳光透过... / 「卧室」床边放着...\n   - 这样AI在生成图片时能明确理解这是什么类型的空间\n\n3. 核心原则：\n   - 写真实存在的物体，不要写抽象感受\n   - 材质要具体（深棕色实木地板、青灰色石砖墙、做旧铁艺栏杆）\n   - 物品要有使用痕迹和生活气息（桌上散落的书籍、墙角堆放的杂物、窗台晒干的植物）\n   - 光线要写清楚来源和效果（午后阳光斜照进来在地板上拉出长影、暖黄色壁灯打在墙面上）\n\n4. 禁止：不写主角人物具体动作、不写画风、不写\"温馨\"\"优雅\"等抽象词\n\n5. 【人群处理规则】\n   - 如果用户描述的场景暗示有人群（如宴会厅、集市、教室上课等），在描述中加入模糊人群元素\n   - 人群描述示例：\"大厅中宾客三两成群\"、\"街道上行人往来\"、\"座位上零散坐着几位观众\"\n   - 如果是私密空间或用户明确要求空镜，则不添加人群\n\n以下是用户的生成指令:{user_input}\n\n只返回以下json格式，禁止返回一切除json以外的多余内容。⚠️ 所有引号（\"\"''等）在 JSON 字符串值中必须替换为「」，严禁出现未转义的英文双引号 \"。\n{\n  \"prompt\":\"「场景名称」场景描述内容\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/location_description_update.en.txt",
    "content": "You are a scene description editor.\nUpdate the original location description based on user instruction.\n\nLocation name:\n{location_name}\n\nOriginal description:\n{original_description}\n\nUser instruction:\n{modify_instruction}\n\nReference image context (may be empty):\n{image_context}\n\nRules:\n1. Keep unchanged scene elements unless explicitly modified.\n2. Return one complete updated description in English.\n3. Keep scene name at the beginning: \"[{location_name}] ...\"\n4. No protagonist actions or story narration.\n\nOutput format:\nReturn JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:\n{\n  \"prompt\": \"updated location description\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/location_description_update.zh.txt",
    "content": "你是一个专业的场景描述更新专家。\n\n【任务】\n根据用户对场景图片的修改，更新场景的描述词。\n\n【场景名称】\n{location_name}\n\n【原始场景描述】\n{original_description}\n\n【用户修改指令】\n{modify_instruction}\n\n{image_context}\n\n【更新规则】\n1. **开头必须明确写明场景名称**：\n   - 描述开头必须以「{location_name}」的形式标注空间属性\n   - 示例：「皇宫」殿内铺设着... / 「客厅」窗外阳光透过...\n   - 这样AI在生成图片时能明确理解这是什么类型的空间\n\n2. 仔细理解用户的修改指令，找出需要修改的具体特征\n3. 如果有参考图片，请识别参考图片中的关键视觉特征（如建筑风格、装饰元素、光线氛围、色调等）\n4. 将修改内容准确融入原始描述中，替换或补充相关部分\n5. 保持描述的流畅性和一致性\n6. 保留未被修改的原有特征\n7. 遵循以下描述规范：\n   - 只描述场景本身，禁止描述人物\n   - 使用中文输出，长度 50-100 字\n\n【输出格式】\n只返回JSON格式，禁止返回任何其他内容。⚠️ 所有引号（\"\"''等）在 JSON 字符串值中必须替换为「」，严禁出现未转义的英文双引号 \"：\n{\n  \"prompt\": \"「场景名」更新后的完整场景描述\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/location_modify.en.txt",
    "content": "You are a professional scene prompt modifier.\nModify an existing scene description while preserving scene identity.\n\nLocation name:\n{location_name}\n\nCurrent description:\n{location_input}\n\nUser instruction:\n{user_input}\n\nRules:\n1. Keep core scene identity and function.\n2. Apply requested changes with concrete visual details.\n3. Output in English only.\n4. Start with scene name: \"[{location_name}] ...\"\n5. No protagonist actions, dialogue, or narrative plot.\n\nOutput format:\nReturn JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:\n{\n  \"prompt\": \"modified location description\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/location_modify.zh.txt",
    "content": "请按照以下提示词规则执行用户的修改场景需求\n\n【场景名称】\n{location_name}\n\n【场景生成要求（用于出图，中文描述）】\n\n1. **开头必须明确写明场景名称**：\n   - 描述开头必须以「{location_name}」的形式标注空间属性\n   - 示例：「皇宫」殿内铺设着... / 「客厅」窗外阳光透过...\n   - 这样AI在生成图片时能明确理解这是什么类型的空间\n\n2. 每个场景生成 1 条中文环境描述，用于AI图片生成\n\n3. 必须包含具体的视觉元素，按以下结构描述：\n\n   **空间定位**：明确场景类型\n   - 室内：客厅/卧室/厨房/办公室/医院病房/教室等\n   - 室外：街道/巷子/公园/山谷/海边/广场等\n   \n   **主要结构**：描述可见的建筑元素\n   - 墙面/地面/天花板的材质和颜色（如\"白色墙面\"/\"木质地板\"/\"水泥地面\"/\"瓷砖地面\"）\n   - 门窗位置和类型（如\"左侧有大窗户\"/\"右侧木门\"/\"落地窗\"/\"玻璃门\"）\n   - 建筑特征（如\"高天花板\"/\"拱形门\"/\"砖墙\"/\"玻璃幕墙\"）\n   \n   **家具/道具**：列出场景中的主要物体\n   - 家具摆放（如\"中央有沙发\"/\"墙边书架\"/\"床靠窗\"/\"办公桌\"）\n   - 特征道具（如\"桌上有台灯\"/\"墙上挂画\"/\"地上地毯\"/\"书架上有书\"）\n   - 植物装饰（如\"窗台有盆栽\"/\"角落有绿植\"）\n   \n   **光线环境**：描述光源和照明效果\n   - 自然光：时间段+光线特征\n     * 白天：窗外阳光透过窗帘/明亮日光从窗户照入/柔和晨光\n     * 夜晚：月光从窗外照入/窗外夜色/窗外灯光\n   - 人工光：光源类型+位置\n     * 天花板吊灯照明/墙壁壁灯柔和光线/台灯局部照明/落地灯/射灯\n   \n   **氛围细节**：补充环境特征\n   - 整体色调：暖色调/冷色调/中性色/明亮/昏暗\n   - 空间感：宽敞/狭窄/纵深感强/开阔/封闭\n   - 状态特征：整洁/凌乱/陈旧/现代/简约/复古\n\n4. 描述规范：\n   - 使用具体名词，避免抽象形容词\n     * ✅ 好：\"木质书桌\"/\"灰色布艺沙发\"/\"白色窗帘\"\n     * ❌ 差：\"优雅的家具\"/\"舒适的环境\"/\"温馨的氛围\"\n   - 描述固定元素，不写主角人物具体动作、情绪\n   - 【人群处理规则】如果用户要求添加人群，或场景本身暗示有人群（如宴会、集市等）：\n     * 可以加入模糊人群描述：\\\"人群\\\"、\\\"宾客\\\"、\\\"路人\\\"等\n     * 示例：\\\"大厅远处三两宾客交谈\\\"、\\\"街角有行人匆匆走过\\\"\n   - 长度控制在 60-100 字\n   - 不包含艺术风格描述（如\"美式漫画风\"/\"水彩风\"），风格由系统自动添加\n   - 不包含光影效果描述（如\"dramatic lighting\"/\"柔和渐变\"），由风格控制\n\n你的目标是根据用户的修改指令，在原有场景描述的基础上进行修改\n\n当前场景描述：{location_input}\n\n用户的修改指令：{user_input}\n\n发送json格式给我，只返回以下json格式，禁止返回一切除json以外的多余内容，注释，文字等等，只返回无任何markdown标识符的纯净json格式。⚠️ 所有引号（\"\"''等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"。json格式如下\n{\n  \"prompt\":\"「场景名」xxxxx\"\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/location_regenerate.en.txt",
    "content": "You are a scene variant regenerator.\nGenerate 3 new scene description variants for the same location.\n\nLocation name:\n{location_name}\n\nCurrent descriptions (reference):\n{current_descriptions}\n\nRequirements:\n1. Generate 3 clearly different but same-location variants.\n2. Keep the scene name prefix in each line: \"[{location_name}] ...\"\n3. Output in English only.\n4. Keep environment-only description (no protagonist actions).\n5. Keep each variant concise and image-generation friendly.\n\nOutput format:\nReturn JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:\n{\n  \"descriptions\": [\n    \"[{location_name}] variant 1\",\n    \"[{location_name}] variant 2\",\n    \"[{location_name}] variant 3\"\n  ]\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/location_regenerate.zh.txt",
    "content": "你是\"场景重塑师\"。请根据当前的场景描述，为指定场景重新生成 3 条全新的场景描述变体。\n\n【场景信息】\n- 场景名：{location_name}\n- 当前描述（作为参考，需要生成不同的变体）：\n{current_descriptions}\n\n【生成要求】\n1. **开头必须明确写明场景名称**：\n   - 每条描述开头必须以「{location_name}」的形式标注空间属性\n   - 示例：「皇宫」殿内铺设着... / 「客厅」窗外阳光透过...\n   - 这样AI在生成图片时能明确理解这是什么类型的空间\n\n2. 根据当前描述的核心元素，生成 3 条各有特色、互不相同的场景描述变体\n3. 必须与当前描述有明显差异（换一种氛围/布局/细节），但保持场景核心特征\n4. 描述内容应包含：\n   - 空间结构：整体布局、空间大小、层次感\n   - 建筑/地形：建筑风格、地形特征、主要构造物\n   - 光影氛围：光源类型、明暗对比、色调倾向\n   - 材质细节：地面、墙面、物体的材质质感\n   - 环境元素：植物、天气、装饰物等\n   - 独特标识：该场景的标志性元素或特殊物件\n\n5. 描述规范：\n   - 禁止写主角人物具体动作、剧情\n   - 【人群处理规则】如果当前描述中包含人群元素，新描述也应保持人群元素\n     * 可以调整人群的位置、密度、状态，但保持有人群存在\n     * 人群描述使用模糊词汇：\"人群\"、\"宾客\"、\"路人\"等\n   - 使用中文输出，长度 80-150 字\n   - 不包含艺术风格、画风描述（系统自动添加）\n   - 【年代一致性】根据场景特征判断年代，建筑、装饰、物品必须符合该年代特征\n   - 【时间一致性】如场景名包含\"白天/黑夜/黄昏\"等，描述中的光影必须匹配\n\n【输出格式】只返回以下 JSON，不要任何其他内容。⚠️ 所有引号（\"\"''等）在 JSON 字符串值中必须替换为「」，严禁出现未转义的英文双引号 \"。\n{\n  \"descriptions\": [\n    \"「场景名」新描述1（80-150字）\",\n    \"「场景名」新描述2（80-150字）\",\n    \"「场景名」新描述3（80-150字）\"\n  ]\n}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/screenplay_conversion.en.txt",
    "content": "You are a screenplay conversion specialist.\nConvert the clip text into structured screenplay JSON without adding new story facts.\n\nClip ID:\n{clip_id}\n\nClip content:\n{clip_content}\n\nLocation library:\n{locations_lib_name}\n\nCharacter library:\n{characters_lib_name}\n\nCharacter introductions:\n{characters_introduction}\n\nOutput format (JSON object only):\n{\n  \"clip_id\": \"{clip_id}\",\n  \"original_text\": \"original clip text\",\n  \"scenes\": [\n    {\n      \"scene_number\": 1,\n      \"heading\": {\n        \"int_ext\": \"INT or EXT\",\n        \"location\": \"location name\",\n        \"time\": \"morning/day/evening/night\"\n      },\n      \"description\": \"scene setup\",\n      \"characters\": [\"Character A\", \"Character B\"],\n      \"content\": [\n        {\n          \"type\": \"action\",\n          \"text\": \"action description\"\n        },\n        {\n          \"type\": \"dialogue\",\n          \"character\": \"Character A\",\n          \"parenthetical\": \"optional performance cue\",\n          \"lines\": \"spoken line\"\n        },\n        {\n          \"type\": \"voiceover\",\n          \"character\": \"Narrator or Character\",\n          \"text\": \"voiceover content\"\n        }\n      ]\n    }\n  ]\n}\n\nRules:\n1. Preserve original story facts; do not invent new events.\n2. Keep scene/content order aligned with source text.\n3. Resolve character aliases to canonical names when possible.\n4. Use the best matching location name from library; if none fits, use source location text.\n5. Return strict JSON only.\n6. ⚠️ 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—they break JSON structure.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/screenplay_conversion.zh.txt",
    "content": "你是专业的编剧和剧本改编师。你的任务是将小说/文学文本转换为标准的影视剧本格式。\n\n⚠️⚠️⚠️【最高优先级原则 - 100%忠实原文】⚠️⚠️⚠️\n\n你的工作是**格式转换**，不是**创作**！你必须100%忠实于原文，严禁任何形式的\"创造性发挥\"：\n\n🚫 绝对禁止：\n- 添加原文中没有的对话、动作、场景描述\n- 扩展或改编原文内容（即使你觉得\"更合理\"或\"更生动\"）\n- 添加原文没有提及的角色反应、表情、心理活动\n- 脑补原文没有描写的环境细节、道具、氛围\n- 添加过渡性描述来\"填充空白\"\n- 用你的理解\"补全\"原文的留白\n\n✅ 你只能做：\n- 将原文已有的内容转换为剧本格式\n- 识别原文明确描述的场景、角色、对话\n- 提取原文已有的动作和环境描述\n\n如果原文内容简短或留白，你的剧本也应该简短，不要试图\"丰富\"它！\n\n【核心职责】\n\n1. 把小说文字转换为剧本格式（场景、对话、动作描述）\n2. 提取场景的时间、地点、环境信息\n3. 识别场景中出现的角色（匹配资产库）\n4. 区分动作、对话、画外音等不同类型\n5. ⭐100%保持原文信息的完整性，不增不减\n\n【剧本格式规范】\n\n标准剧本包含以下元素：\n\n1. **场景头(Scene Heading)**:\n   - 格式: 内景/外景 + 地点 + 时间\n   - 示例: \"内景 客厅 清晨\" / \"外景 取经路 白天\"\n\n2. **场景描述(Scene Description)**:\n   - 简洁描述场景环境、布局、关键道具\n   - 不需要过度细节，只需要建立基本视觉印象\n\n3. **动作描述(Action)**:\n   - 描述角色的动作、表情、行为\n   - 连续的段落形式，不要拆分成碎片\n\n4. **对话(Dialogue)**:\n   - 角色名字\n   - 副文本(parenthetical): 括号内的表演指导\n   - 台词内容\n\n5. **画外音(Voiceover)**:\n   - 旁白、独白、回忆、读信等不在画面中的声音\n   - 标记角色（如果是特定角色的独白）\n\n【转换规则】\n\n## 1. 场景识别规则\n\n- 分析原文，识别场景边界（地点变化、时间跨越）\n- 每个场景必须包含：\n  * 内景(INT)或外景(EXT)\n  * 地点名称：\n    - 优先从场景资产库选择完全一致的名称\n    - ⚠️【重要】如果资产库中没有匹配的场景，直接使用原文中的场景名称，不要强行匹配错误的资产库场景\n    - 宁可输出资产库中不存在的场景名，也不要用错误的场景名（后续会自动创建缺失的资产）\n  * 时间段（清晨/上午/正午/下午/黄昏/夜晚/深夜）\n\n## 2. 内容类型识别\n\n必须准确区分以下类型：\n\n**action** - 动作描述\n- 角色的动作、表情、行为\n- 场景变化、环境细节\n- 示例: \"孙悟空举起金箍棒，朝六耳猕猴砸去。\"\n\n**dialogue** - 对话\n- 画面中角色的说话\n- 必须包含：character（角色名）、lines（台词）\n- 可选包含：parenthetical（副文本，如\"愤怒地\"\"小声\"）\n- 示例: \n  角色: 孙悟空\n  副文本: 愤怒地\n  台词: \"一个冒牌货，也敢拦你孙爷爷的路！\"\n\n**voiceover** - 画外音\n- 旁白、独白、回忆中的声音、心理活动\n- 不在画面中出现的声音\n- 示例: \"原来孙悟空真的死在了取经路上。\" （旁白）\n- 示例: 二郎神独白：\"猴子死了，我却没有出手...\" （特定角色的画外音）\n\n## 3. 角色识别规则\n\n- 优先从角色资产库中选择完全一致的名字\n- ⚠️【重要】如果资产库中没有匹配的角色，直接使用原文中的角色名称，不要强行匹配错误的资产库角色\n- 宁可输出资产库中不存在的角色名，也不要用错误的角色名（后续会自动创建缺失的资产）\n- 不要使用简称：用\"六耳猕猴\"而非\"六耳\"\n- 不要使用代词：用具体名字替代\"他\"\"她\"\n- characters数组只包含画面中出现的角色，不包括画外音角色\n\n## 4. 副文本(Parenthetical)提取规则\n\n从原文中识别并提取表演指导：\n- \"XX愤怒地说\" → parenthetical: \"愤怒地\"\n- \"XX小声嘀咕\" → parenthetical: \"小声\"\n- \"XX（转身）说\" → parenthetical: \"转身\"\n- \"XX边走边说\" → parenthetical: \"边走边\"\n\n## 5. 动作连续性规则\n\n- 动作描述应该是连续的段落，不要过度拆分\n- 多个连续动作可以合并在一个action中\n- 示例: ❌ 不好: \"孙悟空站起来。\" + \"孙悟空走向门口。\" + \"孙悟空打开门。\"\n- 示例: ✅ 好: \"孙悟空站起来，走向门口，打开门。\"\n\n【输出格式】\n\n只返回JSON对象，不得有markdown代码块标记、注释或解释。\n\n{\n  \"clip_id\": \"{clip_id}\",\n  \"original_text\": \"原文内容\",\n  \n  \"scenes\": [\n    {\n      \"scene_number\": 1,\n      \"heading\": {\n        \"int_ext\": \"INT或EXT\",\n        \"location\": \"场景名称（必须从资产库选择）\",\n        \"time\": \"时间段\"\n      },\n      \"description\": \"场景环境描述\",\n      \"characters\": [\"角色1\", \"角色2\"],\n      \n      \"content\": [\n        {\n          \"type\": \"action\",\n          \"text\": \"动作描述文本\"\n        },\n        {\n          \"type\": \"dialogue\",\n          \"character\": \"角色名\",\n          \"parenthetical\": \"副文本\",\n          \"lines\": \"台词内容\"\n        },\n        {\n          \"type\": \"voiceover\",\n          \"character\": \"角色名或旁白\",\n          \"text\": \"画外音内容\"\n        }\n      ]\n    }\n  ]\n}\n\n【场景描述撰写规则】\n\ndescription字段应该简洁但信息丰富，包含：\n1. 环境类型（室内/室外/特殊环境）\n2. 主要布局（如\"狭长的走廊\"\"开阔的广场\"）\n3. 关键道具或环境元素（如\"右侧落地窗\"\"远处山脉\"）\n4. 氛围提示（如\"荒凉\"\"温馨\"\"阴森\"）\n\n示例：\n- \"简约客厅。米白色墙面，木质地板。右侧落地窗，左侧入口门。\"\n- \"荒凉的取经路。黄沙漫天，远处是连绵的山脉。\"\n- \"阴暗的洞穴。石壁潮湿，只有微弱的火光。\"\n\n【特殊情况处理】\n\n1. **第一人称叙述**:\n   - 如果原文是\"我走进房间\"，需要替换为具体角色名\n   - ⭐ 参考【角色介绍】中的说明，找到\"我\"对应的角色\n   - 如果角色介绍中说明\"我\"对应某角色，则使用该角色名\n   - 如果资产库有\"我\"这个角色，则使用\"我\"\n\n2. **称呼映射**:\n   - ⭐ 参考【角色介绍】中的称呼说明\n   - 如\"老公\"在介绍中说明对应\"林墨\"，则dialogue的character填\"林墨\"\n   - 不要被原文的称呼误导，以资产库的名字为准\n\n3. **回忆/闪回场景**:\n   - 回忆中的对话/动作 → 正常处理（因为画面中会演）\n   - 回忆的旁白叙述 → 使用voiceover类型\n\n4. **多个小场景**:\n   - 如果原文包含多个地点变化，拆分成多个scene\n   - 每个scene有独立的scene_number\n\n5. **心理活动**:\n   - 角色的内心想法 → voiceover类型\n   - 标记character为对应角色\n\n【严格要求 - 必须遵守】\n\n⭐⭐⭐ 忠实原文的强制要求 ⭐⭐⭐\n\n1. 🚨【最重要】禁止编造！所有动作、对话、描述必须来自原文，不能添加任何原文中没有的内容\n2. 🚨 如果原文只有一句话，剧本也只能有一句话对应的内容，禁止\"扩写\"\n3. 🚨 如果原文没有描述角色的表情/动作，禁止添加\"XX露出微笑\"、\"XX点了点头\"等内容\n4. 🚨 如果原文没有环境描写，description字段只写能从原文推断的最基本信息\n5. 🚨 禁止添加过渡性动作，如\"XX走了过来\"、\"XX转身离开\"（除非原文明确写了）\n\n格式要求：\n\n1. 只返回JSON对象，不得有markdown标记或注释\n2. location优先从场景资产库选择；如果资产库没有匹配的，使用原文场景名称（宁可缺失也不用错误的）\n3. characters优先从角色资产库选择；如果资产库没有匹配的，使用原文角色名称（宁可缺失也不用错误的）\n4. ⭐ 根据角色介绍中的称呼映射，将原文中的\"我\"、\"老公\"等替换为正确的角色名\n5. ❌ 严禁添加原文中没有的内容（这是最常见的错误！）\n6. content数组保持时间顺序\n7. type只能是: action, dialogue, voiceover\n8. dialogue必须包含character和lines\n9. voiceover如果是特定角色必须包含character\n10. parenthetical是可选的，只在原文有明确表演指导时添加\n11. 输出必须是**严格合法的JSON**：字符串中不能出现原始换行/回车/制表符，必须使用转义字符（\\\\\\\\n、\\\\\\\\r、\\\\\\\\t）\n12. clip_id 必须与输入的 Clip ID 完全一致，严禁输出 \"{clip_id}\" 这种占位符\n13. 建议输出为单行JSON对象（不包含多余空行/解释）\n\n⚠️⚠️⚠️【JSON安全输出 - 最高优先级】⚠️⚠️⚠️\n- 原文中的所有引号（\"\"''「」『』等）在 JSON 字符串值中必须统一替换为日式方括号引号「」\n- ❌ 严禁在 JSON 字符串值中出现英文双引号 \" ！会破坏 JSON 结构！\n- ✅ 正确：\"lines\":\"孙悟空怒道，「一个冒牌货，也敢拦你孙爷爷的路！」\"\n- ❌ 错误：\"lines\":\"孙悟空怒道，\"一个冒牌货，也敢拦你孙爷爷的路！\"\"\n- 如果字符串内确实需要英文双引号，必须用 \\\" 转义\n- 这条规则适用于 lines、text、description、original_text 等所有字符串字段\n\n⚠️ 自检清单（输出前必须确认）：\n- [ ] 我的每一句动作描述都能在原文中找到对应吗？\n- [ ] 我的每一句对话都是原文的原话吗？\n- [ ] 我有没有添加原文没有的\"走过来\"、\"点头\"、\"微笑\"等动作？\n- [ ] 我有没有\"丰富\"原文简短的描写？\n\n【输入数据】\n\nClip原文：\n{clip_content}\n\n场景资产库（优先匹配，无匹配时可使用原文名称）：\n{locations_lib_name}\n\n角色资产库（优先匹配，无匹配时可使用原文名称）：\n{characters_lib_name}\n\n角色介绍（⭐重要：用于理解\"我\"和称呼对应的角色以及角色关系）：\n{characters_introduction}\n\nClip ID:\n{clip_id}\n\n【输出要求】\n\n请将上述原文转换为标准剧本格式，只返回JSON对象。\n再次强调：输出必须是**严格合法的JSON**，不得包含任何额外文本。\n"
  },
  {
    "path": "lib/prompts/novel-promotion/select_location.en.txt",
    "content": "You are a location asset extraction specialist.\nExtract locations that need dedicated background assets.\n\nInput text:\n{input}\n\nExisting location library:\n{locations_lib_name}\n\nSelection rules:\n1. Include locations where meaningful story actions happen.\n2. Exclude abstract/metaphorical spaces and one-off passing mentions.\n3. Deduplicate aliases of the same location.\n4. Prefer exact library names when a location already exists.\n\nFor each selected location, generate 3 wide-angle environment descriptions.\nEach description should:\n- start with location name in brackets: \"[Location Name] ...\"\n- describe spatial layout, depth layers, major objects, and lighting direction\n- remain environment-only (no named protagonist actions)\n- use concise, production-ready English\n\nOutput format (JSON only):\n{\n  \"locations\": [\n    {\n      \"name\": \"location_name\",\n      \"summary\": \"short usage summary\",\n      \"has_crowd\": false,\n      \"crowd_description\": \"\",\n      \"descriptions\": [\n        \"[location_name] description 1\",\n        \"[location_name] description 2\",\n        \"[location_name] description 3\"\n      ]\n    }\n  ]\n}\n\nStrict constraints:\n1. JSON only.\n2. If no valid location exists, return: {\"locations\":[]}.\n3. ⚠️ JSON SAFETY: All quotation marks in text (\"\"''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes \" inside string values.\n  "
  },
  {
    "path": "lib/prompts/novel-promotion/select_location.zh.txt",
    "content": "你是\"场景资产建立师\"。请基于我提供的文本（可能是小说、剧本、或混合格式），筛选【需要制作画面的场景】，生成用于出图与后续生产的资产 JSON。\n\n【筛选规则 - 精准提取模式】\n\n✅【必须提取的场景】：\n  - 剧本场景头部中出现的地点（如\"内景 客厅 白天\"）\n  - 角色实际身处、产生互动的具体场所\n  - 剧情主线发生的核心地点\n  - 多次出现或戏份较重的场景\n  - 有明确空间描写、需要制作背景画面的地点\n\n❌【不提取的场景】（严格执行！）：\n  - 一次性路过、仅提及但无剧情发生的地点\n  - 意境类、比喻类、修辞类描述（如\"从天堂打到地狱\"、\"从天上打到地下\"、\"心灵深处\"、\"记忆长河\"等）\n  - 抽象空间或无法具象化的概念（如\"命运交汇点\"、\"时空裂缝\"）\n  - 仅作为对话背景提及、没有实际画面需求的地点\n  - 纯过渡性场景（如\"穿过走廊\"、\"路过门口\"等一笔带过的移动描述）\n  - 回忆/幻想中一闪而过、没有具体剧情的场景\n  - 战斗过程中一笔带过的地点（如\"打遍三界\"、\"从山上打到山下\"、\"从天宫打到凡间\"等表示战斗范围的修辞）\n\n📋【判断标准】：\n  问自己：这个场景是否需要单独制作一张背景图？角色是否在此场景有实际戏份？\n  如果只是一句话带过的地点，则不提取。\n  如果是表示\"打斗范围\"的修辞（如从天堂到地狱），则不提取。\n\n🔄【去重规则】：\n  - 若场景在库中已存在则跳过，场景库如下：{locations_lib_name}\n  - 同一场景不同称呼合并为一个（如\"书房\"和\"张先生的书房\"视为同一场景）\n  - 返回的场景名必须与资产库中已有名称完全一致\n\n【场景生成要求 - A: 全景空间版】\n侧重点：宽广完整的空间全貌、整体布局、画面层次\n\n⚠️ 【核心要求】必须生成【宽广的空间全景】，展示场景的完整面貌，而非局部特写！\n- 镜头应该是【广角/远景】视角，能看到整个空间的全貌\n- 展示空间的完整边界（墙壁、地面、天花板/天空）\n- 让观众能够清晰理解这是一个什么样的完整空间\n- 严格按照原文的场景描述来描写，原文描述的场景是最优先级，其他才可以自由发挥\n\n1. **开头必须明确写明场景名称**：\n   - 每条描述开头必须以「场景名」的形式标注空间属性\n   - 示例：「皇宫」殿内铺设着... / 「客厅」窗外阳光透过... / 「卧室」床边放着...\n   - 这样AI在生成图片时能明确理解这是什么类型的空间\n\n2. 每个场景生成 3 条中文环境描述（用于AI图片生成），供用户选择\n\n3. 3条描述要求：\n   - 全部符合原文描述的场景特征\n   - 可以自由发挥细节，但整体风格保持一致，不要有过大差异\n   - 全部使用广角/远景视角，展示完整空间全貌\n   - 每条描述开头都必须以「场景名」标注\n\n4. 每条描述都必须包含：\n\n   **宽广空间感**（最重要）：\n   - 必须是【广角镜头】或【远景视角】，能看到空间的大部分区域\n   - 室内场景：能看到2-3面墙壁、地板、部分天花板\n   - 室外场景：能看到开阔的视野、远处的地平线或建筑群\n   - 强调空间的【开阔感】和【完整性】\n\n   **空间定位与规模**：\n   - 场景类型（室内/室外/幻想空间）\n   - 空间大小感：描述实际的空间尺度（如\"约30平米的客厅\"/\"一眼望不到边的草原\"）\n   - 层高/纵深感：能看到的最远距离\n\n   **空间层次**（创造画面深度）：\n   - 前景：靠近镜头的元素（桌角/门框边缘/植物叶片/栏杆等，部分可见）\n   - 中景：主要场景区域（核心物体的完整呈现）\n   - 背景：远处可见的元素（窗外景色/远处墙面/天际线/门廊深处）\n\n   **物体布局**：\n   - 使用明确的位置词：左侧/右侧/中央/角落/靠窗/远处\n   - 描述物体之间的空间关系和前后层次\n   - 5-8件物体，每件都有位置说明\n\n   **光线方向**：光从哪个方向照入，照亮哪些区域\n\n5. 描述规范：\n   - 强调位置关系词：前方、远处、左侧、角落、靠近、深处\n   - 长度 100-150 字\n   \n   ⚠️【场景图不能出现任何角色 - 核心规则】：\n   \n   场景图的用途：场景图是纯粹的\"背景板\"，主角和重要角色会在后期通过 AI 合成到背景上。\n   因此，场景描述中**绝对不能出现任何有名有姓的角色**。\n   \n   ❌ 错误示例（包含了角色）：\n      - \"两只猴王持棒对峙\" → 错！猴王是角色，不能出现\n      - \"张三站在门口迎接\" → 错！张三是角色，不能出现\n      - \"孙悟空和六耳猕猴在街上打斗\" → 错！主角不能出现\n      \n   ✅ 正确示例（纯背景）：\n      - \"「古道」广角镜头展现蜿蜒在险峻石林间的黄土古道，前景几株枯松，中景道路宽阔平坦，尘土飞扬，背景是连绵群山。\"\n      - \"「宴会厅」大厅远处三两宾客交谈\" → 可以！这是无名背景群众\n      - \"「集市」街道上行人往来\" → 可以！这是模糊的路人群众\n   \n   📋 什么情况可以写人群？\n      - 只有无名的、模糊的背景群众可以出现（如\"宾客\"、\"路人\"、\"行人\"、\"围观群众\"）\n      - 这些群众不能有具体描述，只能用模糊词汇\n      - 如果场景是私密空间或无人场景，保持空镜即可\n   \n   - 不包含艺术风格描述，风格由系统自动添加\n\n6. 场景命名规则：中文 \"地点_时间/状态\"\n   - 示例：\"客厅_白天\"/\"空间站_夜间\"/\"仙宫_黄昏\"/\"森林_迷雾中\"\n\n7. 剧情中出现的关键元素必须在场景中体现（如椅子、桌子等）\n\n8.如无特殊要求，使用用户输入的语言来进行场景生成，例如输入英文输出偏西方场景，中文则输出偏中国场景，但是原则要按照文字剧本里实际发生的地点为准，\n\n【输出规范（只允许以下 JSON 结构；字段名中文；不得输出任何多余文字）】\n{\n  \"locations\": [\n    {\n      \"name\": \"场景_时间\",\n      \"summary\": \"场景简要说明（用途/人物关联，如：张三居住的主卧室、公司高层会议室等）\",\n      \"has_crowd\": true/false,\n      \"crowd_description\": \"人群类型描述（仅当has_crowd为true时填写，如：宴会宾客、集市人群、学生们等）\",\n      \"descriptions\": [\n        \"「场景名」场景环境描述1（如has_crowd为true则包含人群元素）\",\n        \"「场景名」场景环境描述2\",\n        \"「场景名」场景环境描述3\"\n      ]\n    }\n  ]\n}\n\n【严格性】\n- 若无符合条件的场景，locations数组返回 []。\n- 只返回上述 JSON；不得输出markdown代码块标记、如```json注释或解释；不得添加未定义字段。\n- 每条描述必须遵守长度限制（100-150字）；发现超长请自行截断。\n- 禁止在 JSON 字符串值中出现英文双引号 \"。原文中的所有引号（\"\"''等）必须统一替换为「」。如字符串内确实需要英文双引号，必须转义为 \\\"\n\n【原文内容如下】\n{input}\n"
  },
  {
    "path": "lib/prompts/novel-promotion/single_panel_image.en.txt",
    "content": "You are a professional storyboard image artist.\nGenerate exactly one high-quality image for one panel.\n\nAbsolute constraints:\n1. No text in the image.\n2. No subtitles, labels, numbers, watermarks, or symbols.\n3. Do not create collage or multi-frame output.\n4. Output exactly one frame.\n\nAspect ratio (must be exact):\n{aspect_ratio}\n\nStoryboard panel data:\n{storyboard_text_json_input}\n\nSource text:\n{source_text}\n\nStyle requirement:\n{style}\n\nExecution rules:\n1. Respect panel composition, character placement, and action logic.\n2. Use reference images for style/identity consistency only.\n3. Repaint the background according to shot type and angle.\n4. If storyboard conflicts with source text, keep narrative logic from source text.\n5. Keep final visual style consistent with provided references.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/single_panel_image.zh.txt",
    "content": "你是一位专业的分镜画师。请根据以下分镜数据生成单张高质量的镜头图片。\n\n【绝对禁止 - 图像中不得出现任何文字 - 最高优先级】\n生成的图像中绝对禁止出现任何文字：\n- 禁止出现镜头类型标签（特写、中景、全景等）\n- 禁止出现镜头运动文字（推、拉、摇等）\n- 禁止出现数字或画面编号（1、2、3等）\n- 禁止出现中文或英文文字\n- 禁止出现水印、注释或符号\n- 参考图上的文字标签仅供你识别使用，禁止画入图中\n- 所有输入信息都是给你的指令，不是要画进图像的内容\n纯视觉内容！纯视觉内容！纯视觉内容！\n- 每个图片只能有一张镜头，禁止一张图片多张镜头，禁止拼图，禁止生成多张图，禁止生成一张图片里面三张图\n\n【⚠️ 画面比例 - 必须严格遵守】\n本次生成的画面比例为：{aspect_ratio}\n- 必须严格按照此比例生成画面\n- 禁止输出与指定比例不符的图像\n- 禁止因参考图比例影响输出比例\n- 每张输出的图片里面只能有一张图，禁止一张图片多张图！\n\n【参考图使用规则】\n- 角色参考：用于参考角色外貌、服装、面部特征、体型\n- 场景参考：仅用于参考环境的构图布局风格和氛围，需根据画面重新绘制，不要直接贴在背景上使用\n- 画面的背景必须根据镜头角度和景别重新绘制\n- 特写/细节镜头应使用虚化或局部背景\n- 参考图上方的文字标签标注了角色/场景名称，请与分镜要求对应\n\n【⚠️ 摄影规则 - 关键】\n如果分镜数据中包含 photography_rules，必须严格遵守：\n- **光照方向**：按照 lighting.direction 的描述绘制光源方向\n- **角色位置**：按照 characters 数组中的 screen_position 确定角色在画面中的位置（左/右/中央）\n- **角色姿势**：按照 characters 数组中的 posture 确定角色姿态（站立/坐着等）\n- **景深**：按照 depth_of_field 的描述控制前后景虚实\n- **色调**：按照 color_tone 的描述确定整体色彩氛围\n\n【分镜内容要求】\n- 根据分镜数据设计画面的视觉内容\n- 确保镜头方向一致（不跳轴），角色位置正确\n- 严格按照文字分镜要求绘制镜头\n\n【⚠️ 原文优先原则 - 重要】\n分镜描述可能存在空间/位置错误。务必与原文交叉验证：\n- 当分镜与原文冲突时：按原文的空间关系、角色位置、动作顺序\n- 参考分镜的：镜头类型、构图、摄影角度\n- 参考原文的：剧情逻辑、空间关系、角色互动、动作顺序\n- 智能结合两者生成最准确的视觉效果\n\n【绝对规则 - 严格遵守】\n- 严格按照分镜要求绘制画面\n- 禁止添加、删除或重排任何镜头\n- 镜头必须与输入完全匹配\n\n【分镜数据】\n{storyboard_text_json_input}\n\n【镜头原文】\n{source_text}\n\n【⚠️ 风格要求 - 必须严格遵守】\n画面风格：{style}\n- 必须严格遵循上传的角色和场景参考图的美术风格\n- 角色绘制风格、线条、色彩必须与角色参考图匹配\n- 环境风格、氛围、色调必须与场景参考图匹配\n- 禁止出现与参考图风格不一致的情况！\n"
  },
  {
    "path": "lib/prompts/novel-promotion/storyboard_edit.en.txt",
    "content": "You are an expert storyboard image editor.\nEdit a single panel image or a panel set according to user instruction.\n\nRules:\n1. Do not add any text overlay, subtitle, or technical labels.\n2. If user uploaded reference images, use them as primary visual guidance.\n3. Keep identity and scene continuity unless user requests a change.\n\nUser instruction:\n{user_input}\n\nReturn only the edited image result.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/storyboard_edit.zh.txt",
    "content": "你是一个修改编辑图片的大师，你需要根据用户的指令来编辑单个镜头或整组分镜图片，编辑时需要遵守以下规则：\n1:不要添加任何多余标识符文字，如字幕，景别信息等\n2:如果用户上传的有图片，那么就按照图片来进行参考修改\n\n用户的编辑指令如下：{user_input}\n\n请根据指令和原图，输出修改后的图片\n\n\n\n\n\n"
  },
  {
    "path": "lib/prompts/novel-promotion/voice_analysis.en.txt",
    "content": "You are a dialogue voice-line analyzer.\nExtract spoken lines from text, assign speaker, estimate emotion intensity, and map to storyboard panels.\n\nOutput format (JSON array only):\n[\n  {\n    \"lineIndex\": 1,\n    \"speaker\": \"Speaker name\",\n    \"content\": \"Dialogue line\",\n    \"emotionStrength\": 0.3,\n    \"matchedPanel\": {\n      \"storyboardId\": \"storyboard_id\",\n      \"panelIndex\": 0\n    }\n  }\n]\n\nInput text:\n{input}\n\nCharacter library:\n{characters_lib_name}\n\nCharacter introductions:\n{characters_introduction}\n\nStoryboard JSON:\n{storyboard_json}\n\nRules:\n1. Extract spoken dialogue only (quoted speech, direct speech, inner speech that should be voiced).\n2. Exclude pure narration, action-only description, and scene-only description.\n3. emotionStrength must be between 0.1 and 0.5.\n4. Match panel by order + speaker consistency + semantic relevance.\n5. If no reliable panel match exists, set \"matchedPanel\": null.\n6. Use canonical names from character library when possible.\n7. If there is no spoken dialogue that should be voiced, return [].\n8. Return strict JSON only, no markdown.\n9. ⚠️ 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.\n"
  },
  {
    "path": "lib/prompts/novel-promotion/voice_analysis.zh.txt",
    "content": "你是\"台词发言人分析大师\"。\n任务：从文本中提取需要配音的**对话台词**，分析情绪强度，并匹配对应的视频镜头。\n\n输出格式（只返回JSON，禁止markdown标记）：\n[\n  {\n    \"lineIndex\": 1,\n    \"speaker\": \"发言人名称\",\n    \"content\": \"台词内容\",\n    \"emotionStrength\": 0.5,\n    \"matchedPanel\": {\n      \"storyboardId\": \"分镜组ID\",\n      \"panelIndex\": 0\n    }\n  }\n]\n\n分析规则：\n\n1. 【台词提取 - 最重要】\n   ✅ 只提取以下类型的内容：\n   - **带引号的对话**：\"xxx\" 或 \"xxx\" 或 「xxx」\n   - **直接引语**：他说：\"xxx\"、她喊道：\"xxx\"\n   - **内心独白**：我心想：\"xxx\"\n   \n   ❌ 严格排除以下内容：\n   - 叙述性文字（无引号的描述）\n   - 动作描写（描述角色的动作）\n   - 场景描述（描述环境、画面）\n   - 章节标题\n   - 明确设定为无语言、默片、纯画面表达的内容\n\n   ⚠️ 判断标准：这句话是否需要有人\"说出来\"？如果只是描述画面动作，不要提取。\n   ⚠️ 如果全文没有任何需要配音的台词，直接返回 []。\n\n2. 【情绪强度 emotionStrength】\n   根据台词的情绪激烈程度，输出0.1-0.5之间的数值（⚠️ 注意：最高不超过0.5，保持语音自然平稳）：\n   \n   | 情绪类型 | 强度范围 | 示例 |\n   |---------|---------|------|\n   | 平静/陈述 | 0.1-0.15 | \"好的，我知道了\" |\n   | 普通对话 | 0.15-0.2 | \"你今天怎么来了？\" |\n   | 疑惑/好奇 | 0.2-0.25 | \"这是怎么回事？\" |\n   | 惊讶/意外 | 0.25-0.3 | \"什么？！你说真的？\" |\n   | 生气/愤怒 | 0.3-0.35 | \"你给我滚出去！\" |\n   | 悲伤/哭泣 | 0.25-0.35 | \"为什么要这样对我...\" |\n   | 狂喜/激动 | 0.35-0.4 | \"太好了！我们成功了！\" |\n   | 咆哮/嘶吼 | 0.4-0.5 | \"我要杀了你！！！\" |\n\n3. 【发言人识别】\n   - 对话内容：识别说话者，如\"他说\"、\"她喊道\"\n   - 无引导词的引号内容：根据上下文推断发言人\n   - 如果无法确定发言人，设为\"旁白\"\n\n4. 【角色匹配】\n   - 角色库：{characters_lib_name}\n   - 角色介绍：{characters_introduction}\n   - 优先使用角色库中完全一致的名称\n   - ⭐ 参考角色介绍理解\"我\"和其他称呼对应的角色\n   - 如果不存在，使用原文中的称呼\n\n5. 【镜头匹配 - 严格规则】\n   ⚠️ 这是关键步骤，必须严格遵守以下规则：\n   \n   a) **顺序约束**：\n      - 台词在原文中的出现顺序必须与分镜顺序大致对应\n      - 第N条台词应该匹配在第N个分镜附近，不能跳跃太远\n      - 禁止乱序匹配（如第5条台词匹配到第1个分镜）\n   \n   b) **发言人校验**：\n      - 台词的speaker必须与分镜的characters字段中的角色对应\n      - 如果分镜画面角色是\"玄离\"，不能匹配\"柳如烟\"的台词\n      - 对话场景：谁说话，就匹配包含说话者的分镜\n   \n   c) **内容匹配**：\n      - 优先匹配台词内容完全包含在分镜text_segment中的情况\n      - 其次匹配台词内容与text_segment语义相近的情况\n   \n   d) **匹配策略**：\n      1. 首先根据text_segment精确匹配台词内容\n      2. 验证分镜角色是否包含台词发言人\n      3. 验证顺序是否合理（前后3个分镜范围内）\n      4. 如果无法满足以上条件，matchedPanel设为null\n   \n   e) **示例**：\n      - 原文顺序：柳如烟说\"殿下身份尊贵\" → 玄离说\"胆子挺大\"\n      - 分镜顺序：分镜15(柳如烟特写) → 分镜16(玄离特写)\n      - 正确匹配：柳如烟台词→分镜15，玄离台词→分镜16\n      - 错误匹配：柳如烟台词→分镜16（发言人不匹配）\n\n6. 【多音字处理 - 重要】\n   为确保TTS语音合成发音正确，对于容易被误读的多音字，需要替换为**读音完全相同（包括声调）的单音字**。\n   \n   处理原则：\n   a) **识别多音字**：找出台词中的多音字（如：还、行、了、乐、朝、重、都等）\n   b) **判断正确读音**：根据上下文语义判断该字在此处的正确读音\n   c) **选择替换字**：找一个读音完全相同（声母、韵母、声调都一致）的常用单音字替换\n   d) **验证替换**：确保替换后的字读音与原意读音完全一致\n   \n   替换示例思路：\n   - \"还(huán)给我\" → 用\"环\"替换，因为\"环\"只读huán\n   - \"银行(háng)\" → 用\"航\"替换，因为\"航\"只读háng  \n   - \"了(liǎo)解\" → 用\"聊\"替换，因为\"聊\"只读liáo（接近liǎo）\n   - \"快乐(lè)\" → 用\"乐\"保持原字，因为TTS通常能正确读常见词\n   - \"重(zhòng)量\" → 用\"众\"替换，因为\"众\"只读zhòng\n   \n   ⚠️ 注意事项：\n   - 必须确保替换字的读音与目标读音完全一致，不要用读音相近但不同的字\n   - 例如：\"勒\"读lè或lēi，不能用来替换读le的字\n   - 常见词组（如\"了解\"、\"快乐\"、\"音乐\"）TTS通常能正确读，可以保持原字\n   - 只有当多音字在特定语境下容易被TTS误读时才需要替换\n\n7. 【JSON安全输出】\n   ⚠️ 原文中的所有引号（\"\"''「」等）在 JSON 字符串值中必须统一替换为「」，严禁出现未转义的英文双引号 \"\n   - ✅ 正确：\"content\":\"「你好啊」\"\n   - ❌ 错误：\"content\":\"\"你好啊\"\"\n\n分镜数据如下：\n{storyboard_json}\n\n原文如下：\n{input}\n"
  },
  {
    "path": "lib/prompts/proxy.ts",
    "content": "\nexport async function setProxy() {\n    if (process.env.PROXY_URL) {  // If you are in China, you must use this proxy:\n      const { setGlobalDispatcher, ProxyAgent } = await import(\"undici\");\n      const proxyAgent = new ProxyAgent(process.env.PROXY_URL);\n      setGlobalDispatcher(proxyAgent);\n    }\n  }"
  },
  {
    "path": "lib/prompts/skills/api-config-template.system.txt",
    "content": "你是 API 配置助手，负责把第三方 API 文档映射为可执行模型模板并保存。\n\n目标：\n1) 最少追问，只收集运行必填字段。\n2) 字段齐全后立刻调用工具保存。\n3) 绝不输出臆测字段；不确定就追问。\n\n工具调用规则：\n1) 单模型保存：saveModelTemplate\n2) 多模型一次配置：saveModelTemplates（优先批量）\n3) compatMediaTemplate 必须是 JSON 对象，不能是 JSON 字符串\n4) 保存前先自检字段名，严禁使用 submit/query 这类旧字段\n\n模板结构必须是：\n{\n  \"version\": 1,\n  \"mediaType\": \"image\" | \"video\",\n  \"mode\": \"sync\" | \"async\",\n  \"create\": { \"method\": \"...\", \"path\": \"...\", \"contentType\"?: \"...\", \"bodyTemplate\"?: {}, \"multipartFileFields\"?: [\"...\"] },\n  \"status\"?: { \"method\": \"...\", \"path\": \"...\" },\n  \"content\"?: { \"method\": \"...\", \"path\": \"...\" },\n  \"response\": {\n    \"taskIdPath\"?: \"$....\",\n    \"statusPath\"?: \"$....\",\n    \"outputUrlPath\"?: \"$....\",\n    \"outputUrlsPath\"?: \"$....\",\n    \"errorPath\"?: \"$....\"\n  },\n  \"polling\"?: {\n    \"intervalMs\": 5000,\n    \"timeoutMs\": 600000,\n    \"doneStates\": [\"completed\"],\n    \"failStates\": [\"failed\", \"error\"]\n  }\n}\n\n严格校验约束：\n1) version 只能是 1\n2) mediaType 只能 image/video\n3) mode 只能 sync/async\n4) create.path 必填\n5) create.method 为 POST/PUT/PATCH 时 create.bodyTemplate 必填\n6) mode=async 时 status.path 必填，且必须包含 {{task_id}}\n7) mode=async 时 response.taskIdPath、response.statusPath、polling 必填\n8) mode=sync 时 response.outputUrlPath 或 response.outputUrlsPath 至少一个\n9) JSONPath 字段必须以 $. 开头\n10) 只有 contentType=multipart/form-data 时才能使用 multipartFileFields；且 multipartFileFields 中的字段必须存在于 bodyTemplate 中\n\n最小必问项（只问这些）：\n1) modelId（每个模型）\n2) name（每个模型；无则默认 modelId）\n3) type（image/video）\n4) create endpoint（method + path + body 字段映射）\n5) async 场景下 status endpoint（method + path，且 path 必须带 {{task_id}}）\n6) async 场景下必须明确 taskIdPath、statusPath、polling.doneStates、polling.failStates\n7) 若 create.contentType 是 multipart/form-data，必须明确哪些字段是文件字段，并写入 multipartFileFields\n8) sync 场景下必须明确 outputUrlPath 或 outputUrlsPath\n\n禁止隐式默认：\n1) 不得擅自补 taskIdPath/statusPath/outputUrlPath\n2) 不得擅自补 polling.doneStates/failStates\n3) 文档未明确时必须追问，不能猜\n4) 只有 create.contentType 未写明时，可根据请求示例判断为 application/json 或 multipart/form-data\n\nvideo create bodyTemplate 默认建议（除非文档明确不同）：\n{\n  \"model\": \"{{model}}\",\n  \"prompt\": \"{{prompt}}\",\n  \"seconds\": \"{{duration}}\",\n  \"size\": \"{{size}}\",\n  \"input_reference\": \"{{image}}\"\n}\n\npath 选择规则（非常重要）：\n1) 若文档给了完整 URL，直接完整保留\n2) 若文档给的是相对路径，原样保留，不要擅自去掉 /v1、/v2 或其他前缀\n3) 不允许自作主张改写版本前缀\n\n交互风格：\n1) 不展示思维链路，不输出 <think>\n2) 先给简短中文结论，再给补充问题或执行结果\n3) 字段齐全时直接调用工具，不要重复确认\n4) 工具返回 invalid 时，逐条复述 issues 并只追问缺失字段，然后再次调用工具\n\n当前 providerId={{providerId}}\n"
  },
  {
    "path": "lib/prompts/skills/tutorial.system.txt",
    "content": "你是产品教程助手。\n目标是给出准确、可执行、简洁的操作步骤。\n当用户信息不足时先提问，不要猜测。\n禁止编造不存在的页面、按钮、接口或参数。\n"
  },
  {
    "path": "messages/en/actions.json",
    "content": "{\n  \"storyboard\": \"Storyboard\",\n  \"storyboard_candidate\": \"Storyboard Candidate\",\n  \"character\": \"Character\",\n  \"location\": \"Location\",\n  \"video\": \"Video\",\n  \"analyze\": \"Analyze\",\n  \"analyze_character\": \"Character Analysis\",\n  \"analyze_location\": \"Location Analysis\",\n  \"clips\": \"Clip Splitting\",\n  \"storyboard_text_plan\": \"Storyboard Planning\",\n  \"storyboard_text_detail\": \"Storyboard Details\",\n  \"tts\": \"Text-to-Speech\",\n  \"regenerate\": \"Regenerate\",\n  \"voice-generate\": \"Voice Generation\",\n  \"voice-design\": \"Voice Design\",\n  \"lip-sync\": \"Lip Sync\"\n}"
  },
  {
    "path": "messages/en/apiConfig.json",
    "content": "{\n  \"title\": \"API Configuration\",\n  \"saving\": \"Saving...\",\n  \"saved\": \"Saved\",\n  \"saveFailed\": \"Save failed\",\n  \"connected\": \"Connected\",\n  \"notConfigured\": \"Not configured\",\n  \"configure\": \"Configure\",\n  \"connect\": \"Connect\",\n  \"compatibilityLayerOpenAI\": \"OpenAI Compatible Layer\",\n  \"compatibilityLayerGemini\": \"Gemini Compatible Layer\",\n  \"show\": \"Show\",\n  \"hide\": \"Hide\",\n  \"capability\": \"Models\",\n  \"default\": \"Default\",\n  \"delete\": \"Delete\",\n  \"add\": \"Add\",\n  \"cancel\": \"Cancel\",\n  \"close\": \"Close\",\n  \"save\": \"Save\",\n  \"comingSoon\": \"Coming soon\",\n  \"priceInput\": \"Input {amount}\",\n  \"priceOutput\": \"Output {amount}\",\n  \"priceUnavailable\": \"N/A\",\n  \"fillComplete\": \"Please fill in all fields\",\n  \"fillPricing\": \"Please fill in pricing information\",\n  \"pricingInputLabel\": \"Input price\",\n  \"pricingOutputLabel\": \"Output price\",\n  \"pricingBasePriceLabel\": \"Base price (optional)\",\n  \"pricingEnableCustom\": \"Enable custom pricing (optional)\",\n  \"pricingOptionPricesPlaceholder\": \"Option pricing JSON (optional), e.g. {\\\"resolution\\\":{\\\"1024x1024\\\":0.12}}\",\n  \"modelIdExists\": \"Model ID already exists\",\n  \"flushConfigFailed\": \"Failed to save provider settings. Please save API key/Base URL first.\",\n  \"probeLlmProtocolFailed\": \"Model protocol probe failed. Please try again.\",\n  \"probeAuthFailed\": \"Model protocol probe authentication failed. Please check API key.\",\n  \"probeInconclusive\": \"Model protocol probe is inconclusive (rate limit or provider error). Please retry later.\",\n  \"probeRequestFailed\": \"Model protocol probe request failed. Please try again.\",\n  \"modelDisplayName\": \"Display Name (for your reference)\",\n  \"modelActualId\": \"Actual Model ID (API parameter)\",\n  \"noModelsForProvider\": \"No models configured for this provider\",\n  \"defaultModels\": \"Default Model Configuration\",\n  \"textDefault\": \"Text Model\",\n  \"characterDefault\": \"Character Model\",\n  \"locationDefault\": \"Location Model\",\n  \"storyboardDefault\": \"Storyboard Model\",\n  \"editDefault\": \"Edit Model\",\n  \"videoDefault\": \"Video Model\",\n  \"audioDefault\": \"Voice Model\",\n  \"lipsyncDefault\": \"Lip Sync Model\",\n  \"selectDefault\": \"Select\",\n  \"defaultModelDesc\": {\n    \"analysisModel\": \"Handles script analysis, storyboard construction and full-pipeline text reasoning.\",\n    \"videoModel\": \"Synthesizes images and instructions into final video clips.\",\n    \"characterModel\": \"Generate character portraits and appearance references from script descriptions\",\n    \"locationModel\": \"Generate scene environments and spatial references from script descriptions\",\n    \"storyboardModel\": \"Generate shot frames and visual references from storyboard scripts\",\n    \"editModel\": \"Perform localized edits, style adjustments and refinements on existing images\",\n    \"audioModel\": \"Convert text dialogue into natural and fluent speech audio\",\n    \"lipSyncModel\": \"Precisely synchronize speech audio with video character lip movements\",\n    \"voiceDesignModel\": \"Design custom voice tones and speech style profiles for characters\"\n  },\n  \"defaultModelSection\": {\n    \"coreFoundation\": \"Text Analysis & Video\",\n    \"creativePipeline\": \"Global Image Model Config\",\n    \"unifiedOverride\": \"Batch Image Model Config\",\n    \"unifiedOverrideHint\": \"Set the model responsible for image generation/editing across the entire system\",\n    \"unifiedOverridePlaceholder\": \"Apply to all scenes...\",\n    \"followUnified\": \"Follow global config\",\n    \"extensions\": \"Extensions\",\n    \"coreTextTitle\": \"Text Analysis Model\",\n    \"coreVideoTitle\": \"Video Generation Model\",\n    \"pipelineCharacter\": \"Character Gen\",\n    \"pipelineLocation\": \"Scene Gen\",\n    \"pipelineStoryboard\": \"Shot Gen\",\n    \"pipelineEdit\": \"Image Edit\",\n    \"extLipSync\": \"Lip Sync\",\n    \"extTTS\": \"Speech Synthesis\",\n    \"extVoiceDesign\": \"Voice Design\",\n    \"corePlaceholder\": \"Required\",\n    \"extPlaceholder\": \"Not enabled\"\n  },\n  \"imageModelTip\": \"We recommend using Google's Banana series models. Other image models currently have limited generation quality.\",\n  \"customProviderTip\": \"This project is currently in beta. Due to varying custom API formats across providers, custom API compatibility is still limited. We recommend using the built-in official APIs. Future updates will expand compatibility with more providers.\",\n  \"providerPool\": \"Provider Pool\",\n  \"providerPoolDesc\": \"Configure and use a rich selection of models from global providers\",\n  \"dragToSort\": \"Drag to sort\",\n  \"dragToSortHint\": \"Drag the top-left handle on each card to reorder providers\",\n  \"hideProvider\": \"Hide provider\",\n  \"hideProviderConfirm\": \"Are you sure you want to hide this provider? It will be moved to the bottom and can be restored at any time.\",\n  \"showProvider\": \"Show provider\",\n  \"showHiddenProviders\": \"Show hidden providers\",\n  \"hideHiddenProviders\": \"Hide hidden providers\",\n  \"hiddenProvidersPrefix\": \"Hidden\",\n  \"providerIdExists\": \"Provider ID already exists\",\n  \"presetProviderCannotDelete\": \"Preset providers cannot be deleted\",\n  \"confirmDeleteProvider\": \"Are you sure you want to delete this provider?\",\n  \"presetModelCannotDelete\": \"Preset models cannot be deleted\",\n  \"confirmDeleteModel\": \"Are you sure you want to delete this model?\",\n  \"addGeminiProvider\": \"Add Model Provider\",\n  \"baseUrl\": \"Base URL\",\n  \"configureBaseUrl\": \"Configure URL\",\n  \"addModel\": \"Add Model\",\n  \"batchModeHalfPrice\": \"Batch mode (50% price)\",\n  \"openaiCompatVideoOnlyHint\": \"Only OpenAI official-format image-to-video models are supported.\",\n  \"typeText\": \"Text\",\n  \"typeImage\": \"Image\",\n  \"typeVideo\": \"Video\",\n  \"typeAudio\": \"Audio\",\n  \"apiKeyLabel\": \"API Key\",\n  \"apiType\": \"API Type\",\n  \"apiTypeGeminiCompatible\": \"Gemini Compatible\",\n  \"apiTypeOpenAICompatible\": \"OpenAI Compatible\",\n  \"apiTypeGeminiHint\": \"Uses Google SDK\",\n  \"otherProviders\": \"Other Settings\",\n  \"audioCategory\": \"Audio\",\n  \"audioAndLipsync\": \"Audio & Lip Sync\",\n  \"configureApiKey\": \"Configure API Key\",\n  \"enterApiKey\": \"Enter API Key\",\n  \"testConnection\": \"Test Connection\",\n  \"testing\": \"Testing...\",\n  \"testPassed\": \"Connection test passed\",\n  \"testFailed\": \"Connection test failed\",\n  \"testWarning\": \"We recommend checking your configuration before adding\",\n  \"testRetry\": \"Retry\",\n  \"addAnyway\": \"Add Anyway\",\n  \"testStep\": {\n    \"models\": \"Model List\",\n    \"textGen\": \"Text Generation\",\n    \"imageGen\": \"Image Generation\",\n    \"credits\": \"Credits Check\",\n    \"audioGen\": \"Audio Generation\",\n    \"skipped\": \"Skipped\"\n  },\n  \"tabs\": {\n    \"llm\": \"Text Models\",\n    \"image\": \"Image Models\",\n    \"video\": \"Video Models\",\n    \"audio\": \"Audio Models\",\n    \"other\": \"Other\"\n  },\n  \"sections\": {\n    \"llmApiKeys\": \"Text Model API Keys\",\n    \"imageApiKeys\": \"Image Model API Keys\",\n    \"videoApiKeys\": \"Video Model API Keys\",\n    \"audioApiKey\": \"Audio Model API Key\",\n    \"lipsyncApiKey\": \"Lip Sync API Key\"\n  },\n  \"defaultModel\": {\n    \"title\": \"Default Model\",\n    \"hint\": \"New projects and Asset Hub will use this default configuration. You can also customize models per project in project settings.\",\n    \"notSelected\": \"Not selected\",\n    \"analysis\": \"Analysis Model\",\n    \"image\": \"Image Generation\",\n    \"video\": \"Video Generation\",\n    \"resolution\": \"Image Resolution\"\n  },\n  \"workflowConcurrency\": {\n    \"analysis\": \"Analysis Concurrency\",\n    \"image\": \"Image Concurrency\",\n    \"video\": \"Video Concurrency\"\n  },\n  \"viewTutorial\": \"View Tutorial\",\n  \"tutorial\": {\n    \"button\": \"Tutorial\",\n    \"title\": \"Setup Guide\",\n    \"subtitle\": \"Follow these steps to complete the configuration\",\n    \"close\": \"Got it\",\n    \"openLink\": \"Open link\",\n    \"steps\": {\n      \"ark_step1\": \"Go to the Volcano Engine console to create an API Key\",\n      \"ark_step2\": \"On the model management page, click 'Enable All Models' button in the top right corner\",\n      \"openrouter_step1\": \"Go to OpenRouter platform and create an API Key (must select models with image capabilities)\",\n      \"fal_step1\": \"Go to FAL platform and create an API Key\",\n      \"google_step1\": \"Go to Google AI Studio and create an API Key\",\n      \"minimax_step1\": \"Go to MiniMax platform and get an API Key\",\n      \"vidu_step1\": \"Go to the Vidu platform and click 'Create API Key'\",\n      \"openai_compatible_step1\": \"Enter any OpenAI-compatible service Base URL and API key\",\n      \"gemini_compatible_step1\": \"Enter any Gemini-compatible service Base URL and API key\",\n      \"bailian_step1\": \"Go to Alibaba Cloud Bailian console and get an API Key\",\n      \"siliconflow_step1\": \"Go to SiliconFlow console and create an API Key\"\n    }\n  },\n  \"assistantOpen\": \"AI Assistant\",\n  \"assistantTitle\": \"AI Config Assistant\",\n  \"assistantSubtitle\": \"Convert third-party docs into executable image/video templates and auto-save the model.\",\n  \"assistantWelcome\": \"Describe your API docs (endpoint, request body, response fields). I will ask follow-up questions and auto-save once valid.\",\n  \"assistantInputPlaceholder\": \"Paste docs or describe endpoint details...\",\n  \"assistantSend\": \"Send\",\n  \"assistantDisabledHint\": \"Save API key and Base URL first\",\n  \"assistantRequestFailed\": \"Assistant request failed. Please try again later.\",\n  \"assistantResponseInvalid\": \"Assistant response format is invalid. Please retry.\",\n  \"assistantMissingTitle\": \"Missing fields\",\n  \"assistantWarningsTitle\": \"Warnings\",\n  \"assistantDraftTitle\": \"Current draft model\",\n  \"assistantReasoningTitle\": \"Reasoning\",\n  \"assistantReasoningExpand\": \"Show\",\n  \"assistantReasoningCollapse\": \"Hide\",\n  \"assistantCompletedTitle\": \"Template Saved\",\n  \"assistantCompletedMessage\": \"Model {model} has been added to this provider.\\nClick close to finish this chat.\",\n  \"you\": \"You\",\n  \"thinking\": \"Thinking...\"\n}\n"
  },
  {
    "path": "messages/en/apiTypes.json",
    "content": "{\n  \"image\": \"Image Generation\",\n  \"video\": \"Video Generation\",\n  \"text\": \"Text Analysis\",\n  \"tts\": \"Text-to-Speech\",\n  \"voice\": \"Voice Dubbing\",\n  \"voice_design\": \"Voice Design\",\n  \"lip_sync\": \"Lip Sync\"\n}"
  },
  {
    "path": "messages/en/assetHub.json",
    "content": "{\n    \"title\": \"Asset Hub\",\n    \"description\": \"Manage your global character and location assets\",\n    \"modelHint\": \"Asset Hub uses default models. To change settings, go to\",\n    \"modelHintLink\": \"API Settings\",\n    \"modelHintSuffix\": \"\",\n    \"folders\": \"Folders\",\n    \"noFolders\": \"No folders yet\",\n    \"allAssets\": \"All Assets\",\n    \"characters\": \"Characters\",\n    \"locations\": \"Locations\",\n    \"voices\": \"Voices\",\n    \"addCharacter\": \"Add Character\",\n    \"addLocation\": \"Add Location\",\n    \"addVoice\": \"Add Voice\",\n    \"downloadAll\": \"Download All\",\n    \"downloadAllTitle\": \"Download All Image Assets as ZIP\",\n    \"downloading\": \"Packing...\",\n    \"downloadSuccess\": \"Download Complete\",\n    \"downloadFailed\": \"Download Failed\",\n    \"downloadEmpty\": \"No image assets to download\",\n    \"newFolder\": \"New Folder\",\n    \"editFolder\": \"Edit Folder\",\n    \"deleteFolder\": \"Delete Folder\",\n    \"folderName\": \"Folder Name\",\n    \"folderNamePlaceholder\": \"Enter folder name\",\n    \"emptyState\": \"No assets yet\",\n    \"emptyStateHint\": \"Click the buttons above to add characters or locations\",\n    \"generate\": \"Generate\",\n    \"generating\": \"Generating...\",\n    \"regenerate\": \"Regenerate\",\n    \"undo\": \"Undo\",\n    \"delete\": \"Delete\",\n    \"cancel\": \"Cancel\",\n    \"save\": \"Save\",\n    \"create\": \"Create\",\n    \"confirmDeleteFolder\": \"Delete this folder? Assets inside will be moved to uncategorized.\",\n    \"confirmDeleteCharacter\": \"Delete this character? This action cannot be undone.\",\n    \"confirmDeleteLocation\": \"Delete this location? This action cannot be undone.\",\n    \"confirmDeleteVoice\": \"Delete this voice? This action cannot be undone.\",\n    \"voiceName\": \"Voice Name\",\n    \"voiceNamePlaceholder\": \"Enter voice name\",\n    \"voiceNameRequired\": \"Please enter a voice name\",\n    \"voicePickerTitle\": \"Select from Voice Library\",\n    \"voicePickerEmpty\": \"No voices yet. Please create a voice first.\",\n    \"voicePickerConfirm\": \"Confirm Selection\",\n    \"pagination\": {\n        \"previous\": \"Previous\",\n        \"next\": \"Next\"\n    },\n    \"common\": {\n        \"cancel\": \"Cancel\"\n    },\n    \"generateFailed\": \"Generation failed\",\n    \"selectFailed\": \"Selection failed\",\n    \"uploadFailed\": \"Upload failed\",\n    \"editFailed\": \"Edit failed\",\n    \"saveVoiceFailed\": \"Failed to save voice\",\n    \"saveVoiceFailedDetail\": \"Failed to save voice: {error}\",\n    \"bindVoiceFailed\": \"Failed to bind voice\",\n    \"bindVoiceFailedDetail\": \"Failed to bind voice: {error}\",\n    \"voiceDesignSaved\": \"AI-designed voice has been set for {name}\",\n    \"appearanceLabel\": \"Appearance {index}\",\n    \"voiceSettings\": {\n        \"title\": \"Voice\",\n        \"noVoice\": \"No voice\",\n        \"previewFailed\": \"Preview failed: {error}\",\n        \"uploadFailed\": \"Upload audio failed: {error}\",\n        \"uploading\": \"Uploading...\",\n        \"uploaded\": \"Uploaded\",\n        \"uploadAudio\": \"Upload Audio\",\n        \"aiDesign\": \"AI Design\",\n        \"voiceLibrary\": \"Voice Library\",\n        \"pause\": \"Pause\",\n        \"preview\": \"Preview Voice\"\n    },\n    \"modal\": {\n        \"newCharacter\": \"New Character\",\n        \"confirm\": \"Confirm\",\n        \"processing\": \"Processing...\",\n        \"newLocation\": \"New Location\",\n        \"addCharacter\": \"Create Character\",\n        \"addLocation\": \"Create Location\",\n        \"adding\": \"Creating...\",\n        \"aiDesign\": \"AI Design\",\n        \"aiDesignPlaceholder\": \"e.g., A beautiful woman in a red traditional dress with flowing long hair\",\n        \"aiDesignLocationPlaceholder\": \"e.g., A classical Chinese garden with rockery and pavilions\",\n        \"aiDesignTip\": \"AI will generate a detailed description based on your input. You can edit it after generation.\",\n        \"aiDesignLocationTip\": \"AI will generate a detailed scene description based on your input\",\n        \"generate\": \"Generate\",\n        \"generating\": \"Generating...\",\n        \"nameLabel\": \"Character Name\",\n        \"namePlaceholder\": \"Enter character name\",\n        \"descLabel\": \"Character Description\",\n        \"descPlaceholder\": \"Describe the character's appearance, clothing, hairstyle, etc...\",\n        \"locationNameLabel\": \"Location Name\",\n        \"locationNamePlaceholder\": \"Enter location name\",\n        \"locationSummaryLabel\": \"Location Description\",\n        \"locationSummaryPlaceholder\": \"Describe the environment, atmosphere, features, etc...\",\n        \"referenceUpload\": \"Upload Reference\",\n        \"referenceUploadTip\": \"Upload a character image, AI will convert it to a three-view design sheet\",\n        \"convertToCharacter\": \"Convert to 3-View\",\n        \"converting\": \"Converting...\",\n        \"dropOrClick\": \"Drop image or click to upload\",\n        \"supportedFormats\": \"JPG, PNG supported\"\n    }\n}"
  },
  {
    "path": "messages/en/assetLibrary.json",
    "content": "{\n  \"title\": \"Asset Library\",\n  \"button\": \"Assets\",\n  \"characters\": \"Characters\",\n  \"locations\": \"Locations\",\n  \"noCharacters\": \"No characters\",\n  \"noLocations\": \"No locations\",\n  \"addCharacter\": \"Add Character\",\n  \"addLocation\": \"Add Location\",\n  \"generateImage\": \"Generate Image\",\n  \"regenerateImage\": \"Regenerate\",\n  \"analyzeAssets\": \"Analyze Assets\",\n  \"analyzing\": \"Analyzing...\"\n}"
  },
  {
    "path": "messages/en/assetModal.json",
    "content": "{\n    \"character\": {\n        \"title\": \"New Character\",\n        \"name\": \"Character Name\",\n        \"namePlaceholder\": \"Enter character name\",\n        \"modeReference\": \"Reference Image\",\n        \"modeDescription\": \"Description\",\n        \"isSubAppearance\": \"This is a sub-appearance\",\n        \"isSubAppearanceHint\": \"Add a new appearance state for an existing character\",\n        \"uploadReference\": \"Upload Reference\",\n        \"pasteHint\": \"Ctrl+V to paste\",\n        \"dropOrClick\": \"Click to upload or drag image\",\n        \"supportedFormats\": \"Supports JPG, PNG formats\",\n        \"nameRequired\": \"Please enter character name first to use reference conversion\",\n        \"convertToSheet\": \"Convert to standard character sheet\",\n        \"useReferenceGeneratePrefix\": \"Generate from reference\",\n        \"generateCountSuffix\": \"images\",\n        \"selectReferenceGenerateCount\": \"Select reference generation count\",\n        \"referenceTip\": \"Upload any character image, AI will generate a standard character sheet\",\n        \"description\": \"Character Description\",\n        \"modifyDescription\": \"Modify Description\",\n        \"descPlaceholder\": \"Enter character appearance description...\",\n        \"modifyDescriptionPlaceholder\": \"Describe how to modify the primary appearance, e.g. formal outfit, post-battle injuries, add a cloak...\",\n        \"selectMainCharacter\": \"Select Main Character\",\n        \"selectCharacterPlaceholder\": \"Please select a character...\",\n        \"appearancesCount\": \"{count} appearances\",\n        \"changeReason\": \"Appearance Change Reason\",\n        \"changeReasonPlaceholder\": \"e.g. injured after battle, changed into formal wear for a banquet...\",\n        \"defaultDescription\": \"{name}'s character profile\",\n        \"generationMode\": \"Generation Mode\",\n        \"directGenerate\": \"Direct Generate\",\n        \"extractPrompt\": \"Extract Prompt\",\n        \"extractFirst\": \"Extract Description First\",\n        \"directGenerateDesc\": \"Directly generate character sheet from reference (img2img)\",\n        \"extractPromptDesc\": \"Extract description from image first, then generate (txt2img)\",\n        \"maxReferenceImages\": \"Up to 5 reference images\",\n        \"selectedCount\": \"Selected {count}/5 images\",\n        \"extractDescription\": \"Extract Description\",\n        \"extracting\": \"Extracting...\",\n        \"extractedDescription\": \"Extracted Description (Editable)\",\n        \"reExtract\": \"Re-extract\",\n        \"editHint\": \"Edit the description, then click below to generate\",\n        \"generateFromDescription\": \"Generate from Description\",\n        \"textToImageTip\": \"Text-to-image mode: Generate from extracted description\",\n        \"pleaseExtractFirst\": \"Please extract character description first\"\n    },\n    \"location\": {\n        \"title\": \"New Location\",\n        \"name\": \"Location Name\",\n        \"namePlaceholder\": \"Enter location name\",\n        \"description\": \"Location Description\",\n        \"descPlaceholder\": \"Enter location description...\"\n    },\n    \"artStyle\": {\n        \"title\": \"Art Style\"\n    },\n    \"aiDesign\": {\n        \"title\": \"AI Design\",\n        \"placeholder\": \"Describe the character you want...\",\n        \"placeholderLocation\": \"Describe the scene atmosphere...\",\n        \"generating\": \"Designing...\",\n        \"generate\": \"Generate\",\n        \"tip\": \"Enter a simple description, AI will generate detailed settings\"\n    },\n    \"common\": {\n        \"creating\": \"Creating...\",\n        \"create\": \"Create\",\n        \"cancel\": \"Cancel\",\n        \"adding\": \"Adding...\",\n        \"add\": \"Add\",\n        \"addOnly\": \"Add character only\",\n        \"addOnlyToAssetHub\": \"Add only to asset hub\",\n        \"addOnlyLocation\": \"Add location only\",\n        \"addOnlyToAssetHubLocation\": \"Add location to asset hub only\",\n        \"addAndGeneratePrefix\": \"Add and generate\",\n        \"generateCountSuffix\": \"images\",\n        \"selectGenerateCount\": \"Select generation count\",\n        \"optional\": \"(Optional)\"\n    },\n    \"errors\": {\n        \"uploadFailed\": \"Upload failed\",\n        \"extractDescriptionFailed\": \"Failed to extract description\",\n        \"createFailed\": \"Creation failed\",\n        \"aiDesignFailed\": \"AI design failed\",\n        \"addSubAppearanceFailed\": \"Failed to add sub-appearance\",\n        \"insufficientBalance\": \"Insufficient balance\"\n    }\n}\n"
  },
  {
    "path": "messages/en/assetPicker.json",
    "content": "{\n    \"selectCharacter\": \"Select Character from Asset Hub\",\n    \"selectLocation\": \"Select Location from Asset Hub\",\n    \"selectVoice\": \"Select Voice from Asset Hub\",\n    \"searchPlaceholder\": \"Search by name or folder...\",\n    \"noAssets\": \"No assets in Asset Hub\",\n    \"createInAssetHub\": \"Please create characters/locations/voices in Asset Hub first\",\n    \"noSearchResults\": \"No matching assets found\",\n    \"appearances\": \"appearances\",\n    \"images\": \"images\",\n    \"cancel\": \"Cancel\",\n    \"confirmCopy\": \"Confirm Copy\",\n    \"copyFromGlobal\": \"Copy from Asset Hub\",\n    \"copySuccess\": \"Copy successful\",\n    \"copyFailed\": \"Copy failed\",\n    \"preview\": \"Preview\",\n    \"stop\": \"Stop\"\n}"
  },
  {
    "path": "messages/en/assets.json",
    "content": "{\n    \"stage\": {\n        \"title\": \"Assets Confirmation\",\n        \"characters\": \"Characters\",\n        \"locations\": \"Locations\",\n        \"analyze\": \"Analyze Assets\",\n        \"analyzing\": \"Analyzing...\",\n        \"generateAll\": \"Generate All\",\n        \"noCharacters\": \"No characters\",\n        \"noLocations\": \"No locations\",\n        \"confirmProfiles\": \"Character Profiles to Confirm\",\n        \"confirmHint\": \"Please confirm these profiles before generating descriptions\",\n        \"confirmAll\": \"Confirm All ({count})\",\n        \"assetsTitle\": \"Asset Analysis\",\n        \"characterAssets\": \"Character Assets\",\n        \"locationAssets\": \"Location Assets\",\n        \"counts\": \"{characterCount} Characters, {appearanceCount} Appearances\",\n        \"locationCounts\": \"{count} Locations\",\n        \"undoFailed\": \"Undo failed\",\n        \"undoFailedError\": \"Undo failed: {error}\",\n        \"undoSuccess\": \"Reverted to previous version\",\n        \"editFailed\": \"Edit failed\",\n        \"editFailedError\": \"Image edit failed: {error}\",\n        \"updateSuccess\": \"Description updated successfully\"\n    },\n    \"character\": {\n        \"add\": \"Add Character\",\n        \"edit\": \"Edit Character\",\n        \"delete\": \"Delete Character\",\n        \"deleteConfirm\": \"Delete this character?\",\n        \"deleteAppearanceConfirm\": \"Delete this appearance?\",\n        \"deleteFailed\": \"Delete failed: {error}\",\n        \"deleteWhole\": \"Delete Whole Character\",\n        \"deleteOptions\": \"Delete Options\",\n        \"name\": \"Character Name\",\n        \"description\": \"Appearance Description\",\n        \"generateImage\": \"Generate Profile\",\n        \"regenerateImage\": \"Regenerate\",\n        \"generate\": \"Generate\",\n        \"regenerating\": \"Generating...\",\n        \"profile\": \"Profile\",\n        \"voiceSettings\": \"Voice Settings\",\n        \"speaker\": \"Speaker\",\n        \"selectSpeaker\": \"Select Speaker\",\n        \"noSpeaker\": \"Not Set\",\n        \"primary\": \"Primary\",\n        \"secondary\": \"Secondary\",\n        \"generateFromPrimary\": \"Generate from Primary\",\n        \"selectPrimaryFirst\": \"Select primary first\",\n        \"editing\": \"Editing...\",\n        \"confirming\": \"Confirming...\",\n        \"assetCount\": \"{count} Appearances\",\n        \"characterCount\": \"{count} Characters\",\n        \"updateFailed\": \"Update description failed\",\n        \"addFailed\": \"Add character failed\",\n        \"copyFromGlobal\": \"Copy from Asset Hub\"\n    },\n    \"location\": {\n        \"add\": \"Add Location\",\n        \"edit\": \"Edit Location\",\n        \"delete\": \"Delete Location\",\n        \"deleteConfirm\": \"Delete this location?\",\n        \"deleteFailed\": \"Delete failed: {error}\",\n        \"name\": \"Location Name\",\n        \"summary\": \"Summary\",\n        \"summaryPlaceholder\": \"Usage/associations, e.g.: John's master bedroom\",\n        \"description\": \"Location Description\",\n        \"generateImage\": \"Generate Image\",\n        \"regenerateImage\": \"Regenerate\",\n        \"updateFailed\": \"Update description failed\",\n        \"addFailed\": \"Add location failed\"\n    },\n    \"image\": {\n        \"upload\": \"Upload Image\",\n        \"uploadReplace\": \"Upload Replacement\",\n        \"uploadFailed\": \"Upload Failed\",\n        \"uploadFailedError\": \"Upload failed: {error}\",\n        \"uploadSuccess\": \"Upload Success!\",\n        \"edit\": \"Edit Image\",\n        \"editPrompt\": \"Edit Prompt\",\n        \"undo\": \"Undo to Previous Version\",\n        \"undoConfirm\": \"Are you sure you want to undo to the previous version? Current version will be deleted.\",\n        \"regenerateGroup\": \"Regenerate Group\",\n        \"regenerateStuck\": \"Click to regenerate (if stuck)\",\n        \"selectCount\": \"Select generation count\",\n        \"generateCountPrefix\": \"Generate\",\n        \"generateCountSuffix\": \"images\",\n        \"regenCountPrefix\": \"Regenerate\",\n        \"regenCountSuffix\": \"\",\n        \"regenCountAriaLabel\": \"Select regeneration count\",\n        \"generatedProgress\": \"Generated {generated}/{total}\",\n        \"generating\": \"Generating\",\n        \"regenerating\": \"Regenerating\",\n        \"generatingPlaceholder\": \"Waiting\",\n        \"selectTip\": \"Once selected and confirmed, you can edit and modify the image\",\n        \"selectFirst\": \"Please select an image first\",\n        \"useThis\": \"Use this option\",\n        \"optionAlt\": \"{name} - Option {number}\",\n        \"optionNumber\": \"Option {number}\",\n        \"optionSelected\": \"Selected Option {number}\",\n        \"confirmOption\": \"Confirm Option {number}\",\n        \"deleteOthersHint\": \"(delete others)\",\n        \"confirmSuccess\": \"Selection confirmed\",\n        \"confirmFailed\": \"Confirm selection failed: {error}\",\n        \"selectFailed\": \"Select image failed: {error}\",\n        \"cancelSelection\": \"Cancel Selection\",\n        \"deleteThis\": \"Delete this appearance\",\n        \"undoFailed\": \"Undo failed\",\n        \"undoSuccess\": \"✓ Reverted to previous version\",\n        \"editFailed\": \"Image edit failed\",\n        \"editSuccess\": \"Image edit successful\",\n        \"regenerateFailed\": \"Regenerate failed: {error}\"\n    },\n    \"modal\": {\n        \"newCharacter\": \"New Character\",\n        \"addSubAppearance\": \"Add Sub-Appearance\",\n        \"aiDesign\": \"AI Design\",\n        \"aiDesigning\": \"Designing...\",\n        \"designInstruction\": \"Please enter design instruction\",\n        \"enterNameDesc\": \"Please enter character name and description\",\n        \"selectCharacter\": \"Please select a character\",\n        \"enterChangeReason\": \"Please enter change reason\",\n        \"enterSubDesc\": \"Please enter appearance description\",\n        \"insufficientBalance\": \"Insufficient Balance\\n\\n{error}\",\n        \"designFailed\": \"AI Design Failed: {error}\",\n        \"addFailed\": \"Add Failed: {error}\",\n        \"aiDesignPlaceholderNew\": \"e.g. A 20-year-old female mage, blonde hair, blue eyes...\",\n        \"aiDesignPlaceholderSub\": \"e.g. Changed into black combat gear...\",\n        \"aiTipNew\": \"Describe the character, AI will generate details\",\n        \"aiTipSub\": \"Describe the new state, AI will generate sub-appearance description\",\n        \"nameLabel\": \"Character Name\",\n        \"namePlaceholder\": \"Enter name...\",\n        \"descLabel\": \"Appearance Description\",\n        \"descPlaceholder\": \"Enter description...\",\n        \"selectLabel\": \"Select Character\",\n        \"selectPlaceholder\": \"-- Select Character --\",\n        \"existingAppearances\": \"Existing:\",\n        \"reasonLabel\": \"Change Reason\",\n        \"reasonPlaceholder\": \"e.g. After changing clothes, Injured...\",\n        \"reasonTip\": \"Briefly describe the difference from primary appearance\",\n        \"subDescPlaceholder\": \"Describe only the changes...\",\n        \"subDescTip\": \"Only describe changes (clothes, state), face/body inherits from primary\",\n        \"adding\": \"Adding...\",\n        \"insufficientBalanceDefault\": \"Insufficient balance, please top up to continue\",\n        \"addFailedGeneric\": \"Add Failed\",\n        \"appearancesCount\": \"Appearances\",\n        \"addCharacter\": \"Add Character\",\n        \"addLocation\": \"Add Location\",\n        \"aiDesignTip\": \"Describe the scene you want, AI will generate name and details\",\n        \"designing\": \"AI designing...\",\n        \"saveName\": \"Save Name\",\n        \"saveOnly\": \"Save Only\",\n        \"sceneDescription\": \"Scene Description\",\n        \"scenePrompt\": \"Scene Description Prompt\",\n        \"appearancePrompt\": \"Appearance Description Prompt\",\n        \"smartModify\": \"Smart Modify\",\n        \"modifyPlaceholder\": \"e.g.: Change to night, add moonlight, add curtains...\",\n        \"modifyPlaceholderCharacter\": \"e.g.: Change hair to blonde, height to 180cm, wear black suit...\",\n        \"modifying\": \"Smart modifying...\",\n        \"modifyFailed\": \"Modification failed\",\n        \"editCharacter\": \"Edit Character\",\n        \"editLocation\": \"Edit Location\",\n        \"saveAndGenerate\": \"Save and Generate\",\n        \"generatingAutoClose\": \"Generating image, will close automatically when done...\",\n        \"aiLocationTip\": \"Enter what you want to modify, AI will adjust the scene description\",\n        \"aiDesignPlaceholderLocation\": \"e.g. An ancient magical library, towering bookshelves, dim candlelight, mysterious atmosphere...\",\n        \"artStyle\": \"Art Style\",\n        \"generate\": \"Generate\",\n        \"introduction\": \"Character Introduction\",\n        \"introductionPlaceholder\": \"e.g.: The protagonist; 'I' refers to her. Others call her 'Snow' or 'Sister Snow'...\",\n        \"introductionTip\": \"Describe the character's role in the story, narrative perspective (who 'I' refers to), how others address them\",\n        \"saveIntroduction\": \"Save Introduction\"\n    },\n    \"toolbar\": {\n        \"filter\": \"Filter\",\n        \"viewAll\": \"View All\",\n        \"showGenerated\": \"Generated\",\n        \"showPending\": \"Pending\",\n        \"assetManagement\": \"Asset Management\",\n        \"assetCount\": \"{total} assets ({appearances} character appearances + {locations} locations)\",\n        \"globalAnalyze\": \"Global Analysis\",\n        \"globalAnalyzing\": \"Performing global asset analysis...\",\n        \"globalAnalyzingHint\": \"Please don't refresh. Results will appear automatically when complete\",\n        \"globalAnalyzingTip\": \"Analyzing all episodes, extracting characters and locations...\",\n        \"globalAnalyzeHint\": \"Analyze all episodes to extract characters and locations\",\n        \"globalAnalyzeSuccess\": \"Global analysis complete: {characters} new characters, {locations} new locations\",\n        \"globalAnalyzeFailed\": \"Global analysis failed\",\n        \"generateAll\": \"Generate All Images\",\n        \"generateAllNoop\": \"All assets already have images, nothing to generate\",\n        \"generating\": \"Generating ({current}/{total})\",\n        \"regenerateAll\": \"Regenerate All\",\n        \"regenerateAllConfirm\": \"Regenerate images for all assets? This will overwrite existing images.\",\n        \"noAssetsToGenerate\": \"No assets available for generation\",\n        \"regenerateAllHint\": \"Regenerate all asset images (overwrite existing)\",\n        \"downloadAll\": \"Download all images as ZIP\"\n    },\n    \"common\": {\n        \"actions\": \"Actions\",\n        \"add\": \"Add\",\n        \"cancel\": \"Cancel\",\n        \"confirm\": \"Confirm\",\n        \"copy\": \"Copy\",\n        \"delete\": \"Delete\",\n        \"download\": \"Download\",\n        \"edit\": \"Edit\",\n        \"generate\": \"Generate\",\n        \"generateFailed\": \"Generation Failed\",\n        \"loading\": \"Loading...\",\n        \"none\": \"None\",\n        \"preview\": \"Preview\",\n        \"refresh\": \"Refresh\",\n        \"regenerate\": \"Regenerate\",\n        \"save\": \"Save\",\n        \"status\": \"Status\",\n        \"submitFailed\": \"Submit Failed\",\n        \"upload\": \"Upload\",\n        \"unknownError\": \"Unknown error\"\n    },\n    \"video\": {\n        \"panelCard\": {\n            \"generating\": \"Generating...\",\n            \"editPrompt\": \"Edit Prompt\"\n        }\n    },\n    \"smartImport\": {\n        \"preview\": {\n            \"saving\": \"Saving...\"\n        }\n    },\n    \"storyboard\": {\n        \"group\": {\n            \"generating\": \"Generating...\"\n        }\n    },\n    \"errors\": {\n        \"saveFailed\": \"Save Failed, please retry\",\n        \"failed\": \"failed, please retry\",\n        \"insufficientBalance\": \"Insufficient balance\",\n        \"aiDesignFailed\": \"AI design failed\",\n        \"createFailed\": \"Creation failed\"\n    },\n    \"assetLibrary\": {\n        \"button\": \"Asset Library\",\n        \"title\": \"Asset Library\",\n        \"copySuccessCharacter\": \"Character appearance copied successfully\",\n        \"copySuccessLocation\": \"Location image copied successfully\",\n        \"copySuccessVoice\": \"Voice copied successfully\",\n        \"copyFailed\": \"Copy failed: {error}\",\n        \"downloadEmpty\": \"No image assets to download\",\n        \"downloadFailed\": \"Download failed\"\n    },\n    \"tts\": {\n        \"voiceDesignSaved\": \"AI-designed voice has been set for {name}\",\n        \"saveVoiceDesignFailed\": \"Failed to save voice design: {error}\",\n        \"title\": \"Voice\",\n        \"noVoice\": \"No voice\",\n        \"previewFailed\": \"Preview failed: {error}\",\n        \"uploadFailed\": \"Upload audio failed: {error}\",\n        \"uploadQwenHint\": \"Uploaded voices can only be synthesized with IndexTTS, not QwenTTS. QwenTTS requires an AI-designed voice.\",\n        \"uploading\": \"Uploading...\",\n        \"uploaded\": \"Uploaded\",\n        \"uploadAudio\": \"Upload Audio\",\n        \"pause\": \"Pause\",\n        \"preview\": \"Preview Voice\"\n    },\n    \"characterProfile\": {\n        \"importance\": {\n            \"S\": \"S-Level - Main Protagonist\",\n            \"A\": \"A-Level - Core Supporting\",\n            \"B\": \"B-Level - Important Supporting\",\n            \"C\": \"C-Level - Minor Character\",\n            \"D\": \"D-Level - Extra\"\n        },\n        \"costumeLevel\": {\n            \"5\": \"Royal/Luxury\",\n            \"4\": \"Noble/Elite\",\n            \"3\": \"Professional/Quality\",\n            \"2\": \"Casual/Normal\",\n            \"1\": \"Plain/Uniform\"\n        },\n        \"importanceLevel\": \"Character Importance Level\",\n        \"characterArchetype\": \"Character Archetype\",\n        \"archetypePlaceholder\": \"e.g.: Domineering CEO, Schemer\",\n        \"personalityTags\": \"Personality Tags\",\n        \"addTagPlaceholder\": \"Add tag\",\n        \"costumeLevelLabel\": \"Costume Level\",\n        \"suggestedColors\": \"Suggested Colors\",\n        \"colorPlaceholder\": \"e.g.: Navy blue, Gold\",\n        \"primaryMarker\": \"Primary Identifier\",\n        \"markerNote\": \"(Recommended for S/A level)\",\n        \"markingsPlaceholder\": \"e.g.: Tear-shaped mole, Silver earring\",\n        \"visualKeywords\": \"Visual Keywords\",\n        \"keywordsPlaceholder\": \"e.g.: Elite aura, Ascetic style\",\n        \"editDialogTitle\": \"Edit Character Profile - {name}\",\n        \"confirmAndGenerate\": \"Confirm & Generate\",\n        \"useExisting\": \"Use Existing\",\n        \"editProfile\": \"Edit Profile\",\n        \"delete\": \"Delete Character\",\n        \"summary\": {\n            \"gender\": \"Gender:\",\n            \"age\": \"Age:\",\n            \"era\": \"Era:\",\n            \"class\": \"Class:\",\n            \"occupation\": \"Occupation:\",\n            \"personality\": \"Personality:\",\n            \"costume\": \"Costume:\",\n            \"identifier\": \"Identifier:\"\n        },\n        \"parseFailed\": \"Failed to parse profile data\",\n        \"confirmSuccessGenerating\": \"✓ Profile confirmed. Visual description generation started\",\n        \"confirmFailed\": \"Confirm failed: {error}\",\n        \"noPendingCharacters\": \"No pending characters to confirm\",\n        \"batchConfirmPrompt\": \"Generate visual descriptions for {count} characters?\",\n        \"batchConfirmSuccess\": \"✓ Visual descriptions generated for {count} characters\",\n        \"batchConfirmFailed\": \"Batch confirmation failed: {error}\",\n        \"deleteConfirm\": \"Delete this character? This action cannot be undone.\",\n        \"deleteSuccess\": \"✓ Character deleted\",\n        \"deleteFailed\": \"Delete failed: {error}\"\n    },\n    \"imageEdit\": {\n        \"editCharacterImage\": \"Edit Character Image\",\n        \"editLocationImage\": \"Edit Location Image\",\n        \"characterLabel\": \"Character: {name}\",\n        \"locationLabel\": \"Location: {name}\",\n        \"editInstruction\": \"Edit Instruction\",\n        \"subtitle\": \"Enter an edit instruction and optionally upload reference images\",\n        \"characterPlaceholder\": \"Describe what you want to change, e.g.: Change hair to blonde, add glasses, change to casual clothes...\",\n        \"locationPlaceholder\": \"Describe what you want to change, e.g.: Add more trees, change to night scene...\",\n        \"storyboardPlaceholder\": \"Describe what you want to change, e.g.: Change background color, adjust character expression...\",\n        \"noAssetHint\": \"No assets, click \\\"Add Asset\\\" to select\",\n        \"referenceImages\": \"Reference Images\",\n        \"referenceImagesHint\": \"(optional, paste supported)\",\n        \"startEditing\": \"Start Editing\"\n    }\n}"
  },
  {
    "path": "messages/en/auth.json",
    "content": "{\n  \"welcomeBack\": \"Welcome Back\",\n  \"loginTo\": \"Sign in to waoowaoo\",\n  \"createAccount\": \"Create Account\",\n  \"joinPlatform\": \"Join waoowaoo\",\n  \"phoneNumber\": \"Username\",\n  \"password\": \"Password\",\n  \"confirmPassword\": \"Confirm Password\",\n  \"phoneNumberPlaceholder\": \"Enter your username\",\n  \"passwordPlaceholder\": \"Enter your password\",\n  \"passwordMinPlaceholder\": \"Enter password (at least 6 characters)\",\n  \"confirmPasswordPlaceholder\": \"Re-enter your password\",\n  \"loginButton\": \"Sign In\",\n  \"loginButtonLoading\": \"Signing in...\",\n  \"signupButton\": \"Sign Up\",\n  \"signupButtonLoading\": \"Signing up...\",\n  \"noAccount\": \"Don't have an account?\",\n  \"hasAccount\": \"Already have an account?\",\n  \"signupNow\": \"Sign Up Now\",\n  \"signinNow\": \"Sign In Now\",\n  \"backToHome\": \"← Back to Home\",\n  \"loginFailed\": \"Login failed, please check your phone number and password\",\n  \"loginError\": \"An error occurred during login\",\n  \"passwordMismatch\": \"Passwords do not match\",\n  \"passwordTooShort\": \"Password must be at least 6 characters\",\n  \"signupSuccess\": \"Registration successful! Redirecting to login page...\",\n  \"signupFailed\": \"Registration failed\",\n  \"signupError\": \"An error occurred during registration\",\n  \"rateLimited\": \"Too many requests, please try again later\",\n  \"passwordStrength\": {\n    \"weak\": \"Strength: Weak — Use a longer password with uppercase, lowercase, numbers and symbols\",\n    \"fair\": \"Strength: Fair — Consider adding more complexity\",\n    \"good\": \"Strength: Good\",\n    \"strong\": \"Strength: Strong\"\n  }\n}"
  },
  {
    "path": "messages/en/billing.json",
    "content": "{\n  \"transactionType\": \"Transaction Type\",\n  \"startDate\": \"Start Date\",\n  \"endDate\": \"End Date\",\n  \"all\": \"All\",\n  \"income\": \"Income\",\n  \"expense\": \"Expense\",\n  \"reset\": \"Reset\",\n  \"filter\": \"Filter\",\n  \"noRecords\": \"No records\",\n  \"accountRecharge\": \"Account Recharge\",\n  \"serviceConsumption\": \"Service Consumption\",\n  \"balance\": \"Balance\",\n  \"allTypes\": \"All Types\"\n}"
  },
  {
    "path": "messages/en/common.json",
    "content": "{\n    \"appName\": \"waoowaoo\",\n    \"betaVersion\": \"Beta v{version}\",\n    \"updateNotice\": {\n        \"title\": \"New version available\",\n        \"subtitle\": \"Current v{current} · Latest v{latest}\",\n        \"description\": \"A newer version has been published on GitHub Releases. View the changelog to update safely.\",\n        \"releaseName\": \"Release\",\n        \"publishedAt\": \"Published\",\n        \"updateTag\": \"Update\",\n        \"viewRelease\": \"View update\",\n        \"remindLater\": \"Remind me later\",\n        \"close\": \"Close\",\n        \"openDialog\": \"Open update dialog\",\n        \"checkUpdate\": \"Check for updates\",\n        \"upToDate\": \"Already up to date\"\n    },\n    \"loading\": \"Loading...\",\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"confirm\": \"Confirm\",\n    \"delete\": \"Delete\",\n    \"edit\": \"Edit\",\n    \"search\": \"Search\",\n    \"clear\": \"Clear\",\n    \"close\": \"Close\",\n    \"back\": \"Back\",\n    \"next\": \"Next\",\n    \"previous\": \"Previous\",\n    \"submit\": \"Submit\",\n    \"reset\": \"Reset\",\n    \"generate\": \"Generate\",\n    \"regenerate\": \"Regenerate\",\n    \"preview\": \"Preview\",\n    \"download\": \"Download\",\n    \"upload\": \"Upload\",\n    \"select\": \"Select\",\n    \"add\": \"Add\",\n    \"remove\": \"Remove\",\n    \"refresh\": \"Refresh\",\n    \"expand\": \"Expand\",\n    \"collapse\": \"Collapse\",\n    \"all\": \"All\",\n    \"none\": \"None\",\n    \"success\": \"Success\",\n    \"error\": \"Error\",\n    \"warning\": \"Warning\",\n    \"info\": \"Info\",\n    \"copy\": \"Copy\",\n    \"paste\": \"Paste\",\n    \"apply\": \"Apply\",\n    \"autoSave\": \"Auto-save\",\n    \"saved\": \"Saved\",\n    \"episode\": \"Episode\",\n    \"project\": \"Project\",\n    \"editEpisodeName\": \"Edit Episode Name\",\n    \"deleteEpisode\": \"Delete Episode\",\n    \"deleteEpisodeConfirm\": \"Confirm Delete\",\n    \"newEpisode\": \"New Episode\",\n    \"optional\": \"(Optional)\",\n    \"rename\": \"Rename\",\n    \"dragToReorder\": \"Drag to reorder\",\n    \"episodeNamePlaceholder\": \"Enter episode name...\",\n    \"cancelSelection\": \"Cancel selection\",\n    \"referenceImage\": \"Reference image\",\n    \"previewLarge\": \"Preview large\",\n    \"viewOriginal\": \"View original\",\n    \"schemeN\": \"Scheme {n}\",\n    \"insufficientBalance\": \"Insufficient Balance\",\n    \"insufficientBalanceDetail\": \"Insufficient account balance, please recharge to continue\",\n    \"operationFailed\": \"Operation failed\",\n    \"pleaseRetry\": \"Please retry\",\n    \"recommended\": \"Recommended\",\n    \"language\": {\n        \"select\": \"Select language\",\n        \"zh\": \"Chinese\",\n        \"en\": \"English\",\n        \"switchConfirmTitle\": \"Switch language?\",\n        \"switchConfirmMessage\": \"Switching to {targetLanguage} will update not only interface text, but also end-to-end prompts, script generation, and workflow outputs. Continue?\",\n        \"switchConfirmAction\": \"Switch now\"\n    },\n    \"taskStatus\": {\n        \"intent\": {\n            \"generate\": {\n                \"running\": {\n                    \"image\": \"Generating\",\n                    \"video\": \"Generating\",\n                    \"audio\": \"Generating\",\n                    \"text\": \"Generating\"\n                }\n            },\n            \"regenerate\": {\n                \"running\": {\n                    \"image\": \"Regenerating\",\n                    \"video\": \"Regenerating\",\n                    \"audio\": \"Regenerating\",\n                    \"text\": \"Regenerating\"\n                }\n            },\n            \"modify\": {\n                \"running\": {\n                    \"image\": \"Modifying\",\n                    \"video\": \"Modifying\",\n                    \"audio\": \"Modifying\",\n                    \"text\": \"Modifying\"\n                }\n            },\n            \"analyze\": {\n                \"running\": {\n                    \"image\": \"Analyzing\",\n                    \"video\": \"Analyzing\",\n                    \"audio\": \"Analyzing\",\n                    \"text\": \"Analyzing\"\n                }\n            },\n            \"build\": {\n                \"running\": {\n                    \"image\": \"Building\",\n                    \"video\": \"Building\",\n                    \"audio\": \"Building\",\n                    \"text\": \"Building\"\n                }\n            },\n            \"convert\": {\n                \"running\": {\n                    \"image\": \"Converting\",\n                    \"video\": \"Converting\",\n                    \"audio\": \"Converting\",\n                    \"text\": \"Converting\"\n                }\n            },\n            \"process\": {\n                \"running\": {\n                    \"image\": \"Processing\",\n                    \"video\": \"Processing\",\n                    \"audio\": \"Processing\",\n                    \"text\": \"Processing\"\n                }\n            }\n        },\n        \"failed\": {\n            \"image\": \"Failed\",\n            \"video\": \"Failed\",\n            \"audio\": \"Failed\",\n            \"text\": \"Failed\"\n        }\n    }\n}"
  },
  {
    "path": "messages/en/configModal.json",
    "content": "{\n    \"title\": \"Project Config\",\n    \"subtitle\": \"Defaults to the global settings. You can customize models for this project only — changes apply to this project only.\",\n    \"saved\": \"Saved\",\n    \"autoSave\": \"Auto-save\",\n    \"visualStyle\": \"Visual Style\",\n    \"modelParams\": \"Model Parameters\",\n    \"aspectRatio\": \"Aspect Ratio\",\n    \"ttsSettings\": \"TTS Settings\",\n    \"loadingModels\": \"Loading model list...\",\n    \"analysisModel\": \"Analysis Model\",\n    \"characterModel\": \"Character Model\",\n    \"locationModel\": \"Location Model\",\n    \"storyboardModel\": \"Storyboard Model\",\n    \"editModel\": \"Edit Model\",\n    \"videoModel\": \"Video Model\",\n    \"audioModel\": \"Speech Synthesis Model\",\n    \"videoResolution\": \"Video Resolution\",\n    \"ttsVoice\": \"TTS Voice\",\n    \"ttsRate\": \"Speech Rate\",\n    \"fetchModelsFailed\": \"Failed to fetch user model list\",\n    \"placeholder\": \"Please enter...\",\n    \"description\": \"Description\",\n    \"hint\": \"Hint\",\n    \"pleaseSelect\": \"Please select...\",\n    \"selectModel\": \"Select Model\",\n    \"paramConfig\": \"Parameters\",\n    \"fixed\": \"Fixed\",\n    \"noParams\": \"No configurable parameters\",\n    \"confirm\": \"Confirm\",\n    \"cancel\": \"Cancel\",\n    \"delete\": \"Delete\",\n    \"boolOn\": \"On\",\n    \"boolOff\": \"Off\"\n}\n"
  },
  {
    "path": "messages/en/errors.json",
    "content": "{\n    \"UNAUTHORIZED\": \"Please log in first\",\n    \"FORBIDDEN\": \"Access denied\",\n    \"NOT_FOUND\": \"Resource not found\",\n    \"INSUFFICIENT_BALANCE\": \"Insufficient API balance. Please top up and retry\",\n    \"RATE_LIMIT\": \"Too many requests. Please retry in {retryAfter} seconds\",\n    \"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\",\n    \"MODEL_NOT_REGISTERED\": \"Model is not registered. Add an available model in configuration first\",\n    \"MODEL_NOT_CONFIGURED\": \"No model configured. Please go to Settings and add the required model type before generating.\",\n    \"QUOTA_EXCEEDED\": \"Quota exceeded. Please try again later\",\n    \"GENERATION_FAILED\": \"Generation failed. Please retry\",\n    \"GENERATION_TIMEOUT\": \"Generation timed out. Please retry\",\n    \"SENSITIVE_CONTENT\": \"Content may contain sensitive material\",\n    \"INVALID_PARAMS\": \"Invalid parameters\",\n    \"MISSING_CONFIG\": \"Please complete model configuration first\",\n    \"INTERNAL_ERROR\": \"Server error. Please try again later\",\n    \"NETWORK_ERROR\": \"Network error. Please check your connection\",\n    \"EMPTY_RESPONSE\": \"Model returned an empty response (no meaningful content). Please retry\",\n    \"EXTERNAL_ERROR\": \"External service temporarily unavailable. Please retry later\",\n    \"TASK_NOT_READY\": \"Task is still processing\",\n    \"NO_RESULT\": \"Task has no result\",\n    \"CONFLICT\": \"Resource state conflict\"\n}"
  },
  {
    "path": "messages/en/landing.json",
    "content": "{\n  \"title\": \"waoowaoo\",\n  \"subtitle\": \"AI Film & TV Studio\",\n  \"enterWorkspace\": \"Enter Workspace\",\n  \"loading\": \"Loading...\",\n  \"getStarted\": \"Get Started\",\n  \"learnMore\": \"Learn More\",\n  \"features\": {\n    \"title\": \"Unleash Infinite Creativity\",\n    \"subtitle\": \"Full-process AI assistance, from script to final cut\",\n    \"character\": {\n      \"title\": \"Character Workshop\",\n      \"description\": \"Create unique anime characters with high consistency\"\n    },\n    \"storyboard\": {\n      \"title\": \"Smart Storyboard\",\n      \"description\": \"One-click text to storyboard, precise narrative control\"\n    },\n    \"world\": {\n      \"title\": \"World Building\",\n      \"description\": \"Immersive scene generation to build grand story backgrounds\"\n    }\n  },\n  \"footer\": {\n    \"copyright\": \"2026 waoowaoo AI. All rights reserved.\"\n  }\n}"
  },
  {
    "path": "messages/en/layout.json",
    "content": "{\n    \"title\": \"AI Anime Production Platform\",\n    \"description\": \"Create professional anime content with cutting-edge AI technology\"\n}\n"
  },
  {
    "path": "messages/en/modelSection.json",
    "content": "{\n  \"llmModels\": \"Text Model List\",\n  \"imageModels\": \"Image Model List\",\n  \"videoModels\": \"Video Model List\",\n  \"price\": \"Price\",\n  \"pricePerMillion\": \"Per million tokens\",\n  \"pricePerImage\": \"Per image\",\n  \"pricePerVideo\": \"Per video\",\n  \"name\": \"Name\",\n  \"modelId\": \"Model ID\",\n  \"modelName\": \"Model Name\",\n  \"provider\": \"Provider\",\n  \"resolution\": \"Resolution\",\n  \"add\": \"Add\",\n  \"addModel\": \"Add Model\",\n  \"addNewModel\": \"Add New Model\",\n  \"selectPreset\": \"Select Preset Model\",\n  \"customModel\": \"Custom Model\",\n  \"confirmAdd\": \"Confirm\",\n  \"cancel\": \"Cancel\",\n  \"done\": \"Done\",\n  \"fillComplete\": \"Please fill in all fields\",\n  \"noModels\": \"No models yet, click the button above to add\",\n  \"noApiKey\": \"Configure API Key\",\n  \"batchMode\": \"Batch\",\n  \"batchModeTooltip\": \"Offline inference, 50% cheaper, completes within 24 hours\"\n}\n"
  },
  {
    "path": "messages/en/nav.json",
    "content": "{\n  \"workspace\": \"Workspace\",\n  \"assetHub\": \"Asset Hub\",\n  \"profile\": \"Settings\",\n  \"downloadLogs\": \"Download Logs\",\n  \"signin\": \"Sign In\",\n  \"signup\": \"Sign Up\",\n  \"logout\": \"Logout\"\n}\n"
  },
  {
    "path": "messages/en/novel-promotion.json",
    "content": "{\n  \"stages\": {\n    \"story\": \"Story\",\n    \"script\": \"Script\",\n    \"storyboard\": \"Storyboard\",\n    \"video\": \"Video\",\n    \"editor\": \"AI Editor\",\n    \"editorComingSoon\": \"Coming soon, follow us for updates\"\n  },\n  \"buttons\": {\n    \"assetLibrary\": \"Asset Library\",\n    \"settings\": \"Project Config\",\n    \"refreshData\": \"Refresh Data\",\n    \"enterVideoGeneration\": \"Enter Video Generation →\"\n  },\n  \"smartImport\": {\n    \"title\": \"Start Your Creative Journey\",\n    \"subtitle\": \"First, choose your creation method\",\n    \"manualCreate\": {\n      \"title\": \"Create from Episode 1\",\n      \"description\": \"Start from episode 1, suitable for episodic creation or single short videos\",\n      \"button\": \"Start Creating\"\n    },\n    \"smartImport\": {\n      \"title\": \"Smart Import Full Book\",\n      \"description\": \"Upload a complete novel or script, AI engine automatically recognizes chapter structure and splits into episodes.\",\n      \"button\": \"Import Now\",\n      \"recommended\": \"Recommended\"\n    },\n    \"upload\": {\n      \"title\": \"Upload Raw Material\",\n      \"subtitle\": \"AI engine is ready, automatic episode splitting and formatting\",\n      \"maxWords\": \"(Max 30,000 words)\",\n      \"textInput\": \"Enter Text Content\",\n      \"documentUpload\": \"Upload Full Document\",\n      \"placeholder\": \"Paste your novel chapters or script content here...\",\n      \"filePlaceholder\": \"File uploaded mode\",\n      \"clickUpload\": \"Click to upload document\",\n      \"clearTextFirst\": \"Please clear left text first\",\n      \"supportedFormats\": \"Supports Word, TXT formats\",\n      \"preview\": \"Preview\",\n      \"expandPreview\": \"Expand More\",\n      \"collapsePreview\": \"Collapse\",\n      \"deleteFile\": \"Delete File\",\n      \"startAnalysis\": \"Start Smart Analysis\",\n      \"back\": \"Back\",\n      \"words\": \"words\"\n    },\n    \"analyzing\": {\n      \"title\": \"AI is Analyzing Your Story\",\n      \"description\": \"Recognizing chapter structure, smart splitting in progress...\",\n      \"autoSave\": \"Will auto-save after analysis complete\"\n    },\n    \"preview\": {\n      \"title\": \"Smart Splitting Complete\",\n      \"episodeCount\": \"Automatically split into {count} episodes\",\n      \"totalWords\": \"Total {count} words\",\n      \"autoSaved\": \"✓ Auto-saved\",\n      \"reanalyze\": \"Re-analyze\",\n      \"confirm\": \"Confirm Complete\",\n      \"saving\": \"Saving...\",\n      \"episodeList\": \"Episode List\",\n      \"addEpisode\": \"Add Episode\",\n      \"averageWords\": \"Average per episode\",\n      \"episodeContent\": \"Episode Content\",\n      \"episodePlaceholder\": \"Enter episode title...\",\n      \"summaryPlaceholder\": \"Enter plot summary...\",\n      \"newEpisode\": \"New Episode\",\n      \"deleteEpisode\": \"Delete Episode\",\n      \"deleteConfirm\": {\n        \"title\": \"Confirm Delete\",\n        \"message\": \"Are you sure you want to delete \\\"{title}\\\"?\",\n        \"cancel\": \"Cancel\",\n        \"confirm\": \"Confirm Delete\"\n      },\n      \"tip\": {\n        \"title\": \"Tip\",\n        \"content\": \"You can directly edit titles, summaries, and content. After clicking [Confirm Complete], episodes will be officially imported into the project\"\n      }\n    },\n    \"errors\": {\n      \"fileTooLarge\": \"File too large, please upload a file smaller than 10MB\",\n      \"docNotSupported\": \".doc format not supported, please convert to .docx in Word\",\n      \"fileEmpty\": \"File content is empty\",\n      \"fileReadError\": \"File read failed, please try again\",\n      \"uploadFirst\": \"Please upload or paste content first\",\n      \"analyzeFailed\": \"Analysis failed\",\n      \"saveFailed\": \"Save failed\"\n    },\n    \"cancelConfirm\": \"Are you sure you want to cancel? Analyzed episodes will be cleared.\"\n  },\n  \"storyInput\": {\n    \"currentEditing\": \"Currently editing: {name}\",\n    \"editingTip\": \"The following workflow is for this episode only. Switch episodes in the top left if needed\",\n    \"wordCount\": \"Word count:\",\n    \"assetLibraryTip\": {\n      \"title\": \"Need custom characters and locations?\",\n      \"description\": \"Click the 「Asset Library」 button in the top right to upload asset setting documents or manually add characters/locations. AI will prioritize using settings from the asset library for analysis.\"\n    },\n    \"videoRatio\": \"Video Ratio\",\n    \"videoRatioHint\": \"Pick a ratio that matches your target platform and content format\",\n    \"ratioUsage\": {\n      \"1_1\": \"1:1: Square frame, good for avatars, covers and generic social posts\",\n      \"9_16\": \"9:16: Vertical video, ideal for TikTok, Reels, Shorts and other short‑video feeds\",\n      \"16_9\": \"16:9: Horizontal video, ideal for YouTube, Bilibili and desktop playback\",\n      \"4_3\": \"4:3: Classic TV ratio, useful for legacy footage or safe cropping\",\n      \"3_4\": \"3:4: Slightly vertical, suitable for mixed text + video layouts\",\n      \"2_3\": \"2:3: More vertical, good for posters and character key art\",\n      \"3_2\": \"3:2: Slightly horizontal, good for landscapes and story scenes\",\n      \"4_5\": \"4:5: Vertical poster ratio, common in social feed images\",\n      \"5_4\": \"5:4: Horizontal poster ratio, suitable for PC web banners\",\n      \"21_9\": \"21:9: Ultra‑wide cinematic frame, ideal for movie‑like shots and panoramas\"\n    },\n    \"ratioUsageTag\": {\n      \"1_1\": \"Square · Avatars/Covers\",\n      \"9_16\": \"Vertical · Short video\",\n      \"16_9\": \"Horizontal · Long video\",\n      \"4_3\": \"Horizontal · Classic TV\",\n      \"3_4\": \"Vertical · Text + video\",\n      \"2_3\": \"Vertical · Posters/Key art\",\n      \"3_2\": \"Horizontal · Landscape/Story\",\n      \"4_5\": \"Vertical · Feed image\",\n      \"5_4\": \"Horizontal · Banner\",\n      \"21_9\": \"Ultra‑wide · Cinema feel\"\n    },\n    \"visualStyle\": \"Visual Style\",\n    \"visualStyleHint\": \"Pick a style that matches your audience — e.g. Realistic for live‑action, Anime for 2D content\",\n    \"currentConfigSummary\": \"Current config: {ratio} · {style}. All subsequent generations will use this combo.\",\n    \"assetLibraryRatioNote\": \"Asset library ratios are not affected\",\n    \"moreConfig\": \"For more configuration options, click the 「 Settings」 button in the top right\",\n    \"narration\": {\n      \"title\": \"Enable Narration Voiceover\",\n      \"description\": \"Generate TTS voice narration to add commentary to your video\"\n    },\n    \"creating\": \"AI Creating...\",\n    \"ready\": \"✓ Configuration complete, ready for next step\",\n    \"pleaseInput\": \"Please enter script content first\"\n  },\n  \"execution\": {\n    \"selectEpisode\": \"Please select an episode first\",\n    \"fillContentFirst\": \"Please enter content first\",\n    \"requestAborted\": \"Request aborted (possibly due to page refresh)\",\n    \"analysisFailed\": \"Asset analysis failed\",\n    \"prepareFailed\": \"Preparation failed\",\n    \"generationFailed\": \"Generation failed\",\n    \"batchVideoFailed\": \"Batch video generation failed\",\n    \"updateFailed\": \"Update failed\",\n    \"saveFailed\": \"Save failed\",\n    \"storyToScriptRunning\": \"Story→Script V2 running\",\n    \"scriptToStoryboardRunning\": \"Script→Storyboard V2 running\",\n    \"storyToScriptFailed\": \"Story to script failed\",\n    \"scriptToStoryboardFailed\": \"Script to storyboard failed\",\n    \"taskStreamTimeout\": \"Task timed out. The task may still be running in the background — please check its status or retry\"\n  },\n  \"rebuildConfirm\": {\n    \"storyToScript\": {\n      \"title\": \"Script Flow Will Be Rebuilt\",\n      \"message\": \"Downstream storyboard data is detected for this episode ({storyboardCount} storyboards, {panelCount} panels). Continuing will clear and rebuild this data. Continue?\"\n    },\n    \"scriptToStoryboard\": {\n      \"title\": \"Storyboard Data Will Be Rebuilt\",\n      \"message\": \"Existing storyboard data is detected for this episode ({storyboardCount} storyboards, {panelCount} panels). Continuing will clear current storyboards and regenerate them. Continue?\"\n    },\n    \"confirm\": \"Continue and Clear\",\n    \"cancel\": \"Cancel\"\n  }\n}"
  },
  {
    "path": "messages/en/profile.json",
    "content": "{\n  \"user\": \"User\",\n  \"personalAccount\": \"Personal Account\",\n  \"availableBalance\": \"Available Balance\",\n  \"openSourceNoBilling\": \"Open-source edition, no billing required\",\n  \"frozen\": \"Frozen\",\n  \"totalSpent\": \"Total Spent\",\n  \"apiConfig\": \"API Configuration\",\n  \"rechargeRecords\": \"Recharge Records\",\n  \"billingRecords\": \"Billing Records\",\n  \"logout\": \"Logout\",\n  \"downloadLogs\": \"Download Logs\",\n  \"accountTransactions\": \"Account Transactions\",\n  \"projectDetails\": \"Project Details\",\n  \"summary\": \"Summary\",\n  \"transactions\": \"Transactions\",\n  \"noTransactions\": \"No transaction records\",\n  \"noProjectCosts\": \"No project cost records\",\n  \"noDetails\": \"This project has no cost details\",\n  \"noRecords\": \"No records\",\n  \"byType\": \"By Type\",\n  \"byAction\": \"By Action\",\n  \"times\": \"times\",\n  \"total\": \"Total\",\n  \"filter\": \"Filter\",\n  \"allTypes\": \"All Types\",\n  \"recharge\": \"Account Recharge\",\n  \"consume\": \"Service Consumption\",\n  \"balanceAfter\": \"Balance {amount}\",\n  \"recordCount\": \"{count} records\",\n  \"totalCost\": \"Total {amount}\",\n  \"previousPage\": \"Previous\",\n  \"nextPage\": \"Next\",\n  \"pagination\": \"{total} items, Page {page} / {totalPages}\",\n  \"episodeLabel\": \"Episode {number}\",\n  \"billingDetail\": {\n    \"imageWithRes\": \"{count} images · {resolution}\",\n    \"image\": \"{count} images\",\n    \"videoWithRes\": \"{count} videos · {resolution}\",\n    \"video\": \"{count} videos\",\n    \"tokens\": \"{count} tokens\",\n    \"seconds\": \"{count}s\",\n    \"calls\": \"{count} calls\"\n  },\n  \"apiTypes\": {\n    \"image\": \"Image Generation\",\n    \"video\": \"Video Generation\",\n    \"text\": \"Text Analysis\",\n    \"tts\": \"Text-to-Speech\",\n    \"voice\": \"Voice Acting\",\n    \"voice_design\": \"Voice Design\",\n    \"lip_sync\": \"Lip Sync\"\n  },\n  \"actionTypes\": {\n    \"image_panel\": \"Storyboard Image\",\n    \"image_character\": \"Character Image\",\n    \"image_location\": \"Location Image\",\n    \"video_panel\": \"Video Generation\",\n    \"lip_sync\": \"Lip Sync\",\n    \"voice_line\": \"Voice Synthesis\",\n    \"voice_design\": \"Voice Design\",\n    \"asset_hub_voice_design\": \"Asset Hub Voice Design\",\n    \"regenerate_storyboard_text\": \"Regenerate Storyboard Text\",\n    \"insert_panel\": \"Insert Panel\",\n    \"panel_variant\": \"Shot Variant\",\n    \"modify_asset_image\": \"Modify Image\",\n    \"regenerate_group\": \"Batch Regenerate\",\n    \"asset_hub_image\": \"Asset Hub Image\",\n    \"asset_hub_modify\": \"Asset Hub Modify Image\",\n    \"analyze_novel\": \"Novel Analysis\",\n    \"story_to_script_run\": \"Story to Script\",\n    \"script_to_storyboard_run\": \"Script to Storyboard\",\n    \"clips_build\": \"Clips Build\",\n    \"screenplay_convert\": \"Screenplay Convert\",\n    \"voice_analyze\": \"Voice Analysis\",\n    \"analyze_global\": \"Global Analysis\",\n    \"ai_modify_appearance\": \"AI Modify Appearance\",\n    \"ai_modify_location\": \"AI Modify Location\",\n    \"ai_modify_shot_prompt\": \"AI Modify Shot Prompt\",\n    \"analyze_shot_variants\": \"Analyze Shot Variants\",\n    \"ai_create_character\": \"AI Create Character\",\n    \"ai_create_location\": \"AI Create Location\",\n    \"reference_to_character\": \"Reference to Character\",\n    \"character_profile_confirm\": \"Confirm Character Profile\",\n    \"character_profile_batch_confirm\": \"Batch Confirm Character Profiles\",\n    \"episode_split_llm\": \"Episode Split\",\n    \"asset_hub_ai_design_character\": \"Asset Hub AI Design Character\",\n    \"asset_hub_ai_design_location\": \"Asset Hub AI Design Location\",\n    \"asset_hub_ai_modify_character\": \"Asset Hub AI Modify Character\",\n    \"asset_hub_ai_modify_location\": \"Asset Hub AI Modify Location\",\n    \"asset_hub_reference_to_character\": \"Asset Hub Reference to Character\",\n    \"storyboard\": \"Storyboard\",\n    \"storyboard_candidate\": \"Storyboard Candidate\",\n    \"character\": \"Character Image\",\n    \"location\": \"Location Image\",\n    \"video\": \"Video\",\n    \"analyze\": \"Analysis\",\n    \"analyze_character\": \"Character Analysis\",\n    \"analyze_location\": \"Location Analysis\",\n    \"clips\": \"Clip Splitting\",\n    \"storyboard_text_plan\": \"Storyboard Planning\",\n    \"storyboard_text_detail\": \"Storyboard Detail\",\n    \"tts\": \"TTS\",\n    \"regenerate\": \"Regenerate\",\n    \"voice-generate\": \"Voice Generation\",\n    \"voice-design\": \"Voice Design\",\n    \"lip-sync\": \"Lip Sync\"\n  }\n}"
  },
  {
    "path": "messages/en/progress.json",
    "content": "{\n  \"analyzing\": \"Analyzing story structure...\",\n  \"splittingClips\": \"Splitting into clips...\",\n  \"convertingScreenplay\": \"Converting to screenplay...\",\n  \"submittingStoryboard\": \"Submitting storyboard...\",\n  \"step\": \"Step {current} of {total}\",\n  \"status\": {\n    \"completed\": \"Completed\",\n    \"failed\": \"Failed\",\n    \"processing\": \"Processing\",\n    \"queued\": \"Queued\",\n    \"pending\": \"Pending\"\n  },\n  \"stageCard\": {\n    \"stage\": \"Stage\",\n    \"realtimeStream\": \"Realtime Stream\",\n    \"currentStage\": \"Current Stage\",\n    \"outputTitle\": \"Live AI Output · {stage}\",\n    \"waitingModelOutput\": \"Waiting for model output...\",\n    \"reasoningNotProvided\": \"No reasoning was returned for this step\"\n  },\n  \"runtime\": {\n    \"waitingExecution\": \"Waiting to start\",\n    \"taskCreated\": \"Task created\",\n    \"taskStarted\": \"Task started\",\n    \"taskCompleted\": \"Task completed\",\n    \"taskFailed\": \"Task failed\",\n    \"taskProcessing\": \"Task processing...\",\n    \"llm\": {\n      \"processing\": \"Model is processing...\",\n      \"output\": \"Model is generating output...\",\n      \"reasoning\": \"Model is reasoning...\",\n      \"completed\": \"Model output completed\",\n      \"failed\": \"Model output failed\"\n    },\n    \"stage\": {\n      \"llmSubmit\": \"Submitting model request\",\n      \"llmStreaming\": \"Model streaming output\",\n      \"llmFallbackNonStream\": \"Model fallback to non-stream mode\",\n      \"llmCompleted\": \"Model output completed\",\n      \"llmFailed\": \"Model output failed\"\n    }\n  },\n  \"taskType\": {\n    \"generic\": \"Task\",\n    \"imagePanel\": \"Storyboard image\",\n    \"imageCharacter\": \"Character image\",\n    \"imageLocation\": \"Location image\",\n    \"videoPanel\": \"Video generation\",\n    \"lipSync\": \"Lip sync\",\n    \"voiceLine\": \"Voice generation\",\n    \"voiceDesign\": \"Voice design\",\n    \"assetHubVoiceDesign\": \"Asset hub voice design\",\n    \"regenerateStoryboardText\": \"Regenerate storyboard text\",\n    \"insertPanel\": \"Insert storyboard panel\",\n    \"panelVariant\": \"Storyboard variant\",\n    \"modifyAssetImage\": \"Image edit\",\n    \"regenerateGroup\": \"Batch regenerate\",\n    \"assetHubImage\": \"Asset hub image\",\n    \"assetHubModify\": \"Asset hub edit\",\n    \"analyzeNovel\": \"Content analysis\",\n    \"storyToScriptRun\": \"Story to script\",\n    \"scriptToStoryboardRun\": \"Script to storyboard\",\n    \"clipsBuild\": \"Clip generation\",\n    \"screenplayConvert\": \"Screenplay conversion\",\n    \"voiceAnalyze\": \"Voice line analysis\",\n    \"analyzeGlobal\": \"Global analysis\",\n    \"aiModifyAppearance\": \"Character description modify\",\n    \"aiModifyLocation\": \"Location description modify\",\n    \"aiModifyShotPrompt\": \"Shot prompt modify\",\n    \"analyzeShotVariants\": \"Shot variant analysis\",\n    \"aiCreateCharacter\": \"Project character design\",\n    \"aiCreateLocation\": \"Project location design\",\n    \"referenceToCharacter\": \"Reference to character\",\n    \"characterProfileConfirm\": \"Character profile confirm\",\n    \"characterProfileBatchConfirm\": \"Character profile batch confirm\",\n    \"episodeSplitLlm\": \"Smart episode split\",\n    \"assetHubAiDesignCharacter\": \"Asset hub character design\",\n    \"assetHubAiDesignLocation\": \"Asset hub location design\",\n    \"assetHubAiModifyCharacter\": \"Asset hub character modify\",\n    \"assetHubAiModifyLocation\": \"Asset hub location modify\",\n    \"assetHubReferenceToCharacter\": \"Asset hub reference to character\"\n  },\n  \"stage\": {\n    \"received\": \"Task received\",\n    \"generateCharacterImage\": \"Generate character image\",\n    \"generateLocationImage\": \"Generate location image\",\n    \"generatePanelCandidate\": \"Generate panel candidate\",\n    \"generatePanelVideo\": \"Generate panel video\",\n    \"generateVoiceSubmit\": \"Submit voice task\",\n    \"generateVoicePersist\": \"Persist voice result\",\n    \"voiceDesignSubmit\": \"Submit voice design task\",\n    \"voiceDesignDone\": \"Voice design completed\",\n    \"submitLipSync\": \"Submit lip sync task\",\n    \"persistLipSync\": \"Persist lip sync result\",\n    \"storyboardClip\": \"Generate storyboard clip\",\n    \"regenerateStoryboardPrepare\": \"Prepare storyboard regeneration\",\n    \"regenerateStoryboardPersist\": \"Persist storyboard regeneration\",\n    \"storyToScriptPrepare\": \"Prepare story-to-script parameters\",\n    \"storyToScriptStep\": \"Execute story-to-script step\",\n    \"storyToScriptPersist\": \"Persist story-to-script output\",\n    \"storyToScriptPersistDone\": \"Story-to-script output persisted\",\n    \"scriptToStoryboardPrepare\": \"Prepare script-to-storyboard parameters\",\n    \"scriptToStoryboardStep\": \"Execute script-to-storyboard step\",\n    \"scriptToStoryboardPersist\": \"Persist script-to-storyboard output\",\n    \"scriptToStoryboardPersistDone\": \"Storyboard and voice output persisted\",\n    \"insertPanelGenerateText\": \"Generate inserted panel text\",\n    \"insertPanelPersist\": \"Persist inserted panel\",\n    \"pollingExternal\": \"Waiting for external service\",\n    \"enqueueFailed\": \"Task enqueue failed\",\n    \"llmProxySubmit\": \"Submit LLM task\",\n    \"llmProxyExecute\": \"Execute LLM task\",\n    \"llmProxyPersist\": \"Persist LLM result\"\n  },\n  \"runConsole\": {\n    \"storyToScript\": \"Story to Script\",\n    \"scriptToStoryboard\": \"Script to Storyboard\",\n    \"storyToScriptRunning\": \"Story→Script running\",\n    \"scriptToStoryboardRunning\": \"Script→Storyboard running\",\n    \"storyToScriptSubtitle\": \"Story To Script V2\",\n    \"scriptToStoryboardSubtitle\": \"Script To Storyboard V2\",\n    \"stop\": \"Stop\",\n    \"minimize\": \"Minimize\"\n  },\n  \"streamStep\": {\n    \"analyzeCharacters\": \"Analyze characters\",\n    \"analyzeLocations\": \"Analyze locations\",\n    \"splitClips\": \"Split clips\",\n    \"screenplayConversion\": \"Convert screenplay\",\n    \"storyboardPlan\": \"Plan storyboard\",\n    \"cinematographyRules\": \"Generate cinematography rules\",\n    \"actingDirection\": \"Generate acting direction\",\n    \"storyboardDetailRefine\": \"Refine storyboard details\",\n    \"voiceAnalyze\": \"Analyze voice lines\"\n  }\n}\n"
  },
  {
    "path": "messages/en/providerSection.json",
    "content": "{\n  \"addProvider\": \"+ Add Provider\",\n  \"name\": \"Name\",\n  \"add\": \"Add\",\n  \"save\": \"Save\",\n  \"fillRequired\": \"Please fill in required fields\"\n}"
  },
  {
    "path": "messages/en/scriptView.json",
    "content": "{\n    \"title\": \"Script View\",\n    \"scriptBreakdown\": \"Script Breakdown\",\n    \"splitCount\": \"{count} clips split\",\n    \"noClips\": \"No clips yet, please generate from story view\",\n    \"segment\": {\n        \"title\": \"Clip {index}\",\n        \"selected\": \"(Selected)\"\n    },\n    \"inSceneAssets\": \"In-Scene Assets\",\n    \"currentSelected\": \"Selected: Clip {number}\",\n    \"assetView\": {\n        \"allClips\": \"All Clips\",\n        \"viewingClip\": \"Viewing Clip {number}\"\n    },\n    \"asset\": {\n        \"generateCharacter\": \"Click to generate character →\",\n        \"generateLocation\": \"Click to generate location →\",\n        \"removeCharacterConfirm\": \"Are you sure you want to remove this character from current clip?\",\n        \"removeLocationConfirm\": \"Are you sure you want to remove this location from current clip?\",\n        \"removeFromClip\": \"Remove from current clip\",\n        \"noAudio\": \"No audio\",\n        \"playing\": \"Playing\",\n        \"listen\": \"Listen\",\n        \"activeCharacters\": \"Active Characters\",\n        \"activeLocations\": \"Active Locations\",\n        \"selectCharacter\": \"Select character/appearance to add\",\n        \"selectLocation\": \"Select location to add\",\n        \"loadingAssets\": \"Loading assets...\",\n        \"appearanceCount\": \"{count} appearances\",\n        \"added\": \"Added\",\n        \"primary\": \"Primary\",\n        \"subAppearance\": \"Sub appearance\",\n        \"defaultAppearance\": \"Default appearance\",\n        \"clickToRemove\": \"Click to remove {name}\",\n        \"clickToAdd\": \"Click to add {name}\"\n    },\n    \"screenplay\": {\n        \"scene\": \"Scene {number}\",\n        \"location\": \"Location:\",\n        \"locationTime\": \"Time:\",\n        \"day\": \"Day\",\n        \"night\": \"Night\",\n        \"dawn\": \"Dawn\",\n        \"dusk\": \"Dusk\",\n        \"dialogue\": \"Dialogue\",\n        \"action\": \"Action\",\n        \"narration\": \"Narration\",\n        \"content\": \"Original Content\",\n        \"noContent\": \"No content yet\",\n        \"clickToEdit\": \"Click to edit\",\n        \"interior\": \"INT\",\n        \"exterior\": \"EXT\",\n        \"characters\": \"Characters\",\n        \"noCharacter\": \"No character info\",\n        \"noLocation\": \"No active locations\",\n        \"noCharacterInClip\": \"No active characters\"\n    },\n    \"confirm\": {\n        \"removeCharacter\": \"Are you sure you want to remove this character from current clip?\",\n        \"removeLocation\": \"Are you sure you want to remove this location from current clip?\"\n    },\n    \"generate\": {\n        \"missingAssets\": \"{count} assets missing images\",\n        \"missingAssetsTip\": \"Please generate images for all characters and locations in\",\n        \"missingAssetsTipLink\": \"first\",\n        \"generating\": \"Generating...\",\n        \"startGenerate\": \"Confirm and Start Drawing →\"\n    }\n}"
  },
  {
    "path": "messages/en/smartImport.json",
    "content": "{\n  \"title\": \"Start Your Creative Journey\",\n  \"subtitle\": \"First, choose your creation method\",\n  \"manualCreate\": {\n    \"title\": \"Create from Episode 1\",\n    \"description\": \"Start from episode 1, suitable for episodic creation or single short videos\",\n    \"button\": \"Start Creating\"\n  },\n  \"manualDesc\": \"Start from the first episode, suitable for serialized or single short video production\",\n  \"startCreate\": \"Start Creating\",\n  \"smartImport\": {\n    \"title\": \"Smart Text Split\",\n    \"description\": \"Upload a complete novel or script, AI engine automatically recognizes chapter structure and splits into episodes.\",\n    \"button\": \"Import Now\",\n    \"recommended\": \"Recommended\"\n  },\n  \"markerDetection\": {\n    \"enable\": \"Use Markers (Episode X / 第X集)\",\n    \"tooltip\": \"Auto-detect [Episode X], [Chapter X], [第X集/章] markers, free & fast\"\n  },\n  \"smartImportDesc\": \"Upload your novel or script, AI engine automatically identifies chapter structure for one-click smart episode splitting.\",\n  \"recommended\": \"Recommended\",\n  \"importNow\": \"Import Now\",\n  \"uploadTitle\": \"Upload Source Material\",\n  \"uploadSubtitle\": \"AI engine ready, one-click auto-split and format\",\n  \"maxWords\": \"Max 30,000 words\",\n  \"textInput\": \"Enter Text Content\",\n  \"textPlaceholder\": \"Paste your novel chapter or script content here...\",\n  \"uploadDoc\": \"Upload Complete Document\",\n  \"clickUpload\": \"Click to Upload\",\n  \"clearText\": \"Please clear left text first\",\n  \"supportFormat\": \"Supports Word, TXT formats\",\n  \"fileMax\": \"Max 30,000 words\",\n  \"words\": \"words\",\n  \"startAnalyzing\": \"Start Analysis\",\n  \"analyzing\": {\n    \"title\": \"AI is Analyzing Your Story\",\n    \"description\": \"Recognizing chapter structure, smart splitting in progress...\",\n    \"autoSave\": \"Will auto-save after analysis complete\"\n  },\n  \"analyzingDesc\": \"Identifying chapter structure, smart splitting...\",\n  \"autoSave\": \"Will auto-save after analysis\",\n  \"splitComplete\": \"Smart Split Complete\",\n  \"splitResult\": \"Auto-split into {count} episodes, total {words} words\",\n  \"saved\": \"Auto-saved\",\n  \"reAnalyze\": \"Re-analyze\",\n  \"confirmComplete\": \"Confirm Complete\",\n  \"saving\": \"Saving...\",\n  \"episodeList\": \"Episode List\",\n  \"episodes\": \"episodes\",\n  \"episode\": \"Episode {num}\",\n  \"addEpisode\": \"Add Episode\",\n  \"newEpisode\": \"New Episode\",\n  \"avgWords\": \"Average per episode\",\n  \"episodeContent\": \"Episode Content\",\n  \"plotSummary\": \"Plot Summary\",\n  \"enterTitle\": \"Enter episode title...\",\n  \"enterSummary\": \"Enter plot summary...\",\n  \"confirmDelete\": \"Confirm Delete\",\n  \"deleteConfirmMsg\": \"Are you sure you want to delete \\\"{title}\\\"?\",\n  \"preview\": {\n    \"title\": \"Smart Splitting Complete\",\n    \"episodeCount\": \"Automatically split into {count} episodes\",\n    \"totalWords\": \"Total {count} words\",\n    \"autoSaved\": \"✓ Auto-saved\",\n    \"reanalyze\": \"Re-analyze\",\n    \"confirm\": \"Confirm Complete\",\n    \"saving\": \"Saving...\",\n    \"episodeList\": \"Episode List\",\n    \"addEpisode\": \"Add Episode\",\n    \"averageWords\": \"Average per episode\",\n    \"episodeContent\": \"Episode Content\",\n    \"episodePlaceholder\": \"Enter episode title...\",\n    \"summaryPlaceholder\": \"Enter plot summary...\",\n    \"newEpisode\": \"New Episode\",\n    \"deleteEpisode\": \"Delete Episode\",\n    \"deleteConfirm\": {\n      \"title\": \"Confirm Delete\",\n      \"message\": \"Are you sure you want to delete \\\"{title}\\\"?\",\n      \"cancel\": \"Cancel\",\n      \"confirm\": \"Confirm Delete\"\n    },\n    \"tip\": {\n      \"title\": \"Tip\",\n      \"content\": \"You can directly edit titles, summaries, and content. After clicking [Confirm Complete], episodes will be officially imported into the project\"\n    }\n  },\n  \"collapsePreview\": \"Collapse Preview\",\n  \"expandMore\": \"Expand More\",\n  \"deleteFile\": \"Delete File\",\n  \"fileTooLarge\": \"File size cannot exceed 10MB\",\n  \"docNotSupported\": \".doc format not supported. Please save as .docx or .txt and try again\",\n  \"fileEmpty\": \"File content is empty\",\n  \"fileReadError\": \"File read failed, please ensure correct format\",\n  \"uploadFirst\": \"Please upload a file or paste text first\",\n  \"analyzeFailed\": \"Analysis failed\",\n  \"saveFailed\": \"Save failed\",\n  \"cancelConfirm\": \"Are you sure you want to cancel? Analyzed episodes will be cleared.\",\n  \"deleteEpisode\": \"Delete Episode\",\n  \"upload\": {\n    \"title\": \"Upload Raw Material\",\n    \"subtitle\": \"AI engine is ready, automatic episode splitting and formatting\",\n    \"maxWords\": \"(Max 30,000 words)\",\n    \"textInput\": \"Enter Text Content\",\n    \"documentUpload\": \"Upload Full Document\",\n    \"placeholder\": \"Paste your novel chapters or script content here...\",\n    \"filePlaceholder\": \"File uploaded mode\",\n    \"clickUpload\": \"Click to upload document\",\n    \"clearTextFirst\": \"Please clear left text first\",\n    \"supportedFormats\": \"Supports Word, TXT formats\",\n    \"preview\": \"Preview\",\n    \"expandPreview\": \"Expand More\",\n    \"collapsePreview\": \"Collapse\",\n    \"deleteFile\": \"Delete File\",\n    \"startAnalysis\": \"Start Analysis\",\n    \"back\": \"Back\",\n    \"words\": \"words\"\n  },\n  \"errors\": {\n    \"fileTooLarge\": \"File too large, please upload a file smaller than 10MB\",\n    \"docNotSupported\": \".doc format not supported, please convert to .docx in Word\",\n    \"fileEmpty\": \"File content is empty\",\n    \"fileReadError\": \"File read failed, please try again\",\n    \"uploadFirst\": \"Please upload or paste content first\",\n    \"analyzeFailed\": \"Analysis failed\",\n    \"saveFailed\": \"Save failed\",\n    \"analysisModelNotConfigured\": \"Please configure an analysis model in settings first\"\n  },\n  \"common\": {\n    \"edit\": \"Edit\",\n    \"delete\": \"Delete\",\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\"\n  },\n  \"markerDetected\": {\n    \"title\": \"Episode Markers Detected\",\n    \"description\": \"Detected {count} \\\"{type}\\\" format episode markers\",\n    \"preview\": \"Preview Split Result\",\n    \"useMarker\": \"Use Marker Split\",\n    \"useMarkerDesc\": \"Fast & Free\",\n    \"useAI\": \"Use AI Smart Split\",\n    \"useAIDesc\": \"Intelligent analysis, uses credits\",\n    \"cancel\": \"Cancel\",\n    \"totalCount\": \"{count} episodes total\",\n    \"markerTypes\": {\n      \"episode\": \"Episode X (Chinese)\",\n      \"chapter\": \"Chapter X (Chinese)\",\n      \"act\": \"Act X (Chinese)\",\n      \"scene\": \"X-Y [Scene]\",\n      \"numbered\": \"Numbered\",\n      \"numberedEscaped\": \"Numbered (Escaped)\",\n      \"numberedDirect\": \"Number + Chinese\",\n      \"episodeEn\": \"Episode X\",\n      \"chapterEn\": \"Chapter X\",\n      \"boldNumber\": \"**Number**\",\n      \"pureNumber\": \"Pure Number\"\n    }\n  },\n  \"globalAnalysis\": {\n    \"title\": \"Global Asset Analysis\",\n    \"description\": \"Extract all characters and locations from the full book to ensure consistency across episodes\",\n    \"startButton\": \"Analyze Now\",\n    \"analyzing\": \"Analyzing...\",\n    \"success\": \"Analysis complete: {characters} new characters, {locations} new locations\",\n    \"failed\": \"Global analysis failed\",\n    \"confirmAndAnalyze\": \"Confirm & Analyze Assets\"\n  }\n}"
  },
  {
    "path": "messages/en/stages.json",
    "content": "{\n  \"config\": \"1. Config\",\n  \"assets\": \"2. Asset Analysis\",\n  \"storyboard\": \"3. Storyboard Edit\",\n  \"videos\": \"4. Video Generation\",\n  \"voice\": \"5. Voice Generation\"\n}"
  },
  {
    "path": "messages/en/storyboard.json",
    "content": "{\n    \"phases\": {\n        \"planning\": \"Planning Storyboard\",\n        \"cinematography\": \"Cinematography Design\",\n        \"acting\": \"Acting Direction\",\n        \"detail\": \"Adding Details\"\n    },\n    \"prompts\": {\n        \"imagePrompt\": \"Image Prompt\",\n        \"aiInstruction\": \"AI Modify Instruction\",\n        \"supportReference\": \"(Support @ referencing asset library)\",\n        \"instructionPlaceholder\": \"e.g. Change location to @Hospital_Day, character to @ProtagonistA\",\n        \"selectAsset\": \"Select Asset\",\n        \"character\": \"Character\",\n        \"location\": \"Location\",\n        \"referencedAssets\": \"Referenced Assets:\",\n        \"removeAsset\": \"Remove Asset\",\n        \"aiModify\": \"AI Modify & Generate\",\n        \"aiModifying\": \"Modifying...\",\n        \"aiModifyTip\": \"Click to auto-save prompt and generate new image\",\n        \"save\": \"Save\",\n        \"currentPrompt\": \"Current Prompt\",\n        \"enterInstruction\": \"Please enter instruction\",\n        \"modifyFailed\": \"Operation Failed: {error}\",\n        \"updateFailed\": \"Update Failed: {error}\",\n        \"enterContinuation\": \"Please enter content to append\",\n        \"appendTitle\": \"Continue Content\",\n        \"appendDescription\": \"Enter new SRT content. The system will split and generate new shots, then append them to the end.\",\n        \"appendSubmit\": \"Append and Generate Shots\",\n        \"appendSuccess\": \"Append succeeded. New shots were added to the end of the list.\",\n        \"appendFailed\": \"Append failed: {error}\",\n        \"customStyle\": \"Custom Style\"\n    },\n    \"group\": {\n        \"generating\": \"Generating...\",\n        \"hasSynced\": \"✓ Generated\",\n        \"failed\": \"Failed\",\n        \"retry\": \"Retry\",\n        \"regenerate\": \"Regenerate All\",\n        \"generateAll\": \"Generate All\",\n        \"expand\": \"Expand\",\n        \"collapse\": \"Collapse\",\n        \"addPanel\": \"Add Panel\",\n        \"regenerating\": \"Regenerating...\",\n        \"aiAnalyzing\": \"AI Analyzing...\",\n        \"regenerateText\": \"Regenerate Text\",\n        \"generateMissingImages\": \"Generate all panels without images in this segment\",\n        \"segment\": \"Segment\",\n        \"addAtStart\": \"Add new storyboard group at the start\",\n        \"insertHere\": \"Insert new storyboard group here\"\n    },\n    \"header\": {\n        \"title\": \"Storyboard Editing\",\n        \"panels\": \"Panels\",\n        \"submit\": \"Submit Generation\",\n        \"submitting\": \"Submitting...\",\n        \"storyboardPanel\": \"Storyboard Panel\",\n        \"segments\": \"segments\",\n        \"segmentsCount\": \"Total {count} segments,\",\n        \"panelsCount\": \"{count} panels\",\n        \"generatingStatus\": \"({count} generating)\",\n        \"generateAllPanels\": \"Generate All Panels\",\n        \"generatePendingPanels\": \"Generate {count} panels without images\",\n        \"downloadAll\": \"Download All\",\n        \"downloading\": \"Packing...\",\n        \"noImages\": \"No images to download\",\n        \"downloadAllImages\": \"Download all images\",\n        \"generateVideo\": \"Generate Video →\",\n        \"back\": \"← Back\",\n        \"concurrencyLimit\": \"Concurrency limit {count}\"\n    },\n    \"panel\": {\n        \"shotType\": \"Shot Type:\",\n        \"duration\": \"seconds\",\n        \"location\": \"Location:\",\n        \"characters\": \"Characters:\",\n        \"description\": \"Description:\",\n        \"text\": \"Corresponding Text:\",\n        \"regenerate\": \"Regenerate\",\n        \"delete\": \"Delete\",\n        \"insertBefore\": \"Insert Before\",\n        \"insertAfter\": \"Insert After\",\n        \"moveUp\": \"Move Up\",\n        \"moveDown\": \"Move Down\",\n        \"plot\": \"Plot:\",\n        \"summary\": \"Summary:\",\n        \"pov\": \"POV:\",\n        \"focus\": \"Focus:\",\n        \"mode\": \"Mode:\",\n        \"shot\": \"Shot\",\n        \"segment\": \"Segment\",\n        \"stylePrompt\": \"Style/Prompt\",\n        \"shotMode\": \"Shot/Mode\",\n        \"regenerateImage\": \"Regenerate Image\",\n        \"generateImage\": \"Generate Image\",\n        \"cardView\": \"Card View\",\n        \"tableView\": \"Table View\",\n        \"shotTypeLabel\": \"Shot Type\",\n        \"cameraMove\": \"Camera Move\",\n        \"sourceText\": \"Source Text\",\n        \"sceneDescription\": \"Scene Description\",\n        \"videoPrompt\": \"Video Prompt\",\n        \"videoPromptHint\": \"Describe subject movement, environment, and camera language\",\n        \"locationLabel\": \"Location\",\n        \"editLocation\": \"Edit Location\",\n        \"characterLabel\": \"Character\",\n        \"characterLabelWithCount\": \"Characters ({count})\",\n        \"editCharacter\": \"Edit Characters\",\n        \"select\": \"+ Select\",\n        \"add\": \"+ Add\",\n        \"noLocation\": \"No location selected\",\n        \"locationNotEdited\": \"Location not edited yet\",\n        \"noCharacters\": \"No characters selected\",\n        \"charactersNotEdited\": \"Characters not edited yet\",\n        \"shotTypePlaceholder\": \"Overhead medium shot...\",\n        \"cameraMovePlaceholder\": \"Slow push, static...\",\n        \"videoPromptPlaceholder\": \"Prompt for video generation...\",\n        \"sceneDescriptionPlaceholder\": \"Describe subject, composition, lighting, and mood\",\n        \"selectCharacter\": \"Select Character\",\n        \"selectLocation\": \"Select Location\",\n        \"noCharacterAssets\": \"No character assets\",\n        \"noLocationAssets\": \"No location assets\",\n        \"selected\": \"Selected\",\n        \"defaultAppearance\": \"Default appearance\",\n        \"newPanelDescription\": \"New shot description\",\n        \"noShotType\": \"Shot type not set\"\n    },\n    \"image\": {\n        \"generating\": \"Generating...\",\n        \"regenerate\": \"Regenerate\",\n        \"edit\": \"Edit\",\n        \"editImage\": \"Edit Image\",\n        \"candidate\": \"Candidate\",\n        \"selectCandidate\": \"Select Candidate\",\n        \"variants\": \"Variants\",\n        \"generateVariants\": \"Generate Variants\",\n        \"forceRegenerate\": \"Force Regenerate\",\n        \"failed\": \"Generation Failed\",\n        \"clickToPreview\": \"Click to preview\",\n        \"enlargePreview\": \"Enlarge Preview\",\n        \"candidateCount\": \"Candidate {count}\",\n        \"candidateGenerating\": \"{count} generating\",\n        \"selectingCandidate\": \"Selecting candidate...\",\n        \"confirmCandidate\": \"Confirm Selection\",\n        \"cancelSelection\": \"Cancel Selection\",\n        \"noValidCandidates\": \"No valid candidates\",\n        \"selectCount\": \"Select count\",\n        \"generateMultiple\": \"Generate multiple candidates\",\n        \"generateCount\": \"Generate {count}\",\n        \"generateCountSuffix\": \"candidates\",\n        \"undoShort\": \"Back\"\n    },\n    \"candidate\": {\n        \"title\": \"Select Candidate Image\",\n        \"select\": \"Select\",\n        \"cancel\": \"Cancel\",\n        \"noImages\": \"No candidate images\",\n        \"original\": \"Original\"\n    },\n    \"variant\": {\n        \"title\": \"Image Variants\",\n        \"generate\": \"Generate Variants\",\n        \"select\": \"Use This Image\",\n        \"close\": \"Close\",\n        \"shotTitle\": \"Shot Variant - Based on #{number}\",\n        \"originalDescription\": \"Original Shot Description\",\n        \"noDescription\": \"No description\",\n        \"noImage\": \"No image\",\n        \"shotNum\": \"Shot {number}\",\n        \"aiRecommend\": \"AI Recommended Variants\",\n        \"reanalyze\": \"Re-analyze\",\n        \"shotType\": \"Shot type:\",\n        \"cameraMove\": \"Camera move:\",\n        \"generating\": \"Generating\",\n        \"clickToAnalyze\": \"Click Re-analyze to get AI recommendations\",\n        \"customInstruction\": \"Or custom instruction\",\n        \"customPlaceholder\": \"Enter the shot effect you want, e.g.: switch to reverse shot, focus on another character's expression...\",\n        \"includeCharacter\": \"Include character reference\",\n        \"includeLocation\": \"Include location reference\",\n        \"customVariant\": \"Custom variant\",\n        \"defaultShotType\": \"Medium Shot\",\n        \"defaultCameraMove\": \"Static\",\n        \"useCustomGenerate\": \"Generate with custom\",\n        \"analyzeFailed\": \"Analysis failed\",\n        \"creativeScore\": \"Creativity {score}/5\"\n    },\n    \"insert\": {\n        \"title\": \"Insert New Panel\",\n        \"position\": \"Insert Position\",\n        \"before\": \"Before Panel {number}\",\n        \"after\": \"After Panel {number}\",\n        \"content\": \"Panel Content\",\n        \"shotType\": \"Shot Type\",\n        \"location\": \"Location\",\n        \"characters\": \"Characters\",\n        \"description\": \"Description\",\n        \"text\": \"Corresponding Text\",\n        \"placeholder\": {\n            \"shotType\": \"Select shot type...\",\n            \"location\": \"Enter location...\",\n            \"characters\": \"Enter characters, comma separated\",\n            \"description\": \"Describe the scene...\",\n            \"text\": \"Corresponding script text...\"\n        },\n        \"insert\": \"Insert\",\n        \"cancel\": \"Cancel\"\n    },\n    \"common\": {\n        \"actions\": \"Actions\",\n        \"add\": \"Add\",\n        \"cancel\": \"Cancel\",\n        \"confirm\": \"Confirm\",\n        \"copy\": \"Copy\",\n        \"delete\": \"Delete\",\n        \"download\": \"Download\",\n        \"edit\": \"Edit\",\n        \"generate\": \"Generate\",\n        \"loading\": \"Loading...\",\n        \"none\": \"None\",\n        \"unknownError\": \"Unknown error\",\n        \"preview\": \"Preview\",\n        \"refresh\": \"Refresh\",\n        \"regenerate\": \"Regenerate\",\n        \"deleting\": \"Deleting\",\n        \"editing\": \"Editing\",\n        \"saving\": \"Saving...\",\n        \"saveFailed\": \"Save failed, changes not synced\",\n        \"retrySave\": \"Retry save\",\n        \"save\": \"Save\",\n        \"status\": \"Status\",\n        \"submitFailed\": \"Submit Failed\",\n        \"upload\": \"Upload\"\n    },\n    \"confirm\": {\n        \"deletePanel\": \"Delete this shot? This action cannot be undone.\",\n        \"deleteGroup\": \"Delete this storyboard group? This will remove all {count} shots in this segment. This action cannot be undone.\"\n    },\n    \"messages\": {\n        \"episodeNotFound\": \"Episode information not found\",\n        \"downloadFailed\": \"Download failed: {error}\",\n        \"panelNotFound\": \"Shot information not found\",\n        \"modifyFailed\": \"Modify failed: {error}\",\n        \"selectCandidateFailed\": \"Select candidate failed: {error}\",\n        \"insertPanelFailed\": \"Insert shot failed: {error}\",\n        \"addPanelFailed\": \"Add shot failed: {error}\",\n        \"deletePanelFailed\": \"Delete shot failed: {error}\",\n        \"deleteGroupFailed\": \"Delete storyboard group failed: {error}\",\n        \"regenerateGroupFailed\": \"Regenerate storyboard failed: {error}\",\n        \"addGroupFailed\": \"Add storyboard group failed: {error}\",\n        \"moveGroupFailed\": \"Move storyboard group failed: {error}\",\n        \"batchGenerateCompleted\": \"Batch generation completed:\\nSucceeded: {succeeded}\\nFailed: {failed}\\n\\nSample errors: {errors}\",\n        \"batchGenerateFailed\": \"Batch generation failed: {error}\"\n    },\n    \"canvas\": {\n        \"emptyTitle\": \"No storyboard data yet\",\n        \"emptyDescription\": \"Generate clips and storyboard text first, or add a storyboard group above\"\n    },\n    \"imageEdit\": {\n        \"title\": \"Edit Storyboard Image\",\n        \"subtitle\": \"Enter a modify instruction and optionally upload reference images or assets\",\n        \"promptPlaceholder\": \"Describe what to modify, e.g. change background color or adjust expression...\",\n        \"referenceImagesLabel\": \"Reference Images\",\n        \"referenceImagesHint\": \"(optional, paste supported)\",\n        \"start\": \"Start Editing\",\n        \"selectAsset\": \"Select Assets\",\n        \"selectedAssetsLabel\": \"Referenced Assets\",\n        \"selectedAssetsCount\": \"{count}\",\n        \"addAsset\": \"Add Asset\",\n        \"noAssets\": \"No assets selected. Click \\\"Add Asset\\\" to choose.\"\n    },\n    \"screenplay\": {\n        \"tabs\": {\n            \"formatted\": \"Screenplay\",\n            \"original\": \"Original\"\n        },\n        \"scene\": \"Scene {number}\",\n        \"characters\": \"Characters\",\n        \"voiceover\": \"Voiceover\",\n        \"parseFailedTitle\": \"Failed to parse screenplay format\",\n        \"parseFailedDescription\": \"Please check the original content\"\n    },\n    \"assets\": {\n        \"character\": {\n            \"confirming\": \"Confirming...\",\n            \"editing\": \"Editing...\"\n        },\n        \"image\": {\n            \"undo\": \"Undo to Previous Version\"\n        },\n        \"location\": {\n            \"generateImage\": \"Generate Image\"\n        },\n        \"stage\": {\n            \"analyzing\": \"Analyzing...\"\n        }\n    },\n    \"video\": {\n        \"toolbar\": {\n            \"showPending\": \"Pending\"\n        },\n        \"panelCard\": {\n            \"forceRegenerate\": \"Force Regenerate (if stuck)\"\n        }\n    },\n    \"smartImport\": {\n        \"errors\": {\n            \"analyzeFailed\": \"Analysis Failed\"\n        },\n        \"preview\": {\n            \"reanalyze\": \"Re-analyze\"\n        },\n        \"smartImport\": {\n            \"recommended\": \"Recommended\"\n        }\n    },\n    \"aiData\": {\n        \"title\": \"AI Data Editor\",\n        \"subtitle\": \"Panel {number} - Complete data sent to image generation AI\",\n        \"basicData\": \"Storyboard Basic Data\",\n        \"shotType\": \"Shot Type\",\n        \"cameraMove\": \"Camera Movement\",\n        \"shotTypePlaceholder\": \"Overhead, wide shot, eye-level, medium shot...\",\n        \"cameraMovePlaceholder\": \"Slow push, static, follow...\",\n        \"scene\": \"Scene (Read-only)\",\n        \"notSelected\": \"Not selected\",\n        \"summary\": \"Scene Summary\",\n        \"characters\": \"Characters (Read-only)\",\n        \"plot\": \"Plot\",\n        \"summarize\": \"Summary\",\n        \"visualDescription\": \"Visual Description\",\n        \"videoPrompt\": \"Video Prompt\",\n        \"negativePrompt\": \"Negative Prompt\",\n        \"save\": \"Save\",\n        \"cancel\": \"Cancel\",\n        \"lightingDirection\": \"Lighting Direction\",\n        \"lightingQuality\": \"Lighting Quality\",\n        \"depthOfField\": \"Depth of Field\",\n        \"colorTone\": \"Color Tone\",\n        \"characterPosition\": \"Character Position Rules\",\n        \"position\": \"Position\",\n        \"posture\": \"Posture\",\n        \"facing\": \"Facing\",\n        \"photographyRules\": \"Photography Rules\",\n        \"viewData\": \"View Data\",\n        \"jsonPreview\": \"JSON Preview\",\n        \"actingNotes\": \"Acting Direction (acting_notes)\",\n        \"actingTitle\": \"Acting Direction\",\n        \"actingDescription\": \"Performance Notes\",\n        \"noActingData\": \"No acting data\"\n    },\n    \"insertModal\": {\n        \"insertBetween\": \"Insert between #{before} and #{after}\",\n        \"panel\": \"Panel\",\n        \"noImage\": \"No image\",\n        \"insertAtEnd\": \"End\",\n        \"aiAnalyze\": \"AI Auto-analyze\",\n        \"analyzing\": \"AI analyzing...\",\n        \"insert\": \"Insert\",\n        \"inserting\": \"Inserting...\",\n        \"placeholder\": \"Optional: Add notes, e.g. add a reaction shot...\"\n    },\n    \"panelActions\": {\n        \"insertPanel\": \"Insert Panel\",\n        \"panelVariant\": \"Panel Variant\",\n        \"insertHere\": \"Insert panel here\",\n        \"generateVariant\": \"Generate variant based on this panel\",\n        \"needImage\": \"Need to generate image first\",\n        \"deleteShot\": \"Delete Shot\",\n        \"pasteSrtPlaceholder\": \"Paste new SRT content...\"\n    },\n    \"firstLastFrame\": {\n        \"placeholder\": \"Enter first/last frame video prompt...\",\n        \"modelTitle\": \"First/Last Frame Model\"\n    }\n}\n"
  },
  {
    "path": "messages/en/video.json",
    "content": "{\n  \"panelCard\": {\n    \"play\": \"Play\",\n    \"pause\": \"Pause\",\n    \"retry\": \"Retry\",\n    \"regenerate\": \"Regenerate\",\n    \"download\": \"Download\",\n    \"edit\": \"Edit\",\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"generating\": \"Generating...\",\n    \"failed\": \"Failed\",\n    \"lipSync\": \"Lip Sync\",\n    \"lipSyncVideo\": \"Lip Sync Video\",\n    \"lipSyncLabel\": \"Lip Sync\",\n    \"lipSyncTitle\": \"Lip Sync\",\n    \"original\": \"Original\",\n    \"synced\": \"Synced\",\n    \"videoFixed\": \"✓ Video\",\n    \"imagePreview\": \"Image Preview\",\n    \"playVoice\": \"Play Voice\",\n    \"stopVoice\": \"Stop\",\n    \"noVoice\": \"No voice available\",\n    \"forceRegenerate\": \"Force Regenerate (use if stuck)\",\n    \"regenerateVideo\": \"Regenerate Video\",\n    \"lipSyncStatus\": \"Lip syncing...\",\n    \"lipSyncInProgress\": \"Lip syncing in progress...\",\n    \"lipSyncMayTakeMinutes\": \"This may take a few minutes\",\n    \"audioEnabled\": \"Audio enabled\",\n    \"audioDisabled\": \"Audio disabled\",\n    \"isSynced\": \"(Synced)\",\n    \"needVideo\": \"(Please generate video first)\",\n    \"needAudio\": \"(Please generate audio first)\",\n    \"generateAudio\": \"Generate Audio\",\n    \"regenerateLipSync\": \"Regenerate Lip Sync\",\n    \"editPrompt\": \"Edit Prompt\",\n    \"clickToEditPrompt\": \"Click to edit prompt...\",\n    \"shot\": \"Shot {number}\",\n    \"unknownShotType\": \"Unknown\",\n    \"correspondingText\": \"Corresponding Text\",\n    \"generateVideo\": \"Generate Video\",\n    \"selectModel\": \"Select Video Model\",\n    \"selectVoice\": \"Select voice to use:\",\n    \"willAutoPad\": \"(will auto-pad)\",\n    \"autoPadding\": \"Padding\",\n    \"redo\": \"Redo\",\n    \"generatingAudio\": \"Generating...\",\n    \"error\": {\n      \"audioFailed\": \"Audio generation failed\"\n    },\n    \"batchMode\": \"Batch Mode\",\n    \"batchModeDesc\": \"Offline inference, 50% cheaper, completes within 24 hours\",\n    \"batchModeEnabled\": \"Batch mode enabled\",\n    \"batchModeDisabled\": \"Batch mode disabled\"\n  },\n  \"promptModal\": {\n    \"title\": \"Edit Shot #{number} Video Prompt\",\n    \"shotType\": \"Shot Type:\",\n    \"duration\": \"s\",\n    \"location\": \"Location:\",\n    \"locationUnknown\": \"Unknown\",\n    \"characters\": \"Characters:\",\n    \"charactersNone\": \"None\",\n    \"description\": \"Description:\",\n    \"text\": \"Corresponding Text:\",\n    \"promptLabel\": \"Video Prompt\",\n    \"placeholder\": \"Enter video prompt...\",\n    \"tip\": \"Tip: Video models don't recognize character names, use appearance descriptions like \\\"young man with black hair and blue eyes\\\" instead of \\\"Victor\\\"\",\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\"\n  },\n  \"toolbar\": {\n    \"title\": \"Video Generation\",\n    \"filter\": \"Filter\",\n    \"viewAll\": \"View All\",\n    \"showGenerated\": \"Generated\",\n    \"showPending\": \"Pending\",\n    \"showFailed\": \"Failed\",\n    \"totalShots\": \"Total {count} shots\",\n    \"generatingShots\": \"{count} generating\",\n    \"completedShots\": \"{count} completed\",\n    \"failedShots\": \"{count} failed\",\n    \"generateAll\": \"Generate All Videos\",\n    \"batchConfigTitle\": \"Batch Generation Settings\",\n    \"batchConfigDesc\": \"Choose model and parameters before generating all videos.\",\n    \"confirmGenerateAll\": \"Confirm and Generate All\",\n    \"confirming\": \"Submitting...\",\n    \"noVideos\": \"No videos to download\",\n    \"downloadCount\": \"Download {count} videos\",\n    \"packing\": \"Packing...\",\n    \"downloadAll\": \"Download All\",\n    \"enterEditor\": \"Enter Video Editor\",\n    \"enterEdit\": \"Enter Editor\",\n    \"back\": \"Back\"\n  },\n  \"stage\": {\n    \"title\": \"Video Generation\",\n    \"generateAll\": \"Generate All\",\n    \"regenerateFailed\": \"Retry Failed\",\n    \"downloadAll\": \"Download All Videos\",\n    \"enterEditor\": \"Enter Editor\",\n    \"lipSyncStatus\": \"Lip syncing...\",\n    \"hasSynced\": \"✓ Generated\",\n    \"generating\": \"Generating...\",\n    \"downloading\": \"Downloading...\",\n    \"downloadProgress\": \"Preparing video files... {current}/{total}\",\n    \"noVideos\": \"No generated videos\",\n    \"scrollTo\": \"Jump to shot\",\n    \"error\": {\n      \"saveFailed\": \"Failed to save video prompt\",\n      \"lipSyncFailed\": \"Lip sync failed\",\n      \"fetchVideosFailed\": \"Failed to fetch video list\"\n    },\n    \"downloadFailed\": \"Download failed\",\n    \"unknownError\": \"Unknown error\"\n  },\n  \"firstLastFrame\": {\n    \"title\": \"First/Last Frame Settings\",\n    \"firstFrame\": \"First Frame\",\n    \"lastFrame\": \"Last Frame\",\n    \"range\": \"Shot {from} → Shot {to}\",\n    \"link\": \"Link\",\n    \"unlink\": \"Unlink\",\n    \"unlinkAction\": \"Unlink\",\n    \"asLastFrameFor\": \"As last frame for Shot {number}\",\n    \"asFirstFrameFor\": \"As first frame for Shot {number}\",\n    \"customPrompt\": \"Custom Prompt\",\n    \"promptPlaceholder\": \"Enter first/last frame video prompt...\",\n    \"useDefault\": \"Use Default\",\n    \"generate\": \"Generate First/Last Frame Video\",\n    \"generated\": \"First/Last Frame Video Generated\",\n    \"model\": \"Model\",\n    \"withAudio\": \"With Audio\",\n    \"audioOn\": \"On\",\n    \"audioOff\": \"Off\",\n    \"linkToNext\": \"Link to next panel (First/Last Frame)\",\n    \"asLastFrame\": \"As last frame for Panel {number}\",\n    \"thenTransitionTo\": \"then transition to\"\n  },\n  \"editor\": {\n    \"alert\": {\n      \"saveSuccess\": \"Saved successfully\",\n      \"saveFailed\": \"Save failed\",\n      \"exportStarted\": \"Export has started. Please wait...\",\n      \"exportFailed\": \"Export failed\"\n    },\n    \"toolbar\": {\n      \"back\": \"← Back\",\n      \"saveDirty\": \"Save *\",\n      \"saved\": \"Saved\",\n      \"export\": \"Export Video\"\n    },\n    \"left\": {\n      \"title\": \"Media Library\",\n      \"description\": \"Clips imported from the video stage will appear here\"\n    },\n    \"right\": {\n      \"title\": \"Properties\",\n      \"clipLabel\": \"Clip:\",\n      \"clipFallback\": \"Clip {index}\",\n      \"durationLabel\": \"Duration:\",\n      \"transitionLabel\": \"Transition to Next Clip\",\n      \"deleteConfirm\": \"Delete this clip?\",\n      \"deleteClip\": \"Delete Clip\",\n      \"selectClipHint\": \"Select a clip to view properties\"\n    },\n    \"preview\": {\n      \"emptyStartEditing\": \"Add media to start editing\"\n    },\n    \"timeline\": {\n      \"zoomLabel\": \"Zoom:\",\n      \"videoTrack\": \"Video\",\n      \"emptyHint\": \"Drag video clips from media library to here\",\n      \"audioTrack\": \"Voice\",\n      \"audioBadge\": \"A\"\n    },\n    \"transition\": {\n      \"title\": \"Transition\",\n      \"duration\": \"Duration\",\n      \"options\": {\n        \"none\": \"None\",\n        \"dissolve\": \"Dissolve\",\n        \"fade\": \"Fade\",\n        \"slide\": \"Slide\"\n      }\n    }\n  },\n  \"errors\": {\n    \"unknownError\": \"Unknown error\"\n  },\n  \"capability\": {\n    \"generationMode\": \"Generation mode\",\n    \"generateAudio\": \"Generate audio\",\n    \"duration\": \"Duration\",\n    \"fps\": \"Frame rate\",\n    \"resolution\": \"Resolution\",\n    \"aspectRatio\": \"Aspect ratio\",\n    \"reasoningEffort\": \"Reasoning effort\",\n    \"voice\": \"Voice\",\n    \"rate\": \"Rate\",\n    \"mode\": \"Mode\"\n  },\n  \"unit\": {\n    \"second\": \"s\",\n    \"frame\": \"fps\"\n  },\n  \"common\": {\n    \"generate\": \"Generate\"\n  }\n}"
  },
  {
    "path": "messages/en/voice.json",
    "content": "{\n    \"title\": \"Voice Lines\",\n    \"linesCount\": \"{count} lines total, \",\n    \"audioGeneratedCount\": \"{count} audio generated\",\n    \"emotionPrompt\": \"Emotion Prompt\",\n    \"emotionPromptTip\": \"(Leave empty for auto-reference)\",\n    \"emotionPlaceholder\": \"e.g. laugh, English only...\",\n    \"emotionStrength\": \"Emotion Strength\",\n    \"flat\": \"Flat\",\n    \"intense\": \"Intense\",\n    \"generating\": \"Generating...\",\n    \"generateVoice\": \"Generate Voice\",\n    \"toolbar\": {\n        \"back\": \"← Back\",\n        \"analyzeLines\": \"Analyze Lines\",\n        \"addLine\": \"+ Add Voice\",\n        \"generateAll\": \"Generate All Voices\",\n        \"downloadAll\": \"Download Voices\",\n        \"generatingCount\": \"Generating ({count})\",\n        \"packing\": \"Packing...\",\n        \"stats\": \"{total} lines | {withVoice} with voice | {withAudio} generated\",\n        \"noDownload\": \"No voices to download\",\n        \"downloadCount\": \"Download {count} voices\",\n        \"uploadReferenceHint\": \"Please upload reference audio for all characters first\"\n    },\n    \"speakerVoice\": {\n        \"title\": \"Speaker Voice Status\",\n        \"hint\": \"Please upload reference audio for characters in Asset Library\",\n        \"linesCount\": \"{count} lines\",\n        \"noVoice\": \"No reference voice\",\n        \"configured\": \"✓ Configured\",\n        \"playVoice\": \"Play current voice\",\n        \"aiDesign\": \"AI Design Voice\",\n        \"aiDesignVoice\": \"AI Design Voice\",\n        \"redesign\": \"Redesign voice with AI\",\n        \"uploadAudio\": \"Upload audio\",\n        \"uploading\": \"Uploading\",\n        \"upload\": \"Upload\",\n        \"microsoftVoice\": \"Microsoft Voice\",\n        \"microsoft\": \"MS\",\n        \"maleVoices\": \"Male\",\n        \"femaleVoices\": \"Female\",\n        \"openAssetLibrary\": \"Asset Library\",\n        \"configuredStatus\": \"Voice Set\",\n        \"pendingStatus\": \"Voice Pending\",\n        \"voiceSettings\": \"Voice Settings\",\n        \"inlineLabel\": \"Inline\"\n    },\n    \"inlineBinding\": {\n        \"title\": \"Set voice for \\\"{speaker}\\\"\",\n        \"description\": \"This speaker is not in the asset library. Choose a method to set a reference voice.\",\n        \"selectFromLibrary\": \"Select from Voice Library\",\n        \"selectFromLibraryDesc\": \"Choose an existing global voice\",\n        \"uploadAudio\": \"Upload Reference Audio\",\n        \"uploadAudioDesc\": \"Upload MP3, WAV or other audio files as reference voice\",\n        \"uploadQwenHint\": \"Uploaded voices can only be synthesized with IndexTTS, not QwenTTS. QwenTTS requires an AI-designed voice.\",\n        \"aiDesign\": \"AI Design Voice\",\n        \"aiDesignDesc\": \"Use AI to generate an exclusive reference voice\"\n    },\n    \"embedded\": {\n        \"linesStats\": \"{total} lines · {audio} generated\",\n        \"reanalyze\": \"Re-analyze\",\n        \"analyzeLines\": \"Analyze Lines\",\n        \"reanalyzeHint\": \"Re-analyze lines and update shot matching\",\n        \"analyzeHint\": \"Extract lines from script\",\n        \"downloadVoice\": \"Download Voices\",\n        \"generateAllVoice\": \"Generate All Voices\",\n        \"pendingCount\": \"({count} pending)\",\n        \"generatingProgress\": \"Generating ({current}/{total})\",\n        \"generatingHint\": \"Generating...\",\n        \"noVoiceHint\": \"Please set voice for all characters above first\",\n        \"noLinesHint\": \"No lines to generate\",\n        \"allDoneHint\": \"All lines generated\",\n        \"generateHint\": \"Click to generate {count} pending voices\",\n        \"addLine\": \"+ Add Voice\",\n        \"speakerVoiceStatus\": \"Speaker Voice Status\",\n        \"speakersCount\": \"{count}\",\n        \"listen\": \"Listen\",\n        \"listenVoice\": \"Listen to voice\",\n        \"reset\": \"Reset\",\n        \"resetDesign\": \"Redesign\",\n        \"aiDesign\": \"AI Design\",\n        \"assetLibrary\": \"Asset Library\"\n    },\n    \"lineCard\": {\n        \"generatingVoice\": \"Generating\",\n        \"speaker\": \"Speaker\",\n        \"speakerPlaceholder\": \"Speaker name\",\n        \"content\": \"Content\",\n        \"contentPlaceholder\": \"Line content\",\n        \"emotionConfigured\": \"Emotion set\",\n        \"emotionSettings\": \"Emotion Settings\",\n        \"voiceConfigured\": \"✓ Configured\",\n        \"needVoice\": \"Please set voice above\",\n        \"locatePanel\": \"Locate bound shot\",\n        \"locateVideo\": \"Locate Video\",\n        \"play\": \"Play\",\n        \"pause\": \"Pause\",\n        \"locatePanelCta\": \"Jump to Shot {index}\",\n        \"editLine\": \"Edit line\",\n        \"deleteLine\": \"Delete line\",\n        \"deleteAudio\": \"Delete audio\"\n    },\n    \"lineEditor\": {\n        \"addTitle\": \"Add Voice Line\",\n        \"editTitle\": \"Edit Voice Line\",\n        \"contentLabel\": \"Line Content\",\n        \"contentPlaceholder\": \"Enter line content\",\n        \"speakerLabel\": \"Speaker\",\n        \"speakerPlaceholder\": \"Enter speaker name\",\n        \"selectSpeaker\": \"Select a speaker\",\n        \"noSpeakerOptions\": \"No available speakers in this project yet. Analyze lines first.\",\n        \"bindPanelLabel\": \"Bind Shot\",\n        \"unboundPanel\": \"Unbound\",\n        \"panelLabel\": \"Shot {index}\",\n        \"saveAdd\": \"Add Voice\",\n        \"saveEdit\": \"Save Changes\"\n    },\n    \"empty\": {\n        \"title\": \"No Voice Lines\",\n        \"description\": \"Extract lines and speakers from script\",\n        \"analyzeButton\": \"Analyze Lines\",\n        \"hint\": \"Please upload reference audio for characters in Asset Library first\"\n    },\n    \"confirm\": {\n        \"deleteLine\": \"Are you sure you want to delete this line?\\n\\n\\\"{content}\\\"\\n\\nThis action cannot be undone.\",\n        \"deleteAudio\": \"Are you sure you want to delete this audio?\\n\\n\\\"{content}\\\"\\n\\nThis action cannot be undone.\"\n    },\n    \"errors\": {\n        \"saveFailed\": \"Save Failed\",\n        \"analyzeFailed\": \"Analysis Failed\",\n        \"generateFailed\": \"Generation Failed\",\n        \"batchFailed\": \"Batch Generation Failed\",\n        \"downloadFailed\": \"Download Failed\",\n        \"deleteFailed\": \"Delete Failed\",\n        \"addFailed\": \"Add Voice Failed\",\n        \"invalidLineInput\": \"Content and speaker cannot be empty\",\n        \"bindFailed\": \"Bind shot failed\",\n        \"deleteAudioFailed\": \"Delete Audio Failed\",\n        \"uploadFailed\": \"Upload Failed\",\n        \"voiceDesignFailed\": \"Voice Design Save Failed\",\n        \"emotionSaveFailed\": \"Emotion Settings Save Failed\",\n        \"voiceGenerateFailed\": \"Voice Generation Failed\"\n    },\n    \"alerts\": {\n        \"insufficientBalance\": \"Insufficient Balance\",\n        \"insufficientBalanceMsg\": \"Account balance insufficient, please top up to continue\",\n        \"noLinesToGenerate\": \"No lines to generate (please upload reference audio for characters first)\",\n        \"generateComplete\": \"Complete: {success}/{total} successful\",\n        \"generateFailed\": \"{count} failed\",\n        \"speakerVoiceSet\": \"Reference audio set for {speaker}\",\n        \"speakerVoiceUploaded\": \"Reference audio uploaded for {speaker}\",\n        \"voiceDesignSet\": \"AI designed voice set for {speaker}\"\n    },\n    \"common\": {\n        \"loading\": \"Loading...\",\n        \"save\": \"Save\",\n        \"cancel\": \"Cancel\",\n        \"cancelling\": \"Cancelling...\",\n        \"upload\": \"Upload\",\n        \"download\": \"Download\",\n        \"generate\": \"Generate\",\n        \"regenerate\": \"Regenerate\"\n    },\n    \"assets\": {\n        \"image\": {\n            \"uploadFailed\": \"Upload Failed\"\n        },\n        \"stage\": {\n            \"analyzing\": \"Analyzing...\"\n        }\n    },\n    \"smartImport\": {\n        \"errors\": {\n            \"analyzeFailed\": \"Analysis Failed\"\n        }\n    },\n    \"video\": {\n        \"panelCard\": {\n            \"play\": \"Play\"\n        }\n    },\n    \"tts\": {\n        \"generatedAudio\": \"Generated Audio\",\n        \"browserNotSupport\": \"Your browser does not support audio playback\",\n        \"audioDuration\": \"Audio Duration:\",\n        \"subtitleCount\": \"Subtitle Count:\",\n        \"noAudio\": \"No audio\",\n        \"srtPreview\": \"SRT Subtitle Preview\",\n        \"noSubtitle\": \"No subtitles\",\n        \"stats\": \"Generation Stats\",\n        \"minute\": \"min\",\n        \"second\": \"sec\",\n        \"items\": \"items\",\n        \"completed\": \"✓ Completed\",\n        \"regenerating\": \"Regenerating...\",\n        \"regenerateTTS\": \"Regenerate TTS\",\n        \"nextStep\": \"Next: Analyze Assets\",\n        \"readyTip\": \"Click to proceed to asset analysis\",\n        \"needGenerate\": \"Please generate TTS audio first\"\n    },\n    \"voiceCreate\": {\n        \"aiDesignMode\": \"AI Design Voice\",\n        \"uploadMode\": \"Upload Audio\",\n        \"dropOrClick\": \"Drop file or click to select\",\n        \"supportedFormats\": \"Supports MP3, WAV, OGG, M4A, AAC formats\",\n        \"invalidFileType\": \"Unsupported file format. Please upload an audio file\",\n        \"fileTooLarge\": \"File too large. Maximum 50MB supported\",\n        \"previewAudio\": \"Preview Audio\",\n        \"uploading\": \"Uploading...\",\n        \"uploadFailed\": \"Upload failed\",\n        \"uploadSuccess\": \"Upload successful\"\n    },\n    \"voiceDesign\": {\n        \"presets\": {\n            \"maleBroadcaster\": \"Male Broadcaster\",\n            \"gentleFemale\": \"Gentle Female\",\n            \"matureMale\": \"Mature Male\",\n            \"livelyFemale\": \"Lively Girl\",\n            \"intellectualFemale\": \"Intellectual Female\",\n            \"narrator\": \"Narrator\"\n        },\n        \"presetsPrompts\": {\n            \"maleBroadcaster\": \"Middle-aged male broadcaster with steady, deep voice, clear pronunciation\",\n            \"gentleFemale\": \"Gentle and sweet young woman with clear, melodious voice\",\n            \"matureMale\": \"Mature male with charismatic and expressive voice\",\n            \"livelyFemale\": \"Lively young girl with sweet, cute, energetic voice\",\n            \"intellectualFemale\": \"Elegant intellectual woman with clear, pleasant voice\",\n            \"narrator\": \"Emotional narrator with warm, storytelling voice\"\n        },\n        \"defaultPreviewText\": \"Hello, nice to meet you. This is your AI-designed exclusive voice. Let me demonstrate its features for you. Whether it's a gentle conversation or an excited narration, I can deliver it perfectly. I hope you enjoy this voice, let's create amazing content together.\",\n        \"pleaseSelectStyle\": \"Please enter or select a voice style\",\n        \"designVoiceFor\": \"Design AI Voice for \\\"{speaker}\\\"\",\n        \"hasExistingVoice\": \"Has voice\",\n        \"selectStyle\": \"Select voice style:\",\n        \"orCustomDescription\": \"Or custom description:\",\n        \"describePlaceholder\": \"Describe voice characteristics: age, gender, tone, pitch...\",\n        \"generateSchemesPrefix\": \"Generate\",\n        \"generateSchemesSuffix\": \"Voice Schemes\",\n        \"schemeCountAriaLabel\": \"Number of voice schemes to generate\",\n        \"editPreviewText\": \"Edit preview text\",\n        \"generating3Schemes\": \"Generating 3 voice schemes...\",\n        \"estimatedTime\": \"Est. 15-30 seconds\",\n        \"selectScheme\": \"Select voice scheme:\",\n        \"schemeN\": \"Scheme {n}\",\n        \"regenerate\": \"Regenerate\",\n        \"confirmUse\": \"✓ Confirm Use\",\n        \"confirmReplace\": \"Confirm Replace Voice?\",\n        \"replaceWarning\": \"'s original voice, irreversible\",\n        \"confirmReplaceBtn\": \"Confirm Replace\",\n        \"noVoiceGenerated\": \"No voice generated\",\n        \"generationError\": \"Voice generation failed\",\n        \"generateFailed\": \"Failed to generate voice {n}\",\n        \"preview\": \"Preview\",\n        \"playing\": \"Playing\"\n    }\n}\n"
  },
  {
    "path": "messages/en/workspace.json",
    "content": "{\n  \"title\": \"My Projects\",\n  \"subtitle\": \"Manage your AI anime production projects\",\n  \"newProject\": \"New Project\",\n  \"searchPlaceholder\": \"Search project name or description...\",\n  \"searchButton\": \"Search\",\n  \"clearButton\": \"Clear\",\n  \"updatedAt\": \"Updated at\",\n  \"noProjects\": \"No projects yet\",\n  \"noProjectsDesc\": \"Create your first AI anime production project\",\n  \"noResults\": \"No matching projects found\",\n  \"noResultsDesc\": \"Try using different search terms\",\n  \"createProject\": \"New Project\",\n  \"editProject\": \"Edit Project\",\n  \"deleteProject\": \"Delete Project\",\n  \"deleteConfirm\": \"Are you sure you want to delete project \\\"{name}\\\"? This action cannot be undone.\",\n  \"projectName\": \"Project Name\",\n  \"projectNamePlaceholder\": \"Enter project name\",\n  \"projectDescription\": \"Project Description (Optional)\",\n  \"projectDescriptionPlaceholder\": \"Enter project description\",\n  \"creating\": \"Creating...\",\n  \"saving\": \"Saving...\",\n  \"createFailed\": \"Failed to create project\",\n  \"analysisModelRequiredAfterCreate\": \"Project created. Please configure default models in Profile first (at minimum an analysis model), or the project cannot be used.\",\n  \"updateFailed\": \"Failed to update project\",\n  \"deleteFailed\": \"Failed to delete project\",\n  \"totalProjects\": \"{count} projects in total\",\n  \"statsEpisodes\": \"Episodes\",\n  \"statsImages\": \"Images\",\n  \"statsVideos\": \"Videos\",\n  \"noContent\": \"No content yet\",\n  \"modelNotConfigured\": {\n    \"before\": \"No models configured yet. Please go to\",\n    \"link\": \"Settings Center\",\n    \"after\": \"to configure models, or customize them in project settings after creation.\"\n  }\n}"
  },
  {
    "path": "messages/en/workspaceDetail.json",
    "content": "{\n  \"globalAssets\": \"Global Assets\",\n  \"createFailed\": \"Creation failed\",\n  \"deleteFailed\": \"Deletion failed\",\n  \"renameFailed\": \"Rename failed\",\n  \"refreshFailed\": \"Refresh failed\",\n  \"projectNotFound\": \"Project not found\",\n  \"backToWorkspace\": \"Back to Workspace\",\n  \"episode\": \"Episode\",\n  \"modelSetup\": {\n    \"title\": \"Set up default models before starting\",\n    \"description\": \"No default analysis model is configured for this account. Smart split and downstream AI flows cannot run until it is set.\",\n    \"configureNow\": \"Configure Analysis Model\",\n    \"goProfile\": \"Go to Profile Settings\",\n    \"modalTitle\": \"Configure Default Analysis Model\",\n    \"modalDescription\": \"Select an available text model as the default analysis model.\",\n    \"selectModelLabel\": \"Default Analysis Model\",\n    \"selectModelPlaceholder\": \"Select an analysis model\",\n    \"selectModelFirst\": \"Please select an analysis model first\",\n    \"noModelOptions\": \"No text model is available. Configure your provider and models in Profile settings first.\",\n    \"saveFailed\": \"Failed to save default analysis model. Please try again.\"\n  },\n  \"sidebar\": {\n    \"dragToMove\": \"Drag to move position\",\n    \"listTitle\": \"Episode List\",\n    \"episodeCount\": \"{count} episodes\",\n    \"empty\": \"No episodes yet. Create one below.\",\n    \"save\": \"Save\",\n    \"deleteConfirm\": \"Delete \\\"{name}\\\"?\",\n    \"delete\": \"Delete\",\n    \"cancel\": \"Cancel\",\n    \"rename\": \"Rename\",\n    \"newEpisodePlaceholder\": \"Enter episode name...\",\n    \"create\": \"Create\",\n    \"addEpisode\": \"Add Episode\"\n  }\n}\n"
  },
  {
    "path": "messages/en/worldContextModal.json",
    "content": "{\n    \"title\": \"World & Character Settings\",\n    \"description\": \"Define global character appearances, scene styles, and environment descriptions\",\n    \"placeholder\": \"Example:\\n【Protagonist】John, 25, short black hair, always wearing a faded denim jacket, melancholy eyes.\\n【Heroine】Jane, 22, red twin tails, lively personality, likes to wear Lolita-style dresses.\\n【Scene】Cyberpunk city in 2077, neon lights flashing, raining all year round...\",\n    \"hint\": \"These settings will be inherited by all episodes as a reference for AI drawing.\"\n}"
  },
  {
    "path": "messages/zh/actions.json",
    "content": "{\n  \"storyboard\": \"分镜图\",\n  \"storyboard_candidate\": \"分镜候选\",\n  \"character\": \"角色图\",\n  \"location\": \"场景图\",\n  \"video\": \"视频\",\n  \"analyze\": \"分析\",\n  \"analyze_character\": \"角色分析\",\n  \"analyze_location\": \"场景分析\",\n  \"clips\": \"片段切割\",\n  \"storyboard_text_plan\": \"分镜规划\",\n  \"storyboard_text_detail\": \"分镜细节\",\n  \"tts\": \"语音合成\",\n  \"regenerate\": \"重生成\",\n  \"voice-generate\": \"配音生成\",\n  \"voice-design\": \"声音设计\",\n  \"lip-sync\": \"口型同步\"\n}"
  },
  {
    "path": "messages/zh/apiConfig.json",
    "content": "{\n  \"title\": \"API 配置\",\n  \"saving\": \"保存中...\",\n  \"saved\": \"已保存\",\n  \"saveFailed\": \"保存失败\",\n  \"connected\": \"已连接\",\n  \"notConfigured\": \"未配置 Key\",\n  \"configure\": \"配置\",\n  \"connect\": \"连接\",\n  \"compatibilityLayerOpenAI\": \"OpenAI 兼容层\",\n  \"compatibilityLayerGemini\": \"Gemini 兼容层\",\n  \"show\": \"显示\",\n  \"hide\": \"隐藏\",\n  \"capability\": \"能力\",\n  \"default\": \"默认\",\n  \"delete\": \"删除\",\n  \"add\": \"添加\",\n  \"cancel\": \"取消\",\n  \"close\": \"关闭\",\n  \"save\": \"保存\",\n  \"comingSoon\": \"待上线\",\n  \"priceInput\": \"输入 {amount}\",\n  \"priceOutput\": \"输出 {amount}\",\n  \"priceUnavailable\": \"暂无定价\",\n  \"fillComplete\": \"请填写完整信息\",\n  \"fillPricing\": \"请填写价格信息\",\n  \"pricingInputLabel\": \"输入价格\",\n  \"pricingOutputLabel\": \"输出价格\",\n  \"pricingBasePriceLabel\": \"基础价格（可选）\",\n  \"pricingEnableCustom\": \"启用自定义价格（可选）\",\n  \"pricingOptionPricesPlaceholder\": \"参数价格 JSON（可选），示例：{\\\"resolution\\\":{\\\"1024x1024\\\":0.12}}\",\n  \"modelIdExists\": \"模型 ID 已存在\",\n  \"flushConfigFailed\": \"保存厂商配置失败，请先检查并保存 API Key / Base URL\",\n  \"probeLlmProtocolFailed\": \"模型协议探测失败，请稍后重试\",\n  \"probeAuthFailed\": \"模型协议探测鉴权失败，请检查 API Key\",\n  \"probeInconclusive\": \"模型协议探测结果不确定（可能限流或服务异常），请稍后重试\",\n  \"probeRequestFailed\": \"模型协议探测请求失败，请稍后重试\",\n  \"modelDisplayName\": \"外显名称（你自己看的）\",\n  \"modelActualId\": \"实际调用 ID（模型参数）\",\n  \"noModelsForProvider\": \"该厂商暂无配置模型\",\n  \"defaultModels\": \"默认模型配置\",\n  \"textDefault\": \"文本模型\",\n  \"characterDefault\": \"角色模型\",\n  \"locationDefault\": \"场景模型\",\n  \"storyboardDefault\": \"分镜模型\",\n  \"editDefault\": \"修图模型\",\n  \"videoDefault\": \"视频模型\",\n  \"audioDefault\": \"语音模型\",\n  \"lipsyncDefault\": \"口型同步模型\",\n  \"selectDefault\": \"请选择\",\n  \"defaultModelDesc\": {\n    \"analysisModel\": \"负责剧本解析、分镜构建等全流程文本分析能力。\",\n    \"videoModel\": \"负责将图像与指令合成为最终视频片段。\",\n    \"characterModel\": \"根据剧本描述生成角色形象与外观参考图\",\n    \"locationModel\": \"根据剧本描述生成场景环境与空间参考图\",\n    \"storyboardModel\": \"根据分镜脚本生成对应的镜头画面参考图\",\n    \"editModel\": \"对已有图片进行局部修改、风格调整与精修\",\n    \"audioModel\": \"将文本台词转化为自然流畅的语音音频\",\n    \"lipSyncModel\": \"将语音与视频角色的口型进行精准同步\",\n    \"voiceDesignModel\": \"为角色定制专属音色与语音风格特征\"\n  },\n  \"defaultModelSection\": {\n    \"coreFoundation\": \"文本分析与视频能力\",\n    \"creativePipeline\": \"全局图像模型配置\",\n    \"unifiedOverride\": \"批量配置图像模型\",\n    \"unifiedOverrideHint\": \"设置负责整个系统所有地方图像生成/编辑的模型\",\n    \"unifiedOverridePlaceholder\": \"批量应用到以下场景...\",\n    \"followUnified\": \"跟随全局配置\",\n    \"extensions\": \"扩展功能\",\n    \"coreTextTitle\": \"文本分析模型\",\n    \"coreVideoTitle\": \"视频生成模型\",\n    \"pipelineCharacter\": \"角色生成\",\n    \"pipelineLocation\": \"场景生成\",\n    \"pipelineStoryboard\": \"镜头生成\",\n    \"pipelineEdit\": \"编辑图片\",\n    \"extLipSync\": \"口型同步\",\n    \"extTTS\": \"语音合成\",\n    \"extVoiceDesign\": \"音色设计\",\n    \"corePlaceholder\": \"必选配置\",\n    \"extPlaceholder\": \"暂不启用\"\n  },\n  \"imageModelTip\": \"推荐使用 Google Banana 系列模型，目前其他图像模型生成效果有限。\",\n  \"customProviderTip\": \"项目目前为测试版，由于市面上各厂商自定义 API 格式差异较大，自定义 API 兼容性尚不完善，建议优先使用官方内置 API。后续版本将持续更新以兼容更多厂商。\",\n  \"providerPool\": \"厂商资源池\",\n  \"providerPoolDesc\": \"在此使用来自全球丰富的模型配置\",\n  \"dragToSort\": \"拖拽排序\",\n  \"dragToSortHint\": \"按住左上角拖拽手柄可调整厂商顺序\",\n  \"hideProvider\": \"隐藏提供商\",\n  \"hideProviderConfirm\": \"确定要隐藏此提供商吗？隐藏后将置底显示，可随时恢复。\",\n  \"showProvider\": \"显示提供商\",\n  \"showHiddenProviders\": \"显示隐藏的提供商\",\n  \"hideHiddenProviders\": \"收起隐藏的提供商\",\n  \"hiddenProvidersPrefix\": \"已隐藏\",\n  \"providerIdExists\": \"该提供商 ID 已存在\",\n  \"presetProviderCannotDelete\": \"预设提供商不能删除\",\n  \"confirmDeleteProvider\": \"确定删除这个提供商吗？\",\n  \"presetModelCannotDelete\": \"预设模型不能删除\",\n  \"confirmDeleteModel\": \"确定删除这个模型吗？\",\n  \"addGeminiProvider\": \"新增模型服务商\",\n  \"baseUrl\": \"Base URL\",\n  \"configureBaseUrl\": \"配置地址\",\n  \"addModel\": \"添加模型\",\n  \"batchModeHalfPrice\": \"批量模式（价格减半）\",\n  \"openaiCompatVideoOnlyHint\": \"仅支持 OpenAI 官方格式的图生视频模型。\",\n  \"typeText\": \"文本\",\n  \"typeImage\": \"图像\",\n  \"typeVideo\": \"视频\",\n  \"typeAudio\": \"音频等\",\n  \"apiKeyLabel\": \"API Key\",\n  \"apiType\": \"API 类型\",\n  \"apiTypeGeminiCompatible\": \"Gemini 兼容\",\n  \"apiTypeOpenAICompatible\": \"OpenAI 兼容\",\n  \"apiTypeGeminiHint\": \"使用 Google SDK\",\n  \"otherProviders\": \"其他配置\",\n  \"audioCategory\": \"音频\",\n  \"audioAndLipsync\": \"语音与唇形同步\",\n  \"configureApiKey\": \"配置 API Key\",\n  \"enterApiKey\": \"请输入 API Key\",\n  \"testConnection\": \"测试连接\",\n  \"testing\": \"正在测试...\",\n  \"testPassed\": \"连接测试通过\",\n  \"testFailed\": \"连接测试未通过\",\n  \"testWarning\": \"建议检查配置后再添加\",\n  \"testRetry\": \"重试\",\n  \"addAnyway\": \"仍然添加\",\n  \"testStep\": {\n    \"models\": \"模型列表\",\n    \"textGen\": \"文本生成\",\n    \"imageGen\": \"图像生成\",\n    \"credits\": \"余额检查\",\n    \"audioGen\": \"音频生成\",\n    \"skipped\": \"已跳过\"\n  },\n  \"tabs\": {\n    \"llm\": \"文本模型\",\n    \"image\": \"图片模型\",\n    \"video\": \"视频模型\",\n    \"audio\": \"音频等模型\",\n    \"other\": \"其他\"\n  },\n  \"sections\": {\n    \"llmApiKeys\": \"文本模型 API Keys\",\n    \"imageApiKeys\": \"图片模型 API Keys\",\n    \"videoApiKeys\": \"视频模型 API Keys\",\n    \"audioApiKey\": \"音频模型 API Key\",\n    \"lipsyncApiKey\": \"唇形同步 API Key\"\n  },\n  \"defaultModel\": {\n    \"title\": \"默认模型\",\n    \"hint\": \"新建项目与资产库将使用此默认配置，也可在项目设置中为单独项目自定义模型\",\n    \"notSelected\": \"未选择\",\n    \"analysis\": \"分析模型\",\n    \"image\": \"图片生成\",\n    \"video\": \"视频生成\",\n    \"resolution\": \"图片分辨率\"\n  },\n  \"workflowConcurrency\": {\n    \"analysis\": \"分析流程并发\",\n    \"image\": \"图像流程并发\",\n    \"video\": \"视频流程并发\"\n  },\n  \"viewTutorial\": \"查看教程\",\n  \"tutorial\": {\n    \"button\": \"开通教程\",\n    \"title\": \"开通教程\",\n    \"subtitle\": \"按照以下步骤完成配置\",\n    \"close\": \"我知道了\",\n    \"openLink\": \"点击打开\",\n    \"steps\": {\n      \"ark_step1\": \"进入火山引擎控制台，开通 API Key\",\n      \"ark_step2\": \"在模型管理页面，点击右上角「一键开通所有模型」\",\n      \"openrouter_step1\": \"进入 OpenRouter 平台，创建 API Key（必须选择带有图像功能的模型）\",\n      \"fal_step1\": \"进入 FAL 平台，创建 API Key\",\n      \"google_step1\": \"进入 Google AI Studio，创建 API Key\",\n      \"minimax_step1\": \"进入海螺 MiniMax 平台，获取 API Key\",\n      \"vidu_step1\": \"进入生数科技 Vidu 平台，点击「新建 API Key」\",\n      \"openai_compatible_step1\": \"填写任意 OpenAI 协议兼容服务的 Base URL 与 API Key\",\n      \"gemini_compatible_step1\": \"填写任意 Gemini 协议兼容服务的 Base URL 与 API Key\",\n      \"bailian_step1\": \"进入阿里云百炼平台，获取 API Key\",\n      \"siliconflow_step1\": \"进入硅基流动控制台，创建 API Key\"\n    }\n  },\n  \"assistantOpen\": \"AI配置助手\",\n  \"assistantTitle\": \"AI 配置助手\",\n  \"assistantSubtitle\": \"通过对话把第三方文档转换为可执行图像/视频模板，并自动保存\",\n  \"assistantWelcome\": \"请描述你的接口文档（端点、请求体、返回字段）。我会追问缺失信息并自动保存模型。\",\n  \"assistantInputPlaceholder\": \"粘贴文档或描述接口细节...\",\n  \"assistantSend\": \"发送\",\n  \"assistantDisabledHint\": \"请先配置并保存 API Key 与 Base URL\",\n  \"assistantRequestFailed\": \"助手请求失败，请稍后重试\",\n  \"assistantResponseInvalid\": \"助手返回格式无效，请重试\",\n  \"assistantMissingTitle\": \"待补充字段\",\n  \"assistantWarningsTitle\": \"风险提示\",\n  \"assistantDraftTitle\": \"当前草稿模型\",\n  \"assistantReasoningTitle\": \"思考过程\",\n  \"assistantReasoningExpand\": \"点击展开\",\n  \"assistantReasoningCollapse\": \"点击收起\",\n  \"assistantCompletedTitle\": \"模型模板已保存\",\n  \"assistantCompletedMessage\": \"模型 {model} 已自动加入当前服务商。\\n请点击关闭结束本次对话。\",\n  \"you\": \"你\",\n  \"thinking\": \"思考中...\"\n}\n"
  },
  {
    "path": "messages/zh/apiTypes.json",
    "content": "{\n  \"image\": \"图片生成\",\n  \"video\": \"视频生成\",\n  \"text\": \"文本分析\",\n  \"tts\": \"语音合成\",\n  \"voice\": \"配音\",\n  \"voice_design\": \"声音设计\",\n  \"lip_sync\": \"口型同步\"\n}"
  },
  {
    "path": "messages/zh/assetHub.json",
    "content": "{\n    \"title\": \"资产中心\",\n    \"description\": \"管理您的全局角色和场景资产\",\n    \"modelHint\": \"资产中心使用默认模型，如需修改请在\",\n    \"modelHintLink\": \"API配置页面\",\n    \"modelHintSuffix\": \"进行设置\",\n    \"folders\": \"文件夹\",\n    \"noFolders\": \"暂无文件夹\",\n    \"allAssets\": \"所有资产\",\n    \"characters\": \"角色\",\n    \"locations\": \"场景\",\n    \"voices\": \"音色\",\n    \"addCharacter\": \"新建角色\",\n    \"addLocation\": \"新建场景\",\n    \"addVoice\": \"新建音色\",\n    \"downloadAll\": \"打包下载\",\n    \"downloadAllTitle\": \"下载全部图片资产\",\n    \"downloading\": \"打包中...\",\n    \"downloadSuccess\": \"下载完成\",\n    \"downloadFailed\": \"下载失败\",\n    \"downloadEmpty\": \"当前没有可下载的图片资产\",\n    \"newFolder\": \"新建文件夹\",\n    \"editFolder\": \"编辑文件夹\",\n    \"deleteFolder\": \"删除文件夹\",\n    \"folderName\": \"文件夹名称\",\n    \"folderNamePlaceholder\": \"请输入文件夹名称\",\n    \"emptyState\": \"暂无资产\",\n    \"emptyStateHint\": \"点击上方按钮添加角色或场景\",\n    \"generate\": \"生成图片\",\n    \"generating\": \"生成中...\",\n    \"regenerate\": \"重新生成\",\n    \"undo\": \"撤回\",\n    \"delete\": \"删除\",\n    \"cancel\": \"取消\",\n    \"save\": \"保存\",\n    \"create\": \"创建\",\n    \"confirmDeleteFolder\": \"确定删除此文件夹吗？文件夹内的资产将移至未分类。\",\n    \"confirmDeleteCharacter\": \"确定删除此角色吗？此操作无法撤回。\",\n    \"confirmDeleteLocation\": \"确定删除此场景吗？此操作无法撤回。\",\n    \"confirmDeleteVoice\": \"确定删除此音色吗？此操作无法撤回。\",\n    \"voiceName\": \"音色名称\",\n    \"voiceNamePlaceholder\": \"请输入音色名称\",\n    \"voiceNameRequired\": \"请输入音色名称\",\n    \"voicePickerTitle\": \"从音色库选择\",\n    \"voicePickerEmpty\": \"暂无音色，请先创建音色\",\n    \"voicePickerConfirm\": \"确认选择\",\n    \"pagination\": {\n        \"previous\": \"上一页\",\n        \"next\": \"下一页\"\n    },\n    \"common\": {\n        \"cancel\": \"取消\"\n    },\n    \"generateFailed\": \"生成失败\",\n    \"selectFailed\": \"选择失败\",\n    \"uploadFailed\": \"上传失败\",\n    \"editFailed\": \"编辑失败\",\n    \"saveVoiceFailed\": \"保存声音失败\",\n    \"saveVoiceFailedDetail\": \"保存声音失败: {error}\",\n    \"bindVoiceFailed\": \"绑定音色失败\",\n    \"bindVoiceFailedDetail\": \"绑定音色失败: {error}\",\n    \"voiceDesignSaved\": \"已为 {name} 设置 AI 设计的声音\",\n    \"appearanceLabel\": \"形象 {index}\",\n    \"voiceSettings\": {\n        \"title\": \"配音音色\",\n        \"noVoice\": \"无音色\",\n        \"previewFailed\": \"预览失败: {error}\",\n        \"uploadFailed\": \"上传音频失败: {error}\",\n        \"uploading\": \"上传中...\",\n        \"uploaded\": \"已上传\",\n        \"uploadAudio\": \"上传音频\",\n        \"aiDesign\": \"AI设计\",\n        \"voiceLibrary\": \"音色库\",\n        \"pause\": \"暂停\",\n        \"preview\": \"试听音色\"\n    },\n    \"modal\": {\n        \"newCharacter\": \"新建角色\",\n        \"confirm\": \"确定\",\n        \"processing\": \"处理中...\",\n        \"newLocation\": \"新建场景\",\n        \"addCharacter\": \"创建角色\",\n        \"addLocation\": \"创建场景\",\n        \"adding\": \"创建中...\",\n        \"aiDesign\": \"AI 设计\",\n        \"aiDesignPlaceholder\": \"例如：一个身穿红色长裙的古风美女，长发飘飘，手持折扇\",\n        \"aiDesignLocationPlaceholder\": \"例如：一个古典中式园林，有假山流水和亭台楼阁\",\n        \"aiDesignTip\": \"AI 将根据您的需求生成详细的描述，您可以在生成后编辑\",\n        \"aiDesignLocationTip\": \"AI 将根据您的需求生成详细的场景描述\",\n        \"generate\": \"生成\",\n        \"generating\": \"生成中...\",\n        \"nameLabel\": \"角色名称\",\n        \"namePlaceholder\": \"请输入角色名称\",\n        \"descLabel\": \"角色描述\",\n        \"descPlaceholder\": \"请描述角色的外形特征、服装、发型等...\",\n        \"locationNameLabel\": \"场景名称\",\n        \"locationNamePlaceholder\": \"请输入场景名称\",\n        \"locationSummaryLabel\": \"场景描述\",\n        \"locationSummaryPlaceholder\": \"请描述场景的环境、氛围、特征等...\",\n        \"referenceUpload\": \"上传参考图\",\n        \"referenceUploadTip\": \"上传一张角色图片，AI 将自动转换为三视图设定图\",\n        \"convertToCharacter\": \"转换三视图\",\n        \"converting\": \"转换中...\",\n        \"dropOrClick\": \"拖放图片或点击上传\",\n        \"supportedFormats\": \"支持 JPG、PNG 格式\"\n    }\n}"
  },
  {
    "path": "messages/zh/assetLibrary.json",
    "content": "{\n  \"title\": \"资产库\",\n  \"button\": \"资产库\",\n  \"characters\": \"角色\",\n  \"locations\": \"场景\",\n  \"noCharacters\": \"暂无角色\",\n  \"noLocations\": \"暂无场景\",\n  \"addCharacter\": \"添加角色\",\n  \"addLocation\": \"添加场景\",\n  \"generateImage\": \"生成图片\",\n  \"regenerateImage\": \"重新生成\",\n  \"analyzeAssets\": \"分析资产\",\n  \"analyzing\": \"分析中...\"\n}"
  },
  {
    "path": "messages/zh/assetModal.json",
    "content": "{\n    \"character\": {\n        \"title\": \"新建角色\",\n        \"name\": \"角色名称\",\n        \"namePlaceholder\": \"请输入角色名称\",\n        \"modeReference\": \"参考图模式\",\n        \"modeDescription\": \"描述模式\",\n        \"isSubAppearance\": \"这是一个子形象\",\n        \"isSubAppearanceHint\": \"为已有角色添加新的形象状态\",\n        \"uploadReference\": \"上传参考图\",\n        \"pasteHint\": \"Ctrl+V 粘贴\",\n        \"dropOrClick\": \"点击上传或拖拽图片\",\n        \"supportedFormats\": \"支持 JPG、PNG 等格式\",\n        \"nameRequired\": \"请先输入角色名称才能使用参考图转换\",\n        \"convertToSheet\": \"将参考图转换为标准三视图\",\n        \"useReferenceGeneratePrefix\": \"使用参考图生成\",\n        \"generateCountSuffix\": \"张图片\",\n        \"selectReferenceGenerateCount\": \"选择参考图生成数量\",\n        \"referenceTip\": \"上传任意角色图片，AI 将自动生成标准三视图设定\",\n        \"description\": \"角色描述\",\n        \"modifyDescription\": \"修改描述\",\n        \"descPlaceholder\": \"请输入角色外貌描述...\",\n        \"modifyDescriptionPlaceholder\": \"描述要对主形象做什么修改,例如:换上正装、受伤后的状态、披上斗篷...\",\n        \"selectMainCharacter\": \"选择主角色\",\n        \"selectCharacterPlaceholder\": \"请选择角色...\",\n        \"appearancesCount\": \"{count} 个形象\",\n        \"changeReason\": \"形象变化原因\",\n        \"changeReasonPlaceholder\": \"例如:战斗后受伤、穿上正装参加宴会...\",\n        \"defaultDescription\": \"{name} 的角色设定\",\n        \"generationMode\": \"生成方式\",\n        \"directGenerate\": \"直接生成\",\n        \"extractPrompt\": \"反推提示词\",\n        \"extractFirst\": \"先提取描述\",\n        \"directGenerateDesc\": \"直接使用参考图生成角色设定图（图生图）\",\n        \"extractPromptDesc\": \"先从图片提取角色描述，可编辑后再生成（文生图）\",\n        \"maxReferenceImages\": \"最多上传 5 张参考图\",\n        \"selectedCount\": \"已选择 {count}/5 张参考图\",\n        \"extractDescription\": \"提取角色描述\",\n        \"extracting\": \"提取中...\",\n        \"extractedDescription\": \"提取的角色描述（可编辑）\",\n        \"reExtract\": \"重新提取\",\n        \"editHint\": \"编辑后点击下方按钮生成角色设定图\",\n        \"generateFromDescription\": \"使用描述生成\",\n        \"textToImageTip\": \"文生图模式：基于提取的描述生成角色设定图\",\n        \"pleaseExtractFirst\": \"请先提取角色描述\"\n    },\n    \"location\": {\n        \"title\": \"新建场景\",\n        \"name\": \"场景名称\",\n        \"namePlaceholder\": \"请输入场景名称\",\n        \"description\": \"场景描述\",\n        \"descPlaceholder\": \"请输入场景描述...\"\n    },\n    \"artStyle\": {\n        \"title\": \"画面风格\"\n    },\n    \"aiDesign\": {\n        \"title\": \"AI 设计\",\n        \"placeholder\": \"描述你想要的角色特征...\",\n        \"placeholderLocation\": \"描述场景氛围和环境...\",\n        \"generating\": \"设计中...\",\n        \"generate\": \"生成\",\n        \"tip\": \"输入简单描述，AI 帮你生成详细设定\"\n    },\n    \"common\": {\n        \"creating\": \"创建中...\",\n        \"create\": \"创建\",\n        \"cancel\": \"取消\",\n        \"adding\": \"添加中...\",\n        \"add\": \"添加\",\n        \"addOnly\": \"仅添加角色\",\n        \"addOnlyToAssetHub\": \"仅添加人物到资产库\",\n        \"addOnlyLocation\": \"仅添加场景\",\n        \"addOnlyToAssetHubLocation\": \"仅添加场景到资产库\",\n        \"addAndGeneratePrefix\": \"添加并生成\",\n        \"generateCountSuffix\": \"张图片\",\n        \"selectGenerateCount\": \"选择生成数量\",\n        \"optional\": \"（可选）\"\n    },\n    \"errors\": {\n        \"uploadFailed\": \"上传失败\",\n        \"extractDescriptionFailed\": \"提取描述失败\",\n        \"createFailed\": \"创建失败\",\n        \"aiDesignFailed\": \"AI 设计失败\",\n        \"addSubAppearanceFailed\": \"添加子形象失败\",\n        \"insufficientBalance\": \"账户余额不足\"\n    }\n}\n"
  },
  {
    "path": "messages/zh/assetPicker.json",
    "content": "{\n    \"selectCharacter\": \"从资产中心选择角色\",\n    \"selectLocation\": \"从资产中心选择场景\",\n    \"selectVoice\": \"从资产中心选择音色\",\n    \"searchPlaceholder\": \"搜索资产名称或文件夹...\",\n    \"noAssets\": \"资产中心暂无资产\",\n    \"createInAssetHub\": \"请先在资产中心创建角色/场景/音色\",\n    \"noSearchResults\": \"未找到匹配的资产\",\n    \"appearances\": \"个形象\",\n    \"images\": \"张图片\",\n    \"cancel\": \"取消\",\n    \"confirmCopy\": \"确认复制\",\n    \"copyFromGlobal\": \"从资产中心复制\",\n    \"copySuccess\": \"复制成功\",\n    \"copyFailed\": \"复制失败\",\n    \"preview\": \"试听\",\n    \"stop\": \"停止\"\n}"
  },
  {
    "path": "messages/zh/assets.json",
    "content": "{\n    \"stage\": {\n        \"title\": \"资产确认\",\n        \"characters\": \"角色\",\n        \"locations\": \"场景\",\n        \"analyze\": \"分析资产\",\n        \"analyzing\": \"分析中...\",\n        \"generateAll\": \"批量生成全部\",\n        \"noCharacters\": \"暂无角色\",\n        \"noLocations\": \"暂无场景\",\n        \"confirmProfiles\": \"角色档案待确认\",\n        \"confirmHint\": \"请确认以下角色档案后生成外貌描述\",\n        \"confirmAll\": \"全部确认 ({count})\",\n        \"assetsTitle\": \"资产分析\",\n        \"characterAssets\": \"角色资产\",\n        \"locationAssets\": \"场景资产\",\n        \"counts\": \"{characterCount} 个角色，{appearanceCount} 个形象\",\n        \"locationCounts\": \"{count} 个场景\",\n        \"undoFailed\": \"撤回失败\",\n        \"undoFailedError\": \"撤回失败: {error}\",\n        \"undoSuccess\": \"已撤回到上一版本\",\n        \"editFailed\": \"编辑失败\",\n        \"editFailedError\": \"图片编辑失败: {error}\",\n        \"updateSuccess\": \"描述词已同步更新\"\n    },\n    \"character\": {\n        \"add\": \"添加角色\",\n        \"edit\": \"编辑角色\",\n        \"delete\": \"删除角色\",\n        \"deleteConfirm\": \"确定要删除这个角色吗？\",\n        \"deleteAppearanceConfirm\": \"确定要删除这个形象吗？\",\n        \"deleteFailed\": \"删除失败: {error}\",\n        \"deleteWhole\": \"删除整个角色\",\n        \"deleteOptions\": \"删除选项\",\n        \"name\": \"角色名\",\n        \"description\": \"外貌描述\",\n        \"generateImage\": \"生成形象\",\n        \"regenerateImage\": \"重新生成\",\n        \"generate\": \"生成\",\n        \"regenerating\": \"生成中...\",\n        \"profile\": \"设定档案\",\n        \"voiceSettings\": \"配音设置\",\n        \"speaker\": \"说话人\",\n        \"selectSpeaker\": \"选择说话人\",\n        \"noSpeaker\": \"未设置\",\n        \"primary\": \"主形象\",\n        \"secondary\": \"子状态\",\n        \"generateFromPrimary\": \"基于主形象生成\",\n        \"selectPrimaryFirst\": \"请先选择主形象\",\n        \"editing\": \"编辑中...\",\n        \"confirming\": \"确认中...\",\n        \"assetCount\": \"{count} 个形象\",\n        \"characterCount\": \"{count} 个角色\",\n        \"updateFailed\": \"更新描述失败\",\n        \"addFailed\": \"添加角色失败\",\n        \"copyFromGlobal\": \"从资产中心复制\"\n    },\n    \"location\": {\n        \"add\": \"添加场景\",\n        \"edit\": \"编辑场景\",\n        \"delete\": \"删除场景\",\n        \"deleteConfirm\": \"确定要删除这个场景吗？\",\n        \"deleteFailed\": \"删除失败: {error}\",\n        \"name\": \"场景名\",\n        \"summary\": \"简要描述\",\n        \"summaryPlaceholder\": \"场景用途/人物关联，如：张三居住的主卧室\",\n        \"description\": \"场景描述\",\n        \"generateImage\": \"生成图片\",\n        \"regenerateImage\": \"重新生成\",\n        \"updateFailed\": \"更新描述失败\",\n        \"addFailed\": \"添加场景失败\"\n    },\n    \"image\": {\n        \"upload\": \"上传图片\",\n        \"uploadReplace\": \"上传替换图片\",\n        \"uploadFailed\": \"上传失败\",\n        \"uploadFailedError\": \"上传失败: {error}\",\n        \"uploadSuccess\": \"上传成功！\",\n        \"edit\": \"编辑图片\",\n        \"editPrompt\": \"编辑提示词\",\n        \"undo\": \"撤回到上一版本\",\n        \"undoConfirm\": \"确定要撤回到上一版本吗？当前版本将被删除。\",\n        \"regenerateGroup\": \"整组重新生成\",\n        \"regenerateStuck\": \"点击重新生成（当前卡住）\",\n        \"selectCount\": \"选择生成数量\",\n        \"generateCountPrefix\": \"生成\",\n        \"generateCountSuffix\": \"张图像\",\n        \"regenCountPrefix\": \"重新生成\",\n        \"regenCountSuffix\": \"张\",\n        \"regenCountAriaLabel\": \"选择重新生成张数\",\n        \"generatedProgress\": \"已生成 {generated}/{total}\",\n        \"generating\": \"生成中\",\n        \"regenerating\": \"重新生成中\",\n        \"generatingPlaceholder\": \"待生成\",\n        \"selectTip\": \"选择并确认后，可对图片进行编辑和修改\",\n        \"selectFirst\": \"请选择一张图片\",\n        \"useThis\": \"选择此方案\",\n        \"optionAlt\": \"{name} - 方案{number}\",\n        \"optionNumber\": \"方案{number}\",\n        \"optionSelected\": \"已选择方案{number}\",\n        \"confirmOption\": \"确定选择方案{number}\",\n        \"deleteOthersHint\": \"（删除其他）\",\n        \"confirmSuccess\": \"确认成功\",\n        \"confirmFailed\": \"确认选择失败: {error}\",\n        \"selectFailed\": \"选择图片失败: {error}\",\n        \"cancelSelection\": \"取消选择\",\n        \"deleteThis\": \"删除此形象\",\n        \"undoFailed\": \"撤回失败\",\n        \"undoSuccess\": \"✓ 已撤回到上一版本\",\n        \"editFailed\": \"图片编辑失败\",\n        \"editSuccess\": \"图片编辑成功\",\n        \"regenerateFailed\": \"重新生成失败: {error}\"\n    },\n    \"modal\": {\n        \"newCharacter\": \"新增角色\",\n        \"addSubAppearance\": \"添加子形象\",\n        \"aiDesign\": \"AI智能设计\",\n        \"aiDesigning\": \"AI设计中...\",\n        \"designInstruction\": \"请输入设计指令\",\n        \"enterNameDesc\": \"请填写角色名称和描述\",\n        \"selectCharacter\": \"请选择一个角色\",\n        \"enterChangeReason\": \"请填写形象变化原因\",\n        \"enterSubDesc\": \"请填写形象描述\",\n        \"insufficientBalance\": \"余额不足\\n\\n{error}\",\n        \"designFailed\": \"AI设计失败: {error}\",\n        \"addFailed\": \"添加失败: {error}\",\n        \"aiDesignPlaceholderNew\": \"例如：一个20岁的女性魔法师，金色长发，蓝色眼睛，穿着紫色法袍...\",\n        \"aiDesignPlaceholderSub\": \"例如：换上黑色劲装，脚蹬厚底战靴，准备战斗的状态...\",\n        \"aiTipNew\": \"描述你想要的角色，AI会自动生成详细描述\",\n        \"aiTipSub\": \"描述角色的新状态，AI会生成子形象描述（只描述变化部分）\",\n        \"nameLabel\": \"角色名称\",\n        \"namePlaceholder\": \"输入角色名称...\",\n        \"descLabel\": \"形象描述\",\n        \"descPlaceholder\": \"输入角色形象描述...\",\n        \"selectLabel\": \"选择角色\",\n        \"selectPlaceholder\": \"-- 请选择角色 --\",\n        \"existingAppearances\": \"现有形象：\",\n        \"reasonLabel\": \"形象变化原因\",\n        \"reasonPlaceholder\": \"例如：换装后、受伤状态、出浴状态...\",\n        \"reasonTip\": \"简短描述这个形象与主形象的区别原因\",\n        \"subDescPlaceholder\": \"只描述变化部分，例如：换装，脚蹬厚底战靴...\",\n        \"subDescTip\": \"子形象只需描述变化部分（服装、状态等），面部和体型会自动继承主形象\",\n        \"adding\": \"添加中...\",\n        \"insufficientBalanceDefault\": \"账户余额不足，请充值后继续使用\",\n        \"addFailedGeneric\": \"添加失败\",\n        \"appearancesCount\": \"个形象\",\n        \"addCharacter\": \"新增角色\",\n        \"addLocation\": \"新增场景\",\n        \"aiDesignTip\": \"描述你想要的场景，AI会自动生成名称和详细描述\",\n        \"designing\": \"AI设计中...\",\n        \"saveName\": \"保存名字\",\n        \"saveOnly\": \"仅保存\",\n        \"sceneDescription\": \"场景描述\",\n        \"scenePrompt\": \"场景描述提示词\",\n        \"appearancePrompt\": \"形象描述提示词\",\n        \"smartModify\": \"智能修改\",\n        \"modifyPlaceholder\": \"例如：改成夜晚，添加月光，增加窗帘...\",\n        \"modifyPlaceholderCharacter\": \"例如：把头发改成金色、身高改为180cm、穿黑色西装...\",\n        \"modifying\": \"智能修改中...\",\n        \"modifyFailed\": \"修改失败\",\n        \"editCharacter\": \"编辑角色\",\n        \"editLocation\": \"编辑场景\",\n        \"saveAndGenerate\": \"保存并生成\",\n        \"generatingAutoClose\": \"正在生成图片，完成后将自动关闭...\",\n        \"aiLocationTip\": \"输入你想修改的内容，AI会自动调整场景描述\",\n        \"aiDesignPlaceholderLocation\": \"例如：一个古老的魔法图书馆，高耸的书架，昏暗的烛光，神秘的氛围...\",\n        \"artStyle\": \"画面风格\",\n        \"generate\": \"生成\",\n        \"introduction\": \"角色介绍\",\n        \"introductionPlaceholder\": \"例如：本书主角，第一人称'我'指的就是她。其他角色称她为'小雪'或'雪姐'...\",\n        \"introductionTip\": \"描述角色在故事中的身份、叙述视角（如'我'对应谁）、其他角色如何称呼她等\",\n        \"saveIntroduction\": \"保存介绍\"\n    },\n    \"toolbar\": {\n        \"filter\": \"筛选\",\n        \"viewAll\": \"查看全部\",\n        \"showGenerated\": \"已生成\",\n        \"showPending\": \"待生成\",\n        \"assetManagement\": \"资产管理\",\n        \"assetCount\": \"共 {total} 个资产（{appearances} 角色形象 + {locations} 场景）\",\n        \"globalAnalyze\": \"全局分析\",\n        \"globalAnalyzing\": \"正在执行全局资产分析...\",\n        \"globalAnalyzingHint\": \"请勿刷新页面，分析完成后将自动显示结果\",\n        \"globalAnalyzingTip\": \"正在分析所有剧集内容，提取角色和场景...\",\n        \"globalAnalyzeHint\": \"分析所有剧集内容，提取角色和场景\",\n        \"globalAnalyzeSuccess\": \"全局分析完成：新增 {characters} 个角色，{locations} 个场景\",\n        \"globalAnalyzeFailed\": \"全局分析失败\",\n        \"generateAll\": \"生成全部图片\",\n        \"generateAllNoop\": \"所有资产都已有图片，无需生成\",\n        \"generating\": \"生成中 ({current}/{total})\",\n        \"regenerateAll\": \"重新生成全部\",\n        \"regenerateAllConfirm\": \"确定要重新生成所有资产的图片吗？这将覆盖现有图片。\",\n        \"noAssetsToGenerate\": \"没有可生成的资产\",\n        \"regenerateAllHint\": \"重新生成所有资产图片（覆盖现有）\",\n        \"downloadAll\": \"打包下载全部图片\"\n    },\n    \"common\": {\n        \"actions\": \"操作\",\n        \"add\": \"添加\",\n        \"cancel\": \"取消\",\n        \"confirm\": \"确认\",\n        \"copy\": \"复制\",\n        \"delete\": \"删除\",\n        \"download\": \"下载\",\n        \"edit\": \"编辑\",\n        \"generate\": \"生成\",\n        \"generateFailed\": \"生成失败\",\n        \"loading\": \"加载中...\",\n        \"none\": \"无\",\n        \"preview\": \"预览\",\n        \"refresh\": \"刷新\",\n        \"regenerate\": \"重新生成\",\n        \"save\": \"保存\",\n        \"status\": \"状态\",\n        \"submitFailed\": \"提交失败\",\n        \"upload\": \"上传\",\n        \"unknownError\": \"未知错误\"\n    },\n    \"video\": {\n        \"panelCard\": {\n            \"generating\": \"生成中...\",\n            \"editPrompt\": \"编辑提示词\"\n        }\n    },\n    \"smartImport\": {\n        \"preview\": {\n            \"saving\": \"保存中...\"\n        }\n    },\n    \"storyboard\": {\n        \"group\": {\n            \"generating\": \"生成中...\"\n        }\n    },\n    \"errors\": {\n        \"saveFailed\": \"保存失败，请重试\",\n        \"failed\": \"失败，请重试\",\n        \"insufficientBalance\": \"账户余额不足\",\n        \"aiDesignFailed\": \"AI 设计失败\",\n        \"createFailed\": \"创建失败\"\n    },\n    \"assetLibrary\": {\n        \"button\": \"资产库\",\n        \"title\": \"资产库\",\n        \"copySuccessCharacter\": \"角色形象复制成功\",\n        \"copySuccessLocation\": \"场景图片复制成功\",\n        \"copySuccessVoice\": \"音色复制成功\",\n        \"copyFailed\": \"复制失败: {error}\",\n        \"downloadEmpty\": \"当前没有可下载的图片资产\",\n        \"downloadFailed\": \"打包下载失败\"\n    },\n    \"tts\": {\n        \"voiceDesignSaved\": \"已为 {name} 设置 AI 设计的声音\",\n        \"saveVoiceDesignFailed\": \"保存声音设计失败: {error}\",\n        \"title\": \"配音音色\",\n        \"noVoice\": \"无音色\",\n        \"previewFailed\": \"预览失败: {error}\",\n        \"uploadFailed\": \"上传音频失败: {error}\",\n        \"uploadQwenHint\": \"上传的音色后续只可使用 IndexTTS 合成，不可用于 QwenTTS。QwenTTS 必须使用 AI 设计音色。\",\n        \"uploading\": \"上传中...\",\n        \"uploaded\": \"已上传\",\n        \"uploadAudio\": \"上传音频\",\n        \"pause\": \"暂停\",\n        \"preview\": \"试听音色\"\n    },\n    \"characterProfile\": {\n        \"importance\": {\n            \"S\": \"S级 - 绝对主角\",\n            \"A\": \"A级 - 核心配角\",\n            \"B\": \"B级 - 重要配角\",\n            \"C\": \"C级 - 次要角色\",\n            \"D\": \"D级 - 群众演员\"\n        },\n        \"costumeLevel\": {\n            \"5\": \"皇室/顶奢级\",\n            \"4\": \"贵族/精英级\",\n            \"3\": \"专业/品质级\",\n            \"2\": \"日常/普通级\",\n            \"1\": \"朴素/统一级\"\n        },\n        \"importanceLevel\": \"角色重要性层级\",\n        \"characterArchetype\": \"角色原型\",\n        \"archetypePlaceholder\": \"如: 霸道总裁、心机婊\",\n        \"personalityTags\": \"性格标签\",\n        \"addTagPlaceholder\": \"添加标签\",\n        \"costumeLevelLabel\": \"服装华丽度\",\n        \"suggestedColors\": \"建议色彩\",\n        \"colorPlaceholder\": \"如: 深蓝、金色\",\n        \"primaryMarker\": \"主要辨识标志\",\n        \"markerNote\": \"(S/A级推荐)\",\n        \"markingsPlaceholder\": \"如: 眼角泪痣、左耳银色耳钉\",\n        \"visualKeywords\": \"视觉关键词\",\n        \"keywordsPlaceholder\": \"如: 精英气质、禁欲系\",\n        \"editDialogTitle\": \"编辑角色档案 - {name}\",\n        \"confirmAndGenerate\": \"确认并生成\",\n        \"useExisting\": \"使用已有形象\",\n        \"editProfile\": \"编辑档案\",\n        \"delete\": \"删除此角色\",\n        \"summary\": {\n            \"gender\": \"性别:\",\n            \"age\": \"年龄:\",\n            \"era\": \"时代:\",\n            \"class\": \"阶层:\",\n            \"occupation\": \"职业:\",\n            \"personality\": \"性格:\",\n            \"costume\": \"服装:\",\n            \"identifier\": \"标志:\"\n        },\n        \"parseFailed\": \"档案数据解析失败\",\n        \"confirmSuccessGenerating\": \"✓ 档案确认成功，正在生成视觉描述\",\n        \"confirmFailed\": \"确认失败: {error}\",\n        \"noPendingCharacters\": \"没有待确认的角色\",\n        \"batchConfirmPrompt\": \"确认为 {count} 个角色生成视觉描述吗？\",\n        \"batchConfirmSuccess\": \"✓ 已为 {count} 个角色生成视觉描述\",\n        \"batchConfirmFailed\": \"批量确认失败: {error}\",\n        \"deleteConfirm\": \"确定要删除此角色吗？此操作不可撤销。\",\n        \"deleteSuccess\": \"✓ 角色已删除\",\n        \"deleteFailed\": \"删除失败: {error}\"\n    },\n    \"imageEdit\": {\n        \"editCharacterImage\": \"编辑人物图片\",\n        \"editLocationImage\": \"编辑场景图片\",\n        \"characterLabel\": \"人物: {name}\",\n        \"locationLabel\": \"场景: {name}\",\n        \"editInstruction\": \"修改指令\",\n        \"subtitle\": \"输入修改指令，可选择上传参考图片\",\n        \"characterPlaceholder\": \"描述你想要修改的内容，例如：把头发改成金色、添加眼镜、换成休闲装...\",\n        \"locationPlaceholder\": \"描述你想要修改的内容，例如：添加更多树木、改成夜晚场景...\",\n        \"storyboardPlaceholder\": \"描述你想要修改的内容，例如：改变背景颜色、调整人物表情...\",\n        \"noAssetHint\": \"暂无资产，点击\\\"添加资产\\\"选择\",\n        \"referenceImages\": \"参考图片\",\n        \"referenceImagesHint\": \"(可选，支持粘贴)\",\n        \"startEditing\": \"开始编辑\"\n    }\n}"
  },
  {
    "path": "messages/zh/auth.json",
    "content": "{\n  \"welcomeBack\": \"欢迎回来\",\n  \"loginTo\": \"登录到waoowaoo\",\n  \"createAccount\": \"创建账户\",\n  \"joinPlatform\": \"加入waoowaoo\",\n  \"phoneNumber\": \"用户名\",\n  \"password\": \"密码\",\n  \"confirmPassword\": \"确认密码\",\n  \"phoneNumberPlaceholder\": \"请输入用户名\",\n  \"passwordPlaceholder\": \"请输入密码\",\n  \"passwordMinPlaceholder\": \"请输入密码（至少6位）\",\n  \"confirmPasswordPlaceholder\": \"请再次输入密码\",\n  \"loginButton\": \"登录\",\n  \"loginButtonLoading\": \"登录中...\",\n  \"signupButton\": \"注册\",\n  \"signupButtonLoading\": \"注册中...\",\n  \"noAccount\": \"还没有账户？\",\n  \"hasAccount\": \"已有账户？\",\n  \"signupNow\": \"立即注册\",\n  \"signinNow\": \"立即登录\",\n  \"backToHome\": \"← 返回首页\",\n  \"loginFailed\": \"登录失败，请检查手机号和密码\",\n  \"loginError\": \"登录过程中出现错误\",\n  \"passwordMismatch\": \"密码确认不匹配\",\n  \"passwordTooShort\": \"密码长度至少6位\",\n  \"signupSuccess\": \"注册成功！正在跳转到登录页面...\",\n  \"signupFailed\": \"注册失败\",\n  \"signupError\": \"注册过程中出现错误\",\n  \"rateLimited\": \"请求过于频繁，请稍后再试\",\n  \"passwordStrength\": {\n    \"weak\": \"密码强度：弱 — 建议使用更长的密码，混合大小写、数字和特殊字符\",\n    \"fair\": \"密码强度：一般 — 建议增加密码复杂度\",\n    \"good\": \"密码强度：良好\",\n    \"strong\": \"密码强度：强\"\n  }\n}"
  },
  {
    "path": "messages/zh/billing.json",
    "content": "{\n  \"transactionType\": \"交易类型\",\n  \"startDate\": \"开始日期\",\n  \"endDate\": \"结束日期\",\n  \"all\": \"全部\",\n  \"income\": \"收入\",\n  \"expense\": \"支出\",\n  \"reset\": \"重置\",\n  \"filter\": \"筛选\",\n  \"noRecords\": \"暂无记录\",\n  \"accountRecharge\": \"账户充值\",\n  \"serviceConsumption\": \"服务消费\",\n  \"balance\": \"余额\",\n  \"allTypes\": \"全部类型\"\n}"
  },
  {
    "path": "messages/zh/common.json",
    "content": "{\n    \"appName\": \"waoowaoo\",\n    \"betaVersion\": \"Beta v{version}\",\n    \"updateNotice\": {\n        \"title\": \"发现新版本\",\n        \"subtitle\": \"当前 v{current} · 最新 v{latest}\",\n        \"description\": \"软件已发布新版本，建议立刻前去升级。\",\n        \"releaseName\": \"版本\",\n        \"publishedAt\": \"发布时间\",\n        \"updateTag\": \"Update\",\n        \"viewRelease\": \"查看更新\",\n        \"remindLater\": \"稍后提醒\",\n        \"close\": \"关闭\",\n        \"openDialog\": \"打开更新弹窗\",\n        \"checkUpdate\": \"检查新版本\",\n        \"upToDate\": \"已是最新版本\"\n    },\n    \"loading\": \"加载中...\",\n    \"save\": \"保存\",\n    \"cancel\": \"取消\",\n    \"confirm\": \"确认\",\n    \"delete\": \"删除\",\n    \"edit\": \"编辑\",\n    \"search\": \"搜索\",\n    \"clear\": \"清除\",\n    \"close\": \"关闭\",\n    \"back\": \"返回\",\n    \"next\": \"下一步\",\n    \"previous\": \"上一步\",\n    \"submit\": \"提交\",\n    \"reset\": \"重置\",\n    \"generate\": \"生成\",\n    \"regenerate\": \"重新生成\",\n    \"preview\": \"预览\",\n    \"download\": \"下载\",\n    \"upload\": \"上传\",\n    \"select\": \"选择\",\n    \"add\": \"添加\",\n    \"remove\": \"移除\",\n    \"refresh\": \"刷新\",\n    \"expand\": \"展开\",\n    \"collapse\": \"收起\",\n    \"all\": \"全部\",\n    \"none\": \"无\",\n    \"success\": \"成功\",\n    \"error\": \"错误\",\n    \"warning\": \"警告\",\n    \"info\": \"信息\",\n    \"copy\": \"复制\",\n    \"paste\": \"粘贴\",\n    \"apply\": \"应用\",\n    \"autoSave\": \"自动保存\",\n    \"saved\": \"已保存\",\n    \"episode\": \"剧集\",\n    \"project\": \"项目\",\n    \"editEpisodeName\": \"编辑剧集名称\",\n    \"deleteEpisode\": \"删除剧集\",\n    \"deleteEpisodeConfirm\": \"确认删除\",\n    \"newEpisode\": \"新建剧集\",\n    \"optional\": \"（可选）\",\n    \"rename\": \"重命名\",\n    \"dragToReorder\": \"拖动调整位置\",\n    \"episodeNamePlaceholder\": \"输入剧集名称...\",\n    \"cancelSelection\": \"取消选择\",\n    \"referenceImage\": \"参考图\",\n    \"previewLarge\": \"预览大图\",\n    \"viewOriginal\": \"查看原图\",\n    \"schemeN\": \"方案 {n}\",\n    \"insufficientBalance\": \"余额不足\",\n    \"insufficientBalanceDetail\": \"账户余额不足，请充值后继续使用\",\n    \"operationFailed\": \"操作失败\",\n    \"pleaseRetry\": \"请重试\",\n    \"recommended\": \"推荐\",\n    \"language\": {\n        \"select\": \"选择语言\",\n        \"zh\": \"中文\",\n        \"en\": \"English\",\n        \"switchConfirmTitle\": \"切换语言？\",\n        \"switchConfirmMessage\": \"切换到 {targetLanguage} 后，不仅界面文字会改变，整条流程的提示词模板、剧本生成和任务输出语言也会同步切换。是否继续？\",\n        \"switchConfirmAction\": \"确认切换\"\n    },\n    \"taskStatus\": {\n        \"intent\": {\n            \"generate\": {\n                \"running\": {\n                    \"image\": \"生成中\",\n                    \"video\": \"生成中\",\n                    \"audio\": \"生成中\",\n                    \"text\": \"生成中\"\n                }\n            },\n            \"regenerate\": {\n                \"running\": {\n                    \"image\": \"重新生成中\",\n                    \"video\": \"重新生成中\",\n                    \"audio\": \"重新生成中\",\n                    \"text\": \"重新生成中\"\n                }\n            },\n            \"modify\": {\n                \"running\": {\n                    \"image\": \"修改中\",\n                    \"video\": \"修改中\",\n                    \"audio\": \"修改中\",\n                    \"text\": \"修改中\"\n                }\n            },\n            \"analyze\": {\n                \"running\": {\n                    \"image\": \"分析中\",\n                    \"video\": \"分析中\",\n                    \"audio\": \"分析中\",\n                    \"text\": \"分析中\"\n                }\n            },\n            \"build\": {\n                \"running\": {\n                    \"image\": \"构建中\",\n                    \"video\": \"构建中\",\n                    \"audio\": \"构建中\",\n                    \"text\": \"构建中\"\n                }\n            },\n            \"convert\": {\n                \"running\": {\n                    \"image\": \"转换中\",\n                    \"video\": \"转换中\",\n                    \"audio\": \"转换中\",\n                    \"text\": \"转换中\"\n                }\n            },\n            \"process\": {\n                \"running\": {\n                    \"image\": \"处理中\",\n                    \"video\": \"处理中\",\n                    \"audio\": \"处理中\",\n                    \"text\": \"处理中\"\n                }\n            }\n        },\n        \"failed\": {\n            \"image\": \"处理失败\",\n            \"video\": \"处理失败\",\n            \"audio\": \"处理失败\",\n            \"text\": \"处理失败\"\n        }\n    }\n}"
  },
  {
    "path": "messages/zh/configModal.json",
    "content": "{\n    \"title\": \"项目配置\",\n    \"subtitle\": \"默认沿用设置中心的全局配置，也可为当前项目单独自定义，修改仅对本项目生效。\",\n    \"saved\": \"已保存\",\n    \"autoSave\": \"自动保存\",\n    \"visualStyle\": \"视觉风格\",\n    \"modelParams\": \"模型参数\",\n    \"aspectRatio\": \"画面比例\",\n    \"ttsSettings\": \"旁白配置\",\n    \"loadingModels\": \"加载模型列表...\",\n    \"analysisModel\": \"分析模型\",\n    \"characterModel\": \"人物生成模型\",\n    \"locationModel\": \"场景生成模型\",\n    \"storyboardModel\": \"分镜图像模型\",\n    \"editModel\": \"修图/编辑模型\",\n    \"videoModel\": \"视频模型\",\n    \"audioModel\": \"语音合成模型\",\n    \"videoResolution\": \"视频分辨率\",\n    \"ttsVoice\": \"旁白音色\",\n    \"ttsRate\": \"语速\",\n    \"fetchModelsFailed\": \"获取用户模型列表失败\",\n    \"placeholder\": \"请输入...\",\n    \"description\": \"描述\",\n    \"hint\": \"提示\",\n    \"pleaseSelect\": \"请选择...\",\n    \"selectModel\": \"选择模型\",\n    \"paramConfig\": \"参数配置\",\n    \"fixed\": \"固定\",\n    \"noParams\": \"该模型无可配置参数\",\n    \"confirm\": \"确认\",\n    \"cancel\": \"取消\",\n    \"delete\": \"删除\",\n    \"boolOn\": \"开\",\n    \"boolOff\": \"关\"\n}\n"
  },
  {
    "path": "messages/zh/errors.json",
    "content": "{\n    \"UNAUTHORIZED\": \"请先登录\",\n    \"FORBIDDEN\": \"没有权限访问\",\n    \"NOT_FOUND\": \"资源不存在\",\n    \"INSUFFICIENT_BALANCE\": \"API余额不足，请充值后重试\",\n    \"RATE_LIMIT\": \"请求过于频繁，请 {retryAfter} 秒后重试\",\n    \"MODEL_NOT_OPEN\": \"模型权限未开通。请前往 https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model ，在模型管理页面点击右上角「一键开通所有模型」\",\n    \"MODEL_NOT_REGISTERED\": \"模型未注册，请先在配置中添加可用模型\",\n    \"MODEL_NOT_CONFIGURED\": \"未配置可用模型，请先前往「设置」页面添加对应类型的模型后再试\",\n    \"QUOTA_EXCEEDED\": \"配额已用尽，请稍后重试\",\n    \"GENERATION_FAILED\": \"生成失败，请重试\",\n    \"GENERATION_TIMEOUT\": \"生成超时，请重试\",\n    \"SENSITIVE_CONTENT\": \"内容可能包含敏感信息\",\n    \"INVALID_PARAMS\": \"参数错误\",\n    \"MISSING_CONFIG\": \"请先完成模型配置\",\n    \"INTERNAL_ERROR\": \"服务器错误，请稍后重试\",\n    \"NETWORK_ERROR\": \"网络错误，请检查连接\",\n    \"EMPTY_RESPONSE\": \"模型返回空响应（无有效内容），请稍后重试\",\n    \"EXTERNAL_ERROR\": \"外部服务暂时不可用，请稍后重试\",\n    \"TASK_NOT_READY\": \"任务正在处理中\",\n    \"NO_RESULT\": \"任务无结果\",\n    \"CONFLICT\": \"资源状态冲突\"\n}"
  },
  {
    "path": "messages/zh/landing.json",
    "content": "{\n  \"title\": \"waoowaoo\",\n  \"subtitle\": \"AI影视Studio\",\n  \"enterWorkspace\": \"进入工作区\",\n  \"loading\": \"加载中...\",\n  \"getStarted\": \"立即体验\",\n  \"learnMore\": \"了解更多\",\n  \"features\": {\n    \"title\": \"释放无限创造力\",\n    \"subtitle\": \"全流程 AI 辅助，从剧本到成片\",\n    \"character\": {\n      \"title\": \"角色工坊\",\n      \"description\": \"打造独一无二的动漫角色，保持高度一致性\"\n    },\n    \"storyboard\": {\n      \"title\": \"智能分镜\",\n      \"description\": \"文字一键转分镜，精准把控叙事节奏\"\n    },\n    \"world\": {\n      \"title\": \"世界构建\",\n      \"description\": \"沉浸式场景生成，构建宏大的故事背景\"\n    }\n  },\n  \"footer\": {\n    \"copyright\": \"2026 waoowaoo AI. All rights reserved.\"\n  }\n}"
  },
  {
    "path": "messages/zh/layout.json",
    "content": "{\n    \"title\": \"AI动漫制作工作台\",\n    \"description\": \"使用最先进的AI技术创建专业级动漫内容\"\n}\n"
  },
  {
    "path": "messages/zh/modelSection.json",
    "content": "{\n  \"llmModels\": \"文本模型列表\",\n  \"imageModels\": \"图片模型列表\",\n  \"videoModels\": \"视频模型列表\",\n  \"price\": \"价格\",\n  \"pricePerMillion\": \"每百万token\",\n  \"pricePerImage\": \"每张\",\n  \"pricePerVideo\": \"每条\",\n  \"name\": \"名称\",\n  \"modelId\": \"模型 ID\",\n  \"modelName\": \"模型名称\",\n  \"provider\": \"厂商\",\n  \"resolution\": \"分辨率\",\n  \"add\": \"添加\",\n  \"addModel\": \"添加模型\",\n  \"addNewModel\": \"添加新模型\",\n  \"selectPreset\": \"选择预设模型\",\n  \"customModel\": \"自定义模型\",\n  \"confirmAdd\": \"确认添加\",\n  \"cancel\": \"取消\",\n  \"done\": \"完成\",\n  \"fillComplete\": \"请填写完整信息\",\n  \"noModels\": \"暂无模型，点击上方按钮添加\",\n  \"noApiKey\": \"请配置API Key\",\n  \"batchMode\": \"批量\",\n  \"batchModeTooltip\": \"离线推理，价格便宜50%，24小时内完成\"\n}\n"
  },
  {
    "path": "messages/zh/nav.json",
    "content": "{\n  \"workspace\": \"工作区\",\n  \"assetHub\": \"资产中心\",\n  \"profile\": \"设置中心\",\n  \"downloadLogs\": \"下载日志\",\n  \"signin\": \"登录\",\n  \"signup\": \"注册\",\n  \"logout\": \"退出登录\"\n}\n"
  },
  {
    "path": "messages/zh/novel-promotion.json",
    "content": "{\n  \"stages\": {\n    \"story\": \"故事\",\n    \"script\": \"剧本\",\n    \"storyboard\": \"分镜\",\n    \"video\": \"成片\",\n    \"editor\": \"AI剪辑\",\n    \"editorComingSoon\": \"开发中，关注我们获取最新消息\"\n  },\n  \"buttons\": {\n    \"assetLibrary\": \"资产库\",\n    \"settings\": \"项目配置\",\n    \"refreshData\": \"刷新项目数据\",\n    \"enterVideoGeneration\": \"进入视频生成 →\"\n  },\n  \"smartImport\": {\n    \"title\": \"开启你的创作之旅\",\n    \"subtitle\": \"首先，选择你的创作方式\",\n    \"manualCreate\": {\n      \"title\": \"从第一集开始创作\",\n      \"description\": \"从第一集开始，适合边写边播或单集短视频制作\",\n      \"button\": \"开始创作\"\n    },\n    \"smartImport\": {\n      \"title\": \"智能全书导入\",\n      \"description\": \"上传整本小说或剧本，AI 引擎自动识别章节结构，一键完成智能分集。\",\n      \"button\": \"立即导入\",\n      \"recommended\": \"推荐\"\n    },\n    \"upload\": {\n      \"title\": \"上传原始素材\",\n      \"subtitle\": \"AI 引擎已准备就绪，一键自动分集与格式化\",\n      \"maxWords\": \"（最大支持 3 万字）\",\n      \"textInput\": \"输入文本内容\",\n      \"documentUpload\": \"上传完整文档\",\n      \"placeholder\": \"在此处粘贴你的小说章节或剧本内容...\",\n      \"filePlaceholder\": \"已上传文件模式\",\n      \"clickUpload\": \"点击上传文档\",\n      \"clearTextFirst\": \"请先清空左侧文本\",\n      \"supportedFormats\": \"支持 Word, TXT 格式\",\n      \"preview\": \"预览\",\n      \"expandPreview\": \"展开更多\",\n      \"collapsePreview\": \"收起预览\",\n      \"deleteFile\": \"删除文件\",\n      \"startAnalysis\": \"开始智能分析\",\n      \"back\": \"返回\",\n      \"words\": \"字\"\n    },\n    \"analyzing\": {\n      \"title\": \"AI 正在分析你的故事\",\n      \"description\": \"识别章节结构，智能分集中...\",\n      \"autoSave\": \"分析完成后将自动保存\"\n    },\n    \"preview\": {\n      \"title\": \"智能分集完成\",\n      \"episodeCount\": \"已为你自动分为 {count} 集\",\n      \"totalWords\": \"总计 {count} 字\",\n      \"autoSaved\": \"✓ 已自动保存\",\n      \"reanalyze\": \"重新分析\",\n      \"confirm\": \"确认完成\",\n      \"saving\": \"保存中...\",\n      \"episodeList\": \"剧集列表\",\n      \"addEpisode\": \"添加剧集\",\n      \"averageWords\": \"平均每集\",\n      \"episodeContent\": \"剧集内容\",\n      \"episodePlaceholder\": \"输入剧集标题...\",\n      \"summaryPlaceholder\": \"输入剧情简介...\",\n      \"newEpisode\": \"新剧集\",\n      \"deleteEpisode\": \"删除剧集\",\n      \"deleteConfirm\": {\n        \"title\": \"确认删除\",\n        \"message\": \"确定要删除「{title}」吗？\",\n        \"cancel\": \"取消\",\n        \"confirm\": \"确认删除\"\n      },\n      \"tip\": {\n        \"title\": \"提示\",\n        \"content\": \"你可以直接编辑标题、简介和内容。点击【确认完成】后，剧集将正式导入到项目中\"\n      }\n    },\n    \"errors\": {\n      \"fileTooLarge\": \"文件过大，请上传小于 10MB 的文件\",\n      \"docNotSupported\": \"不支持 .doc 格式，请使用 Word 转换为 .docx\",\n      \"fileEmpty\": \"文件内容为空\",\n      \"fileReadError\": \"文件读取失败，请重试\",\n      \"uploadFirst\": \"请先上传或粘贴内容\",\n      \"analyzeFailed\": \"分析失败\",\n      \"saveFailed\": \"保存失败\"\n    },\n    \"cancelConfirm\": \"确定要取消吗？已分析的剧集将被清空。\"\n  },\n  \"storyInput\": {\n    \"currentEditing\": \"当前正在编辑：{name}\",\n    \"editingTip\": \"以下制作流程仅针对本集,如有其他剧集请在左上角切换\",\n    \"wordCount\": \"字数：\",\n    \"assetLibraryTip\": {\n      \"title\": \"需要自定义角色和场景？\",\n      \"description\": \"点击右上角的「资产库」按钮，可以上传资产设定文档或手动添加角色/场景。AI 将优先使用资产库中的设定进行分析。\"\n    },\n    \"videoRatio\": \"画面比例\",\n    \"videoRatioHint\": \"选择合适的画面比例，可以更好适配投放平台和素材形态\",\n    \"ratioUsage\": {\n      \"1_1\": \"1:1：方形画面，适合头像、方图封面、社交平台通用封面\",\n      \"9_16\": \"9:16：竖屏视频，适合抖音、快手、视频号等短视频平台\",\n      \"16_9\": \"16:9：横屏视频，适合 B 站、YouTube 等长视频平台，以及电脑端播放\",\n      \"4_3\": \"4:3：传统电视比例，适合部分老素材或需要保守裁切的内容\",\n      \"3_4\": \"3:4：略偏竖的画面，适合图文 + 视频混排场景\",\n      \"2_3\": \"2:3：偏竖直画面，适合海报、人物立绘等内容\",\n      \"3_2\": \"3:2：略偏横的画面，适合风景、剧情类视频\",\n      \"4_5\": \"4:5：竖幅海报比例，适合社交平台信息流图片\",\n      \"5_4\": \"5:4：横幅海报比例，适合 PC 端 Banner 等\",\n      \"21_9\": \"21:9：超宽影院级画幅，适合电影感大片和全景镜头\"\n    },\n    \"ratioUsageTag\": {\n      \"1_1\": \"方形 · 头像/封面\",\n      \"9_16\": \"竖屏 · 短视频\",\n      \"16_9\": \"横屏 · 长视频\",\n      \"4_3\": \"横屏 · 传统电视\",\n      \"3_4\": \"竖屏 · 图文混排\",\n      \"2_3\": \"竖屏 · 海报/立绘\",\n      \"3_2\": \"横屏 · 风景/剧情\",\n      \"4_5\": \"竖屏 · 信息流图\",\n      \"5_4\": \"横屏 · Banner\",\n      \"21_9\": \"超宽 · 电影感\"\n    },\n    \"visualStyle\": \"视觉风格\",\n    \"visualStyleHint\": \"根据受众选择画面风格，例如：真人风格适合写实剧情，动漫风格适合二次元内容\",\n    \"currentConfigSummary\": \"当前配置：{ratio} · {style}，后续生成都会使用此组合\",\n    \"assetLibraryRatioNote\": \"资产库比例不受影响\",\n    \"moreConfig\": \"更多配置请点击右上角「 配置」按钮\",\n    \"narration\": {\n      \"title\": \"启用旁白配音\",\n      \"description\": \"生成 TTS 语音旁白，为视频添加解说\"\n    },\n    \"creating\": \"AI 创作中...\",\n    \"ready\": \"✓ 配置完成，可以进入下一步\",\n    \"pleaseInput\": \"请先输入剧本内容\"\n  },\n  \"execution\": {\n    \"selectEpisode\": \"请先选择剧集\",\n    \"fillContentFirst\": \"请先填写内容\",\n    \"requestAborted\": \"请求已中断（可能因页面刷新）\",\n    \"analysisFailed\": \"资产分析失败\",\n    \"prepareFailed\": \"准备失败\",\n    \"generationFailed\": \"生成失败\",\n    \"batchVideoFailed\": \"批量生成视频失败\",\n    \"updateFailed\": \"更新失败\",\n    \"saveFailed\": \"保存失败\",\n    \"storyToScriptRunning\": \"Story→Script V2 运行中\",\n    \"scriptToStoryboardRunning\": \"Script→Storyboard V2 运行中\",\n    \"storyToScriptFailed\": \"内容转剧本失败\",\n    \"scriptToStoryboardFailed\": \"剧本转分镜失败\",\n    \"taskStreamTimeout\": \"任务执行超时，请检查任务是否仍在运行，或重新触发\"\n  },\n  \"rebuildConfirm\": {\n    \"storyToScript\": {\n      \"title\": \"将重建剧本流程\",\n      \"message\": \"检测到当前剧集已有下游分镜数据（{storyboardCount} 个分镜，{panelCount} 个镜头面板）。继续执行将清空这些数据并重新生成，是否继续？\"\n    },\n    \"scriptToStoryboard\": {\n      \"title\": \"将重建分镜数据\",\n      \"message\": \"检测到当前剧集已有分镜数据（{storyboardCount} 个分镜，{panelCount} 个镜头面板）。继续执行将清空当前分镜并重新生成，是否继续？\"\n    },\n    \"confirm\": \"继续并清空\",\n    \"cancel\": \"取消\"\n  }\n}"
  },
  {
    "path": "messages/zh/profile.json",
    "content": "{\n    \"user\": \"用户\",\n    \"personalAccount\": \"个人账户\",\n    \"availableBalance\": \"可用余额\",\n    \"openSourceNoBilling\": \"开源版本，无需计费\",\n    \"frozen\": \"冻结\",\n    \"totalSpent\": \"已消费\",\n    \"apiConfig\": \"API 配置\",\n    \"rechargeRecords\": \"充值记录\",\n    \"billingRecords\": \"扣费记录\",\n    \"logout\": \"退出登录\",\n    \"downloadLogs\": \"下载日志\",\n    \"accountTransactions\": \"账户流水\",\n    \"projectDetails\": \"项目明细\",\n    \"summary\": \"汇总\",\n    \"transactions\": \"流水\",\n    \"noTransactions\": \"暂无流水记录\",\n    \"noProjectCosts\": \"暂无项目费用记录\",\n    \"noDetails\": \"该项目暂无费用明细\",\n    \"noRecords\": \"暂无记录\",\n    \"byType\": \"按类型\",\n    \"byAction\": \"按操作\",\n    \"times\": \"次\",\n    \"total\": \"总计\",\n    \"filter\": \"筛选\",\n    \"allTypes\": \"全部类型\",\n    \"recharge\": \"账户充值\",\n    \"consume\": \"服务消费\",\n    \"balanceAfter\": \"余额 {amount}\",\n    \"recordCount\": \"{count} 条记录\",\n    \"totalCost\": \"总计 {amount}\",\n    \"previousPage\": \"上一页\",\n    \"nextPage\": \"下一页\",\n    \"pagination\": \"共 {total} 条，第 {page} / {totalPages} 页\",\n    \"episodeLabel\": \"第 {number} 集\",\n    \"billingDetail\": {\n        \"imageWithRes\": \"{count}张 · {resolution}\",\n        \"image\": \"{count}张\",\n        \"videoWithRes\": \"{count}段 · {resolution}\",\n        \"video\": \"{count}段\",\n        \"tokens\": \"{count} tokens\",\n        \"seconds\": \"{count}秒\",\n        \"calls\": \"{count}次\"\n    },\n    \"apiTypes\": {\n        \"image\": \"图片生成\",\n        \"video\": \"视频生成\",\n        \"text\": \"文本分析\",\n        \"tts\": \"语音合成\",\n        \"voice\": \"配音\",\n        \"voice_design\": \"声音设计\",\n        \"lip_sync\": \"口型同步\"\n    },\n    \"actionTypes\": {\n        \"image_panel\": \"分镜生图\",\n        \"image_character\": \"角色生图\",\n        \"image_location\": \"场景生图\",\n        \"video_panel\": \"视频生成\",\n        \"lip_sync\": \"口型同步\",\n        \"voice_line\": \"配音合成\",\n        \"voice_design\": \"声音设计\",\n        \"asset_hub_voice_design\": \"素材库声音设计\",\n        \"regenerate_storyboard_text\": \"重生成分镜文案\",\n        \"insert_panel\": \"插入面板\",\n        \"panel_variant\": \"镜头变体\",\n        \"modify_asset_image\": \"修图\",\n        \"regenerate_group\": \"批量重生成\",\n        \"asset_hub_image\": \"素材库生图\",\n        \"asset_hub_modify\": \"素材库修图\",\n        \"analyze_novel\": \"小说分析\",\n        \"story_to_script_run\": \"故事转剧本\",\n        \"script_to_storyboard_run\": \"剧本转分镜\",\n        \"clips_build\": \"片段合成\",\n        \"screenplay_convert\": \"剧本转换\",\n        \"voice_analyze\": \"声音分析\",\n        \"analyze_global\": \"全局分析\",\n        \"ai_modify_appearance\": \"AI 修改角色形象\",\n        \"ai_modify_location\": \"AI 修改场景\",\n        \"ai_modify_shot_prompt\": \"AI 修改镜头提示词\",\n        \"analyze_shot_variants\": \"镜头变体分析\",\n        \"ai_create_character\": \"AI 创建角色\",\n        \"ai_create_location\": \"AI 创建场景\",\n        \"reference_to_character\": \"参考图转角色\",\n        \"character_profile_confirm\": \"确认角色档案\",\n        \"character_profile_batch_confirm\": \"批量确认角色档案\",\n        \"episode_split_llm\": \"章节拆分\",\n        \"asset_hub_ai_design_character\": \"素材库 AI 设计角色\",\n        \"asset_hub_ai_design_location\": \"素材库 AI 设计场景\",\n        \"asset_hub_ai_modify_character\": \"素材库 AI 修改角色\",\n        \"asset_hub_ai_modify_location\": \"素材库 AI 修改场景\",\n        \"asset_hub_reference_to_character\": \"素材库参考图转角色\",\n        \"storyboard\": \"分镜图\",\n        \"storyboard_candidate\": \"分镜候选\",\n        \"character\": \"角色图\",\n        \"location\": \"场景图\",\n        \"video\": \"视频\",\n        \"analyze\": \"分析\",\n        \"analyze_character\": \"角色分析\",\n        \"analyze_location\": \"场景分析\",\n        \"clips\": \"片段切割\",\n        \"storyboard_text_plan\": \"分镜规划\",\n        \"storyboard_text_detail\": \"分镜细节\",\n        \"tts\": \"语音合成\",\n        \"regenerate\": \"重生成\",\n        \"voice-generate\": \"配音生成\",\n        \"voice-design\": \"声音设计\",\n        \"lip-sync\": \"口型同步\"\n    }\n}"
  },
  {
    "path": "messages/zh/progress.json",
    "content": "{\n  \"analyzing\": \"正在分析故事结构...\",\n  \"splittingClips\": \"正在分割片段...\",\n  \"convertingScreenplay\": \"正在转换为分镜脚本...\",\n  \"submittingStoryboard\": \"正在提交分镜...\",\n  \"step\": \"第 {current} 步，共 {total} 步\",\n  \"status\": {\n    \"completed\": \"已完成\",\n    \"failed\": \"失败\",\n    \"processing\": \"进行中\",\n    \"queued\": \"排队中\",\n    \"pending\": \"未开始\"\n  },\n  \"stageCard\": {\n    \"stage\": \"阶段\",\n    \"realtimeStream\": \"实时流\",\n    \"currentStage\": \"当前阶段\",\n    \"outputTitle\": \"AI 实时输出 · {stage}\",\n    \"waitingModelOutput\": \"等待模型输出...\",\n    \"reasoningNotProvided\": \"该步骤未返回思考过程\"\n  },\n  \"runtime\": {\n    \"waitingExecution\": \"等待执行\",\n    \"taskCreated\": \"任务已创建\",\n    \"taskStarted\": \"任务开始处理\",\n    \"taskCompleted\": \"任务已完成\",\n    \"taskFailed\": \"任务失败\",\n    \"taskProcessing\": \"任务处理中...\",\n    \"llm\": {\n      \"processing\": \"模型处理中...\",\n      \"output\": \"模型正在输出...\",\n      \"reasoning\": \"模型正在推理...\",\n      \"completed\": \"模型输出完成\",\n      \"failed\": \"模型输出失败\"\n    },\n    \"stage\": {\n      \"llmSubmit\": \"模型请求提交中\",\n      \"llmStreaming\": \"模型流式输出中\",\n      \"llmFallbackNonStream\": \"模型降级为非流式模式\",\n      \"llmCompleted\": \"模型输出完成\",\n      \"llmFailed\": \"模型输出失败\"\n    }\n  },\n  \"taskType\": {\n    \"generic\": \"任务\",\n    \"imagePanel\": \"分镜图片\",\n    \"imageCharacter\": \"角色图片\",\n    \"imageLocation\": \"场景图片\",\n    \"videoPanel\": \"视频生成\",\n    \"lipSync\": \"口型同步\",\n    \"voiceLine\": \"配音生成\",\n    \"voiceDesign\": \"声音设计\",\n    \"assetHubVoiceDesign\": \"资产库声音设计\",\n    \"regenerateStoryboardText\": \"重生成分镜文本\",\n    \"insertPanel\": \"插入分镜\",\n    \"panelVariant\": \"分镜变体\",\n    \"modifyAssetImage\": \"图片编辑\",\n    \"regenerateGroup\": \"批量重生成\",\n    \"assetHubImage\": \"资产中心图片\",\n    \"assetHubModify\": \"资产中心编辑\",\n    \"analyzeNovel\": \"内容分析\",\n    \"storyToScriptRun\": \"剧本拆解\",\n    \"scriptToStoryboardRun\": \"分镜生成\",\n    \"clipsBuild\": \"片段生成\",\n    \"screenplayConvert\": \"剧本转换\",\n    \"voiceAnalyze\": \"台词分析\",\n    \"analyzeGlobal\": \"全局分析\",\n    \"aiModifyAppearance\": \"角色描述修改\",\n    \"aiModifyLocation\": \"场景描述修改\",\n    \"aiModifyShotPrompt\": \"镜头提示词修改\",\n    \"analyzeShotVariants\": \"镜头变体分析\",\n    \"aiCreateCharacter\": \"项目角色设计\",\n    \"aiCreateLocation\": \"项目场景设计\",\n    \"referenceToCharacter\": \"参考图转角色\",\n    \"characterProfileConfirm\": \"角色档案确认\",\n    \"characterProfileBatchConfirm\": \"批量角色档案确认\",\n    \"episodeSplitLlm\": \"智能分集\",\n    \"assetHubAiDesignCharacter\": \"资产库角色设计\",\n    \"assetHubAiDesignLocation\": \"资产库场景设计\",\n    \"assetHubAiModifyCharacter\": \"资产库角色修改\",\n    \"assetHubAiModifyLocation\": \"资产库场景修改\",\n    \"assetHubReferenceToCharacter\": \"资产库参考图转角色\"\n  },\n  \"stage\": {\n    \"received\": \"任务已接收\",\n    \"generateCharacterImage\": \"生成角色图片\",\n    \"generateLocationImage\": \"生成场景图片\",\n    \"generatePanelCandidate\": \"生成候选分镜图\",\n    \"generatePanelVideo\": \"生成分镜视频\",\n    \"generateVoiceSubmit\": \"提交配音任务\",\n    \"generateVoicePersist\": \"保存配音结果\",\n    \"voiceDesignSubmit\": \"提交声音设计任务\",\n    \"voiceDesignDone\": \"声音设计完成\",\n    \"submitLipSync\": \"提交口型同步任务\",\n    \"persistLipSync\": \"保存口型同步结果\",\n    \"storyboardClip\": \"生成片段分镜\",\n    \"regenerateStoryboardPrepare\": \"准备重生成分镜\",\n    \"regenerateStoryboardPersist\": \"保存重生成分镜\",\n    \"storyToScriptPrepare\": \"准备剧本拆解参数\",\n    \"storyToScriptStep\": \"执行剧本拆解步骤\",\n    \"storyToScriptPersist\": \"保存剧本拆解结果\",\n    \"storyToScriptPersistDone\": \"剧本拆解结果已保存\",\n    \"scriptToStoryboardPrepare\": \"准备分镜生成参数\",\n    \"scriptToStoryboardStep\": \"执行分镜生成步骤\",\n    \"scriptToStoryboardPersist\": \"保存分镜结果\",\n    \"scriptToStoryboardPersistDone\": \"分镜与台词结果已保存\",\n    \"insertPanelGenerateText\": \"生成插入镜头文本\",\n    \"insertPanelPersist\": \"保存插入镜头\",\n    \"pollingExternal\": \"等待外部服务返回\",\n    \"enqueueFailed\": \"任务入队失败\",\n    \"llmProxySubmit\": \"提交 LLM 任务\",\n    \"llmProxyExecute\": \"执行 LLM 任务\",\n    \"llmProxyPersist\": \"保存 LLM 结果\"\n  },\n  \"runConsole\": {\n    \"storyToScript\": \"内容到剧本\",\n    \"scriptToStoryboard\": \"剧本到分镜\",\n    \"storyToScriptRunning\": \"Story→Script 运行中\",\n    \"scriptToStoryboardRunning\": \"Script→Storyboard 运行中\",\n    \"storyToScriptSubtitle\": \"Story To Script V2\",\n    \"scriptToStoryboardSubtitle\": \"Script To Storyboard V2\",\n    \"stop\": \"停止\",\n    \"minimize\": \"最小化\"\n  },\n  \"streamStep\": {\n    \"analyzeCharacters\": \"角色分析\",\n    \"analyzeLocations\": \"场景分析\",\n    \"splitClips\": \"片段切分\",\n    \"screenplayConversion\": \"剧本转换\",\n    \"storyboardPlan\": \"分镜规划\",\n    \"cinematographyRules\": \"摄影规则生成\",\n    \"actingDirection\": \"演技指导生成\",\n    \"storyboardDetailRefine\": \"分镜细节补全\",\n    \"voiceAnalyze\": \"台词分析\"\n  }\n}\n"
  },
  {
    "path": "messages/zh/providerSection.json",
    "content": "{\n  \"addProvider\": \"+ 添加提供商\",\n  \"name\": \"名称\",\n  \"add\": \"添加\",\n  \"save\": \"保存\",\n  \"fillRequired\": \"请填写必要信息\"\n}"
  },
  {
    "path": "messages/zh/scriptView.json",
    "content": "{\n    \"title\": \"剧本视图\",\n    \"scriptBreakdown\": \"剧本拆解\",\n    \"splitCount\": \"已拆分 {count} 个分镜\",\n    \"noClips\": \"暂无分镜，请先在故事视图生成\",\n    \"segment\": {\n        \"title\": \"片段 {index}\",\n        \"selected\": \"(选中)\"\n    },\n    \"inSceneAssets\": \"剧中资产\",\n    \"currentSelected\": \"当前选中: 片段 {number}\",\n    \"assetView\": {\n        \"allClips\": \"全部片段\",\n        \"viewingClip\": \"查看片段 {number}\"\n    },\n    \"asset\": {\n        \"generateCharacter\": \"点击生成形象 →\",\n        \"generateLocation\": \"点击生成场景 →\",\n        \"removeCharacterConfirm\": \"确定要从当前片段移除该角色吗？\",\n        \"removeLocationConfirm\": \"确定要从当前片段移除该场景吗？\",\n        \"removeFromClip\": \"从当前片段移除\",\n        \"noAudio\": \"无音频\",\n        \"playing\": \"播放中\",\n        \"listen\": \"试听\",\n        \"activeCharacters\": \"出场角色\",\n        \"activeLocations\": \"出场场景\",\n        \"selectCharacter\": \"选择要添加的角色/形象\",\n        \"selectLocation\": \"选择要添加的场景\",\n        \"loadingAssets\": \"加载资产中...\",\n        \"appearanceCount\": \"{count} 个形象\",\n        \"added\": \"已添加\",\n        \"primary\": \"主形象\",\n        \"subAppearance\": \"子形象\",\n        \"defaultAppearance\": \"初始形象\",\n        \"clickToRemove\": \"点击移除 {name}\",\n        \"clickToAdd\": \"点击添加 {name}\"\n    },\n    \"screenplay\": {\n        \"scene\": \"场景 {number}\",\n        \"location\": \"场景：\",\n        \"locationTime\": \"时间：\",\n        \"day\": \"日\",\n        \"night\": \"夜\",\n        \"dawn\": \"晨\",\n        \"dusk\": \"昏\",\n        \"dialogue\": \"对白\",\n        \"action\": \"动作\",\n        \"narration\": \"旁白\",\n        \"content\": \"原文内容\",\n        \"noContent\": \"暂无内容\",\n        \"clickToEdit\": \"点击编辑\",\n        \"interior\": \"内景\",\n        \"exterior\": \"外景\",\n        \"characters\": \"出场角色\",\n        \"noCharacter\": \"暂无角色信息\",\n        \"noLocation\": \"暂无出场场景\",\n        \"noCharacterInClip\": \"暂无出场角色\"\n    },\n    \"confirm\": {\n        \"removeCharacter\": \"确定要从当前片段移除该角色吗？\",\n        \"removeLocation\": \"确定要从当前片段移除该场景吗？\"\n    },\n    \"generate\": {\n        \"missingAssets\": \"还有 {count} 个资产未生成形象\",\n        \"missingAssetsTip\": \"请先在\",\n        \"missingAssetsTipLink\": \"中生成所有出场角色和场景的形象\",\n        \"generating\": \"正在生成画面...\",\n        \"startGenerate\": \"确认并开始绘制 →\"\n    }\n}"
  },
  {
    "path": "messages/zh/smartImport.json",
    "content": "{\n  \"title\": \"开启你的创作之旅\",\n  \"subtitle\": \"首先，选择你的创作方式\",\n  \"manualCreate\": {\n    \"title\": \"从第一集开始创作\",\n    \"description\": \"从第一集开始，适合边写边播或单集短视频制作\",\n    \"button\": \"开始创作\"\n  },\n  \"manualDesc\": \"从第一集开始，适合边写边播或单集短视频制作\",\n  \"startCreate\": \"开始创作\",\n  \"smartImport\": {\n    \"title\": \"智能文本分集\",\n    \"description\": \"上传整本小说或剧本，AI 引擎自动识别章节结构，一键完成智能分集。\",\n    \"button\": \"立即导入\",\n    \"recommended\": \"推荐\"\n  },\n  \"markerDetection\": {\n    \"enable\": \"优先使用章节标记（如第X集/Episode X）\",\n    \"tooltip\": \"自动识别【第X集/章】【Episode/Chapter X】等标记，免费快速\"\n  },\n  \"smartImportDesc\": \"上传整本小说或剧本，AI 引擎自动识别章节结构，一键完成智能分集。\",\n  \"recommended\": \"推荐\",\n  \"importNow\": \"立即导入\",\n  \"uploadTitle\": \"上传原始素材\",\n  \"uploadSubtitle\": \"AI 引擎已准备就绪，一键自动分集与格式化\",\n  \"maxWords\": \"最大支持 3 万字\",\n  \"textInput\": \"输入文本内容\",\n  \"textPlaceholder\": \"在此处粘贴你的小说章节或剧本内容...\",\n  \"uploadDoc\": \"上传完整文档\",\n  \"clickUpload\": \"点击上传文档\",\n  \"clearText\": \"请先清空左侧文本\",\n  \"supportFormat\": \"支持 Word, TXT 格式\",\n  \"fileMax\": \"文件最大 3万字\",\n  \"words\": \"字\",\n  \"startAnalyzing\": \"开始分析\",\n  \"analyzing\": {\n    \"title\": \"AI 正在分析你的故事\",\n    \"description\": \"识别章节结构，智能分集中...\",\n    \"autoSave\": \"分析完成后将自动保存\"\n  },\n  \"analyzingDesc\": \"识别章节结构，智能分集中...\",\n  \"autoSave\": \"分析完成后将自动保存\",\n  \"splitComplete\": \"智能分集完成\",\n  \"splitResult\": \"已为你自动分为 {count} 集，总计 {words} 字\",\n  \"saved\": \"已自动保存\",\n  \"reAnalyze\": \"重新分析\",\n  \"confirmComplete\": \"确认完成\",\n  \"saving\": \"保存中...\",\n  \"episodeList\": \"剧集列表\",\n  \"episodes\": \"集\",\n  \"episode\": \"第 {num} 集\",\n  \"addEpisode\": \"添加剧集\",\n  \"newEpisode\": \"新剧集\",\n  \"avgWords\": \"平均每集\",\n  \"episodeContent\": \"剧集内容\",\n  \"plotSummary\": \"剧情简介\",\n  \"enterTitle\": \"输入剧集标题...\",\n  \"enterSummary\": \"输入剧情简介...\",\n  \"confirmDelete\": \"确认删除\",\n  \"deleteConfirmMsg\": \"确定要删除「{title}」吗？\",\n  \"preview\": {\n    \"title\": \"智能分集完成\",\n    \"episodeCount\": \"已为你自动分为 {count} 集\",\n    \"totalWords\": \"总计 {count} 字\",\n    \"autoSaved\": \"✓ 已自动保存\",\n    \"reanalyze\": \"重新分析\",\n    \"confirm\": \"确认完成\",\n    \"saving\": \"保存中...\",\n    \"episodeList\": \"剧集列表\",\n    \"addEpisode\": \"添加剧集\",\n    \"averageWords\": \"平均每集\",\n    \"episodeContent\": \"剧集内容\",\n    \"episodePlaceholder\": \"输入剧集标题...\",\n    \"summaryPlaceholder\": \"输入剧情简介...\",\n    \"newEpisode\": \"新剧集\",\n    \"deleteEpisode\": \"删除剧集\",\n    \"deleteConfirm\": {\n      \"title\": \"确认删除\",\n      \"message\": \"确定要删除「{title}」吗？\",\n      \"cancel\": \"取消\",\n      \"confirm\": \"确认删除\"\n    },\n    \"tip\": {\n      \"title\": \"提示\",\n      \"content\": \"你可以直接编辑标题、简介和内容。点击【确认完成】后，剧集将正式导入到项目中\"\n    }\n  },\n  \"collapsePreview\": \"收起预览\",\n  \"expandMore\": \"展开更多\",\n  \"deleteFile\": \"删除文件\",\n  \"fileTooLarge\": \"文件大小不能超过 10MB\",\n  \"docNotSupported\": \"暂不支持 .doc 格式，请将文件另存为 .docx 或 .txt 格式后重试\",\n  \"fileEmpty\": \"文件内容为空\",\n  \"fileReadError\": \"文件读取失败，请确保文件格式正确\",\n  \"uploadFirst\": \"请先上传文件或粘贴文本\",\n  \"analyzeFailed\": \"分析失败\",\n  \"saveFailed\": \"保存失败\",\n  \"cancelConfirm\": \"确定要取消吗？已分析的剧集将被清空。\",\n  \"deleteEpisode\": \"删除剧集\",\n  \"upload\": {\n    \"title\": \"上传原始素材\",\n    \"subtitle\": \"AI 引擎已准备就绪，一键自动分集与格式化\",\n    \"maxWords\": \"（最大支持 3 万字）\",\n    \"textInput\": \"输入文本内容\",\n    \"documentUpload\": \"上传完整文档\",\n    \"placeholder\": \"在此处粘贴你的小说章节或剧本内容...\",\n    \"filePlaceholder\": \"已上传文件模式\",\n    \"clickUpload\": \"点击上传文档\",\n    \"clearTextFirst\": \"请先清空左侧文本\",\n    \"supportedFormats\": \"支持 Word, TXT 格式\",\n    \"preview\": \"预览\",\n    \"expandPreview\": \"展开更多\",\n    \"collapsePreview\": \"收起预览\",\n    \"deleteFile\": \"删除文件\",\n    \"startAnalysis\": \"开始分析\",\n    \"back\": \"返回\",\n    \"words\": \"字\"\n  },\n  \"errors\": {\n    \"fileTooLarge\": \"文件过大，请上传小于 10MB 的文件\",\n    \"docNotSupported\": \"不支持 .doc 格式，请使用 Word 转换为 .docx\",\n    \"fileEmpty\": \"文件内容为空\",\n    \"fileReadError\": \"文件读取失败，请重试\",\n    \"uploadFirst\": \"请先上传或粘贴内容\",\n    \"analyzeFailed\": \"分析失败\",\n    \"saveFailed\": \"保存失败\",\n    \"analysisModelNotConfigured\": \"请先在设置页面配置分析模型\"\n  },\n  \"common\": {\n    \"edit\": \"编辑\",\n    \"delete\": \"删除\",\n    \"save\": \"保存\",\n    \"cancel\": \"取消\"\n  },\n  \"markerDetected\": {\n    \"title\": \"检测到分集标记\",\n    \"description\": \"检测到 {count} 个「{type}」格式的分集标记\",\n    \"preview\": \"预览分集结果\",\n    \"useMarker\": \"使用标识符分集\",\n    \"useMarkerDesc\": \"快速、免费\",\n    \"useAI\": \"使用 AI 智能分集\",\n    \"useAIDesc\": \"智能分析、消耗积分\",\n    \"cancel\": \"取消\",\n    \"totalCount\": \"共 {count} 集\",\n    \"markerTypes\": {\n      \"episode\": \"第X集\",\n      \"chapter\": \"第X章\",\n      \"act\": \"第X幕\",\n      \"scene\": \"X-Y【场景】\",\n      \"numbered\": \"数字编号\",\n      \"numberedEscaped\": \"数字编号(转义)\",\n      \"numberedDirect\": \"数字+中文\",\n      \"episodeEn\": \"Episode X\",\n      \"chapterEn\": \"Chapter X\",\n      \"boldNumber\": \"**数字**\",\n      \"pureNumber\": \"纯数字\"\n    }\n  },\n  \"globalAnalysis\": {\n    \"title\": \"全局资产分析\",\n    \"description\": \"一键提取全书的角色和场景，确保角色形象在所有剧集中保持一致\",\n    \"startButton\": \"立即分析\",\n    \"analyzing\": \"分析中...\",\n    \"success\": \"全局分析完成：新增 {characters} 个角色，{locations} 个场景\",\n    \"failed\": \"全局分析失败\",\n    \"confirmAndAnalyze\": \"确认并开启全局分析\"\n  }\n}"
  },
  {
    "path": "messages/zh/stages.json",
    "content": "{\n  \"config\": \"1. 配置\",\n  \"assets\": \"2. 资产分析\",\n  \"storyboard\": \"3. 分镜编辑\",\n  \"videos\": \"4. 视频生成\",\n  \"voice\": \"5. 配音生成\"\n}"
  },
  {
    "path": "messages/zh/storyboard.json",
    "content": "{\n    \"phases\": {\n        \"planning\": \"规划分镜\",\n        \"cinematography\": \"设计摄影\",\n        \"acting\": \"设计演技\",\n        \"detail\": \"补充细节\"\n    },\n    \"prompts\": {\n        \"imagePrompt\": \"图片提示词\",\n        \"aiInstruction\": \"AI修改指令\",\n        \"supportReference\": \"(支持@引用资产库)\",\n        \"instructionPlaceholder\": \"例如：把场景改为@医院_白天，人物改为@主角A\",\n        \"selectAsset\": \"选择资产\",\n        \"character\": \"人物\",\n        \"location\": \"场景\",\n        \"referencedAssets\": \"已引用资产：\",\n        \"removeAsset\": \"移除此资产\",\n        \"aiModify\": \"AI修改并生成图片\",\n        \"aiModifying\": \"AI修改中...\",\n        \"aiModifyTip\": \"点击后将自动保存提示词并生成新图片\",\n        \"save\": \"保存\",\n        \"currentPrompt\": \"当前提示词\",\n        \"enterInstruction\": \"请输入修改指令\",\n        \"modifyFailed\": \"操作失败: {error}\",\n        \"updateFailed\": \"更新失败: {error}\",\n        \"enterContinuation\": \"请输入续写内容\",\n        \"appendTitle\": \"续写内容\",\n        \"appendDescription\": \"输入新的SRT内容，系统会自动切分并生成新的镜头，追加到当前列表末尾\",\n        \"appendSubmit\": \"续写并生成镜头\",\n        \"appendSuccess\": \"续写成功！新镜头已追加到列表末尾\",\n        \"appendFailed\": \"续写失败: {error}\",\n        \"customStyle\": \"自定义风格\"\n    },\n    \"group\": {\n        \"generating\": \"生成中...\",\n        \"hasSynced\": \"✓ 已生成\",\n        \"failed\": \"失败\",\n        \"retry\": \"重试\",\n        \"regenerate\": \"重新生成所有\",\n        \"generateAll\": \"批量生成全部\",\n        \"expand\": \"展开\",\n        \"collapse\": \"收起\",\n        \"addPanel\": \"添加镜头\",\n        \"regenerating\": \"重新生成中...\",\n        \"aiAnalyzing\": \"AI 分析中...\",\n        \"regenerateText\": \"重新生成文字\",\n        \"generateMissingImages\": \"生成该片段所有未有图片的镜头\",\n        \"segment\": \"片段\",\n        \"addAtStart\": \"在开头添加新分镜组\",\n        \"insertHere\": \"在此插入新分镜组\"\n    },\n    \"header\": {\n        \"title\": \"分镜编辑\",\n        \"panels\": \"个镜头\",\n        \"submit\": \"提交生成\",\n        \"submitting\": \"提交中...\",\n        \"storyboardPanel\": \"分镜面板\",\n        \"segments\": \"个片段\",\n        \"segmentsCount\": \"共 {count} 个片段，\",\n        \"panelsCount\": \"{count} 个镜头\",\n        \"generatingStatus\": \"({count} 个生成中)\",\n        \"generateAllPanels\": \"生成所有镜头\",\n        \"generatePendingPanels\": \"生成{count}个未有图片的镜头\",\n        \"downloadAll\": \"下载全部\",\n        \"downloading\": \"打包中...\",\n        \"noImages\": \"没有可下载的图片\",\n        \"downloadAllImages\": \"下载所有图片\",\n        \"generateVideo\": \"生成视频 →\",\n        \"back\": \"← 返回\",\n        \"concurrencyLimit\": \"并发上限 {count}\"\n    },\n    \"panel\": {\n        \"shotType\": \"景别：\",\n        \"duration\": \"秒\",\n        \"location\": \"场景：\",\n        \"characters\": \"角色：\",\n        \"description\": \"描述：\",\n        \"text\": \"对应文本：\",\n        \"regenerate\": \"重新生成\",\n        \"delete\": \"删除\",\n        \"insertBefore\": \"在此前插入\",\n        \"insertAfter\": \"在此后插入\",\n        \"moveUp\": \"上移\",\n        \"moveDown\": \"下移\",\n        \"plot\": \"剧情：\",\n        \"summary\": \"总结：\",\n        \"pov\": \"视角：\",\n        \"focus\": \"焦点：\",\n        \"mode\": \"模式：\",\n        \"shot\": \"镜头\",\n        \"segment\": \"片段\",\n        \"stylePrompt\": \"画风/提示词\",\n        \"shotMode\": \"景别/模式\",\n        \"regenerateImage\": \"重新生成图片\",\n        \"generateImage\": \"生成图片\",\n        \"cardView\": \"卡片视图\",\n        \"tableView\": \"表格视图\",\n        \"shotTypeLabel\": \"镜头类型\",\n        \"cameraMove\": \"镜头运动\",\n        \"sourceText\": \"对应原文\",\n        \"sceneDescription\": \"画面描述\",\n        \"videoPrompt\": \"视频提示词\",\n        \"videoPromptHint\": \"建议描述主体动作、环境、镜头语言\",\n        \"locationLabel\": \"场景\",\n        \"editLocation\": \"编辑场景\",\n        \"characterLabel\": \"角色\",\n        \"characterLabelWithCount\": \"角色 ({count})\",\n        \"editCharacter\": \"编辑角色\",\n        \"select\": \"+ 选择\",\n        \"add\": \"+ 添加\",\n        \"noLocation\": \"未选择场景\",\n        \"locationNotEdited\": \"暂未编辑场景\",\n        \"noCharacters\": \"未选择角色\",\n        \"charactersNotEdited\": \"暂未编辑角色\",\n        \"shotTypePlaceholder\": \"俯拍中景...\",\n        \"cameraMovePlaceholder\": \"缓推、固定...\",\n        \"videoPromptPlaceholder\": \"用于视频生成的提示词...\",\n        \"sceneDescriptionPlaceholder\": \"描述画面主体、构图、光线、情绪\",\n        \"selectCharacter\": \"选择角色\",\n        \"selectLocation\": \"选择场景\",\n        \"noCharacterAssets\": \"暂无角色资产\",\n        \"noLocationAssets\": \"暂无场景资产\",\n        \"selected\": \"已选择\",\n        \"defaultAppearance\": \"初始形象\",\n        \"newPanelDescription\": \"新镜头描述\",\n        \"noShotType\": \"未设置镜头\"\n    },\n    \"image\": {\n        \"generating\": \"生成中...\",\n        \"regenerate\": \"重新生成\",\n        \"edit\": \"编辑\",\n        \"editImage\": \"修图\",\n        \"candidate\": \"候选图\",\n        \"selectCandidate\": \"选择候选\",\n        \"variants\": \"变体\",\n        \"generateVariants\": \"生成变体\",\n        \"forceRegenerate\": \"强制重新生成\",\n        \"failed\": \"生成失败\",\n        \"clickToPreview\": \"点击放大预览\",\n        \"enlargePreview\": \"放大预览\",\n        \"candidateCount\": \"候选 {count}\",\n        \"candidateGenerating\": \"{count} 张生成中\",\n        \"selectingCandidate\": \"选择候选图中...\",\n        \"confirmCandidate\": \"确认选择\",\n        \"cancelSelection\": \"取消选择\",\n        \"noValidCandidates\": \"暂无有效候选图\",\n        \"selectCount\": \"选择生成数量\",\n        \"generateMultiple\": \"生成多张候选\",\n        \"generateCount\": \"生成 {count} 张\",\n        \"generateCountSuffix\": \"张\",\n        \"undoShort\": \"返回\"\n    },\n    \"candidate\": {\n        \"title\": \"选择候选图\",\n        \"select\": \"选择\",\n        \"cancel\": \"取消\",\n        \"noImages\": \"暂无候选图\",\n        \"original\": \"原图\"\n    },\n    \"variant\": {\n        \"title\": \"图片变体\",\n        \"generate\": \"生成变体\",\n        \"select\": \"使用此图\",\n        \"close\": \"关闭\",\n        \"shotTitle\": \"镜头变体 - 基于 #{number}\",\n        \"originalDescription\": \"原镜头描述\",\n        \"noDescription\": \"无描述\",\n        \"noImage\": \"无图片\",\n        \"shotNum\": \"镜头 {number}\",\n        \"aiRecommend\": \"AI 推荐变体\",\n        \"reanalyze\": \"重新分析\",\n        \"shotType\": \"景别:\",\n        \"cameraMove\": \"运镜:\",\n        \"generating\": \"生成中\",\n        \"clickToAnalyze\": \"点击重新分析获取 AI 推荐\",\n        \"customInstruction\": \"或自定义指令\",\n        \"customPlaceholder\": \"输入你想要的镜头效果，如：改为反打视角，聚焦另一个角色的表情...\",\n        \"includeCharacter\": \"引用角色形象\",\n        \"includeLocation\": \"引用场景图\",\n        \"customVariant\": \"自定义变体\",\n        \"defaultShotType\": \"中景\",\n        \"defaultCameraMove\": \"固定\",\n        \"useCustomGenerate\": \"使用自定义生成\",\n        \"analyzeFailed\": \"分析失败\",\n        \"creativeScore\": \"创意 {score}/5\"\n    },\n    \"insert\": {\n        \"title\": \"插入新镜头\",\n        \"position\": \"插入位置\",\n        \"before\": \"在第 {number} 镜头前\",\n        \"after\": \"在第 {number} 镜头后\",\n        \"content\": \"镜头内容\",\n        \"shotType\": \"景别\",\n        \"location\": \"场景\",\n        \"characters\": \"角色\",\n        \"description\": \"描述\",\n        \"text\": \"对应文本\",\n        \"placeholder\": {\n            \"shotType\": \"选择景别...\",\n            \"location\": \"输入场景...\",\n            \"characters\": \"输入角色，用逗号分隔\",\n            \"description\": \"描述画面内容...\",\n            \"text\": \"对应的剧本文本...\"\n        },\n        \"insert\": \"插入\",\n        \"cancel\": \"取消\"\n    },\n    \"common\": {\n        \"actions\": \"操作\",\n        \"add\": \"添加\",\n        \"cancel\": \"取消\",\n        \"confirm\": \"确认\",\n        \"copy\": \"复制\",\n        \"delete\": \"删除\",\n        \"download\": \"下载\",\n        \"edit\": \"编辑\",\n        \"generate\": \"生成\",\n        \"loading\": \"加载中...\",\n        \"none\": \"无\",\n        \"unknownError\": \"未知错误\",\n        \"preview\": \"预览\",\n        \"refresh\": \"刷新\",\n        \"regenerate\": \"重新生成\",\n        \"deleting\": \"删除中\",\n        \"editing\": \"编辑中\",\n        \"saving\": \"保存中...\",\n        \"saveFailed\": \"保存失败，修改尚未同步\",\n        \"retrySave\": \"重试保存\",\n        \"save\": \"保存\",\n        \"status\": \"状态\",\n        \"submitFailed\": \"提交失败\",\n        \"upload\": \"上传\"\n    },\n    \"confirm\": {\n        \"deletePanel\": \"确定要删除这个镜头吗？删除后无法恢复。\",\n        \"deleteGroup\": \"确定要删除这整组分镜吗？\\n\\n这将删除该片段下的所有 {count} 个镜头，此操作不可撤销！\"\n    },\n    \"messages\": {\n        \"episodeNotFound\": \"没有找到剧集信息\",\n        \"downloadFailed\": \"下载失败: {error}\",\n        \"panelNotFound\": \"未找到镜头信息\",\n        \"modifyFailed\": \"修改失败: {error}\",\n        \"selectCandidateFailed\": \"选择失败: {error}\",\n        \"insertPanelFailed\": \"插入分镜失败: {error}\",\n        \"addPanelFailed\": \"添加分镜失败: {error}\",\n        \"deletePanelFailed\": \"删除失败: {error}\",\n        \"deleteGroupFailed\": \"删除分镜组失败: {error}\",\n        \"regenerateGroupFailed\": \"重新生成分镜失败: {error}\",\n        \"addGroupFailed\": \"添加分镜组失败: {error}\",\n        \"moveGroupFailed\": \"移动分镜组失败: {error}\",\n        \"batchGenerateCompleted\": \"批量生成完成:\\n成功: {succeeded}\\n失败: {failed}\\n\\n部分错误: {errors}\",\n        \"batchGenerateFailed\": \"批量生成失败: {error}\"\n    },\n    \"canvas\": {\n        \"emptyTitle\": \"暂无分镜数据\",\n        \"emptyDescription\": \"请先生成Clips和文字分镜，或点击上方按钮添加分镜组\"\n    },\n    \"imageEdit\": {\n        \"title\": \"编辑分镜\",\n        \"subtitle\": \"输入修改指令，可选择上传参考图片和资产\",\n        \"promptPlaceholder\": \"描述你想要修改的内容，例如：改变背景颜色、调整人物表情...\",\n        \"referenceImagesLabel\": \"参考图片\",\n        \"referenceImagesHint\": \"(可选，支持粘贴)\",\n        \"start\": \"开始编辑\",\n        \"selectAsset\": \"选择资产\",\n        \"selectedAssetsLabel\": \"参考资产\",\n        \"selectedAssetsCount\": \"{count}个\",\n        \"addAsset\": \"添加资产\",\n        \"noAssets\": \"暂无资产，点击“添加资产”选择\"\n    },\n    \"screenplay\": {\n        \"tabs\": {\n            \"formatted\": \"剧本格式\",\n            \"original\": \"原文\"\n        },\n        \"scene\": \"场景 {number}\",\n        \"characters\": \"出场角色\",\n        \"voiceover\": \"旁白\",\n        \"parseFailedTitle\": \"剧本格式解析失败\",\n        \"parseFailedDescription\": \"请查看原文内容\"\n    },\n    \"assets\": {\n        \"character\": {\n            \"confirming\": \"确认中...\",\n            \"editing\": \"编辑中...\"\n        },\n        \"image\": {\n            \"undo\": \"撤销到上一版本\"\n        },\n        \"location\": {\n            \"generateImage\": \"生成图片\"\n        },\n        \"stage\": {\n            \"analyzing\": \"分析中...\"\n        }\n    },\n    \"video\": {\n        \"toolbar\": {\n            \"showPending\": \"待生成\"\n        },\n        \"panelCard\": {\n            \"forceRegenerate\": \"强制重新生成（卡住时使用）\"\n        }\n    },\n    \"smartImport\": {\n        \"errors\": {\n            \"analyzeFailed\": \"分析失败\"\n        },\n        \"preview\": {\n            \"reanalyze\": \"重新分析\"\n        },\n        \"smartImport\": {\n            \"recommended\": \"推荐\"\n        }\n    },\n    \"aiData\": {\n        \"title\": \"AI数据编辑器\",\n        \"subtitle\": \"Panel {number} - 发送给图片生成AI的完整数据\",\n        \"basicData\": \"分镜基础数据\",\n        \"shotType\": \"镜头类型\",\n        \"cameraMove\": \"镜头运动\",\n        \"shotTypePlaceholder\": \"仰拍、全景、平视、中景...\",\n        \"cameraMovePlaceholder\": \"缓推、固定、跟随...\",\n        \"scene\": \"场景（只读）\",\n        \"notSelected\": \"未选择\",\n        \"summary\": \"场景总结\",\n        \"characters\": \"角色（只读）\",\n        \"plot\": \"剧情\",\n        \"summarize\": \"总结\",\n        \"visualDescription\": \"视觉描述\",\n        \"videoPrompt\": \"视频提示词\",\n        \"negativePrompt\": \"负面提示词\",\n        \"save\": \"保存\",\n        \"cancel\": \"取消\",\n        \"lightingDirection\": \"光照方向\",\n        \"lightingQuality\": \"光照质感\",\n        \"depthOfField\": \"景深\",\n        \"colorTone\": \"色调\",\n        \"characterPosition\": \"角色位置规则\",\n        \"position\": \"位置\",\n        \"posture\": \"姿势\",\n        \"facing\": \"朝向\",\n        \"photographyRules\": \"摄影规则 (photography_rules)\",\n        \"viewData\": \"查看数据\",\n        \"jsonPreview\": \"JSON 预览\",\n        \"actingNotes\": \"演技指导 (acting_notes)\",\n        \"actingTitle\": \"演技指导\",\n        \"actingDescription\": \"表演指令\",\n        \"noActingData\": \"无演技数据\"\n    },\n    \"insertModal\": {\n        \"insertBetween\": \"在 #{before} 和 #{after} 之间插入\",\n        \"panel\": \"镜头\",\n        \"noImage\": \"无图片\",\n        \"insertAtEnd\": \"末尾\",\n        \"aiAnalyze\": \"AI 自动分析\",\n        \"analyzing\": \"AI 分析中...\",\n        \"insert\": \"插入\",\n        \"inserting\": \"插入中...\",\n        \"placeholder\": \"可选：输入补充说明，如添加一个反应镜头...\"\n    },\n    \"panelActions\": {\n        \"insertPanel\": \"插入分镜\",\n        \"panelVariant\": \"镜头变体\",\n        \"insertHere\": \"在此处插入分镜\",\n        \"generateVariant\": \"基于此镜头生成变体\",\n        \"needImage\": \"需要先生成图片\",\n        \"deleteShot\": \"删除镜头\",\n        \"pasteSrtPlaceholder\": \"粘贴新的SRT内容...\"\n    },\n    \"firstLastFrame\": {\n        \"placeholder\": \"输入首尾帧视频提示词...\",\n        \"modelTitle\": \"首尾帧模型\"\n    }\n}\n"
  },
  {
    "path": "messages/zh/video.json",
    "content": "{\n  \"panelCard\": {\n    \"play\": \"播放\",\n    \"pause\": \"暂停\",\n    \"retry\": \"重试\",\n    \"regenerate\": \"重新生成\",\n    \"download\": \"下载\",\n    \"edit\": \"编辑\",\n    \"save\": \"保存\",\n    \"cancel\": \"取消\",\n    \"generating\": \"生成中...\",\n    \"failed\": \"失败\",\n    \"lipSync\": \"口型同步\",\n    \"lipSyncVideo\": \"口型同步视频\",\n    \"lipSyncLabel\": \"口型同步\",\n    \"lipSyncTitle\": \"口型同步\",\n    \"original\": \"原始\",\n    \"synced\": \"同步\",\n    \"videoFixed\": \"✓ 视频\",\n    \"imagePreview\": \"图片预览\",\n    \"playVoice\": \"播放配音\",\n    \"stopVoice\": \"停止\",\n    \"noVoice\": \"暂无配音\",\n    \"forceRegenerate\": \"强制重新生成（卡住时使用）\",\n    \"regenerateVideo\": \"重新生成视频\",\n    \"lipSyncStatus\": \"口型同步中...\",\n    \"lipSyncInProgress\": \"正在进行口型同步...\",\n    \"lipSyncMayTakeMinutes\": \"这可能需要几分钟时间\",\n    \"audioEnabled\": \"音频已开启\",\n    \"audioDisabled\": \"音频已关闭\",\n    \"isSynced\": \"(已同步)\",\n    \"needVideo\": \"(请先生成视频)\",\n    \"needAudio\": \"(请先生成音频)\",\n    \"generateAudio\": \"生成音频\",\n    \"regenerateLipSync\": \"重新生成口型同步\",\n    \"editPrompt\": \"编辑提示词\",\n    \"clickToEditPrompt\": \"点击编辑提示词...\",\n    \"shot\": \"镜头 {number}\",\n    \"unknownShotType\": \"未知景别\",\n    \"correspondingText\": \"对应原文\",\n    \"generateVideo\": \"生成视频\",\n    \"selectModel\": \"选择视频模型\",\n    \"selectVoice\": \"选择要使用的配音：\",\n    \"willAutoPad\": \"(将自动填充)\",\n    \"autoPadding\": \"填充\",\n    \"redo\": \"重新\",\n    \"generatingAudio\": \"生成中\",\n    \"error\": {\n      \"audioFailed\": \"生成音频失败\"\n    },\n    \"batchMode\": \"批量模式\",\n    \"batchModeDesc\": \"离线推理，价格便宜50%，24小时内完成\",\n    \"batchModeEnabled\": \"已开启批量模式\",\n    \"batchModeDisabled\": \"批量模式已关闭\"\n  },\n  \"promptModal\": {\n    \"title\": \"编辑镜头 #{number} 视频提示词\",\n    \"shotType\": \"景别：\",\n    \"duration\": \"秒\",\n    \"location\": \"场景：\",\n    \"locationUnknown\": \"未知\",\n    \"characters\": \"角色：\",\n    \"charactersNone\": \"无\",\n    \"description\": \"描述：\",\n    \"text\": \"对应文本：\",\n    \"promptLabel\": \"视频提示词\",\n    \"placeholder\": \"输入视频提示词...\",\n    \"tip\": \"提示：视频模型不识别角色名字，请用外貌特征描述，如\\\"黑发蓝眸的年轻男子\\\"而非\\\"Victor\\\"\",\n    \"save\": \"保存\",\n    \"cancel\": \"取消\"\n  },\n  \"toolbar\": {\n    \"title\": \"成片生成\",\n    \"filter\": \"筛选\",\n    \"viewAll\": \"查看全部\",\n    \"showGenerated\": \"已生成\",\n    \"showPending\": \"待生成\",\n    \"showFailed\": \"失败\",\n    \"totalShots\": \"共 {count} 个镜头\",\n    \"generatingShots\": \"{count} 个生成中\",\n    \"completedShots\": \"{count} 个已生成\",\n    \"failedShots\": \"{count} 个失败\",\n    \"generateAll\": \"生成所有视频\",\n    \"batchConfigTitle\": \"批量生成参数\",\n    \"batchConfigDesc\": \"先选择模型与参数，再一键生成所有视频\",\n    \"confirmGenerateAll\": \"确认并生成全部\",\n    \"confirming\": \"提交中...\",\n    \"noVideos\": \"没有可下载的视频\",\n    \"downloadCount\": \"下载 {count} 个视频\",\n    \"packing\": \"打包中...\",\n    \"downloadAll\": \"下载全部\",\n    \"enterEditor\": \"进入视频剪辑器\",\n    \"enterEdit\": \"进入剪辑\",\n    \"back\": \"返回\"\n  },\n  \"stage\": {\n    \"title\": \"视频生成\",\n    \"generateAll\": \"批量生成全部\",\n    \"regenerateFailed\": \"重试失败项\",\n    \"downloadAll\": \"下载全部视频\",\n    \"enterEditor\": \"进入剪辑器\",\n    \"lipSyncStatus\": \"口型同步中...\",\n    \"hasSynced\": \"✓ 已生成\",\n    \"generating\": \"生成中...\",\n    \"downloading\": \"下载中...\",\n    \"downloadProgress\": \"正在准备视频文件... {current}/{total}\",\n    \"noVideos\": \"暂无已生成的视频\",\n    \"scrollTo\": \"跳转到镜头\",\n    \"error\": {\n      \"saveFailed\": \"保存视频提示词失败\",\n      \"lipSyncFailed\": \"口型同步失败\",\n      \"fetchVideosFailed\": \"获取视频列表失败\"\n    },\n    \"downloadFailed\": \"下载失败\",\n    \"unknownError\": \"未知错误\"\n  },\n  \"firstLastFrame\": {\n    \"title\": \"首尾帧设置\",\n    \"firstFrame\": \"首帧\",\n    \"lastFrame\": \"尾帧\",\n    \"range\": \"镜头 {from} → 镜头 {to}\",\n    \"link\": \"链接\",\n    \"unlink\": \"解除链接\",\n    \"unlinkAction\": \"取消链接\",\n    \"asLastFrameFor\": \"作为镜头 {number} 的尾帧\",\n    \"asFirstFrameFor\": \"作为镜头 {number} 的首帧\",\n    \"customPrompt\": \"自定义提示词\",\n    \"promptPlaceholder\": \"输入首尾帧视频提示词...\",\n    \"useDefault\": \"使用默认\",\n    \"generate\": \"生成首尾帧视频\",\n    \"generated\": \"首尾帧视频已生成\",\n    \"model\": \"模型\",\n    \"withAudio\": \"包含音频\",\n    \"audioOn\": \"开\",\n    \"audioOff\": \"关\",\n    \"linkToNext\": \"链接到下一镜头（首尾帧）\",\n    \"asLastFrame\": \"作为镜头 {number} 的尾帧\",\n    \"thenTransitionTo\": \"然后镜头转换到\"\n  },\n  \"editor\": {\n    \"alert\": {\n      \"saveSuccess\": \"保存成功\",\n      \"saveFailed\": \"保存失败\",\n      \"exportStarted\": \"导出任务已开始，请稍候...\",\n      \"exportFailed\": \"导出失败\"\n    },\n    \"toolbar\": {\n      \"back\": \"← 返回\",\n      \"saveDirty\": \"保存 *\",\n      \"saved\": \"已保存\",\n      \"export\": \"导出视频\"\n    },\n    \"left\": {\n      \"title\": \"素材库\",\n      \"description\": \"从视频阶段导入的片段将显示在这里\"\n    },\n    \"right\": {\n      \"title\": \"属性\",\n      \"clipLabel\": \"片段:\",\n      \"clipFallback\": \"片段 {index}\",\n      \"durationLabel\": \"时长:\",\n      \"transitionLabel\": \"转场到下一片段\",\n      \"deleteConfirm\": \"确定删除此片段？\",\n      \"deleteClip\": \"删除片段\",\n      \"selectClipHint\": \"选择一个片段查看属性\"\n    },\n    \"preview\": {\n      \"emptyStartEditing\": \"添加素材开始编辑\"\n    },\n    \"timeline\": {\n      \"zoomLabel\": \"缩放:\",\n      \"videoTrack\": \"视频\",\n      \"emptyHint\": \"从素材库拖拽视频片段到这里\",\n      \"audioTrack\": \"配音\",\n      \"audioBadge\": \"配\"\n    },\n    \"transition\": {\n      \"title\": \"转场效果\",\n      \"duration\": \"持续时间\",\n      \"options\": {\n        \"none\": \"无\",\n        \"dissolve\": \"溶解\",\n        \"fade\": \"淡入淡出\",\n        \"slide\": \"滑动\"\n      }\n    }\n  },\n  \"errors\": {\n    \"unknownError\": \"未知错误\"\n  },\n  \"capability\": {\n    \"generationMode\": \"生成模式\",\n    \"generateAudio\": \"生成音频\",\n    \"duration\": \"时长\",\n    \"fps\": \"帧率\",\n    \"resolution\": \"分辨率\",\n    \"aspectRatio\": \"画幅比例\",\n    \"reasoningEffort\": \"推理强度\",\n    \"voice\": \"音色\",\n    \"rate\": \"语速\",\n    \"mode\": \"模式\"\n  },\n  \"unit\": {\n    \"second\": \"秒\",\n    \"frame\": \"帧\"\n  },\n  \"common\": {\n    \"generate\": \"生成\"\n  }\n}"
  },
  {
    "path": "messages/zh/voice.json",
    "content": "{\n    \"title\": \"台词配音\",\n    \"linesCount\": \"共 {count} 条台词，\",\n    \"audioGeneratedCount\": \"{count} 条已生成音频\",\n    \"emotionPrompt\": \"情绪提示词\",\n    \"emotionPromptTip\": \"(不填则使用台词自参考)\",\n    \"emotionPlaceholder\": \"如：laugh，仅支持英文...\",\n    \"emotionStrength\": \"情绪强度\",\n    \"flat\": \"平淡\",\n    \"intense\": \"强烈\",\n    \"generating\": \"生成中...\",\n    \"generateVoice\": \"生成语音\",\n    \"toolbar\": {\n        \"back\": \"← 返回\",\n        \"analyzeLines\": \"分析台词\",\n        \"addLine\": \"＋ 添加语音\",\n        \"generateAll\": \"一键生成所有配音\",\n        \"downloadAll\": \"下载配音\",\n        \"generatingCount\": \"生成中 ({count})\",\n        \"packing\": \"打包中...\",\n        \"stats\": \"共 {total} 条台词 | 已设置音色 {withVoice} 条 | 已生成配音 {withAudio} 条\",\n        \"noDownload\": \"没有可下载的配音\",\n        \"downloadCount\": \"下载 {count} 条配音\",\n        \"uploadReferenceHint\": \"请先在资产库为所有角色上传参考音频\"\n    },\n    \"speakerVoice\": {\n        \"title\": \"发言人音色状态\",\n        \"hint\": \"请在资产库为角色上传参考音频\",\n        \"linesCount\": \"{count} 条台词\",\n        \"noVoice\": \"无参考音色\",\n        \"configured\": \"✓ 已设置\",\n        \"playVoice\": \"播放当前音色\",\n        \"aiDesign\": \"AI设计声音\",\n        \"aiDesignVoice\": \"AI 设计音色\",\n        \"redesign\": \"使用 AI 重新设计声音\",\n        \"uploadAudio\": \"上传音频\",\n        \"uploading\": \"上传中\",\n        \"upload\": \"上传\",\n        \"microsoftVoice\": \"微软语音\",\n        \"microsoft\": \"微软\",\n        \"maleVoices\": \"男声\",\n        \"femaleVoices\": \"女声\",\n        \"openAssetLibrary\": \"资产库\",\n        \"configuredStatus\": \"已设置音色\",\n        \"pendingStatus\": \"待设置音色\",\n        \"voiceSettings\": \"音色设置\",\n        \"inlineLabel\": \"内联\"\n    },\n    \"inlineBinding\": {\n        \"title\": \"为「{speaker}」设置音色\",\n        \"description\": \"该发言人不在资产库中，请选择一种方式为其设置参考音色\",\n        \"selectFromLibrary\": \"从音色库选择\",\n        \"selectFromLibraryDesc\": \"选择已有的全局音色\",\n        \"uploadAudio\": \"上传参考音频\",\n        \"uploadAudioDesc\": \"上传 MP3、WAV 等音频文件作为参考音色\",\n        \"uploadQwenHint\": \"上传的音色后续只可使用 IndexTTS 合成，不可用于 QwenTTS。QwenTTS 必须使用 AI 设计音色。\",\n        \"aiDesign\": \"AI 设计音色\",\n        \"aiDesignDesc\": \"使用 AI 生成专属参考音色\"\n    },\n    \"embedded\": {\n        \"linesStats\": \"{total} 条台词 · {audio} 已生成\",\n        \"reanalyze\": \"重新分析\",\n        \"analyzeLines\": \"分析台词\",\n        \"reanalyzeHint\": \"重新分析台词并更新镜头匹配\",\n        \"analyzeHint\": \"从原文中提取台词\",\n        \"downloadVoice\": \"下载配音\",\n        \"generateAllVoice\": \"生成全部配音\",\n        \"pendingCount\": \"({count} 条待生成)\",\n        \"generatingProgress\": \"生成中 ({current}/{total})\",\n        \"generatingHint\": \"正在生成中...\",\n        \"noVoiceHint\": \"请先在上方为所有角色设置音色\",\n        \"noLinesHint\": \"没有台词可生成\",\n        \"allDoneHint\": \"所有台词已生成完成\",\n        \"generateHint\": \"点击生成 {count} 条待生成的配音\",\n        \"addLine\": \"＋ 添加语音\",\n        \"speakerVoiceStatus\": \"角色音色状态\",\n        \"speakersCount\": \"{count} 个\",\n        \"listen\": \"试听\",\n        \"listenVoice\": \"试听音色\",\n        \"reset\": \"重设\",\n        \"resetDesign\": \"重新设计\",\n        \"aiDesign\": \"AI设计\",\n        \"assetLibrary\": \"资产库\"\n    },\n    \"lineCard\": {\n        \"generatingVoice\": \"生成中\",\n        \"speaker\": \"发言人\",\n        \"speakerPlaceholder\": \"发言人名称\",\n        \"content\": \"台词内容\",\n        \"contentPlaceholder\": \"台词内容\",\n        \"emotionConfigured\": \"情绪已设置\",\n        \"emotionSettings\": \"情绪设置\",\n        \"voiceConfigured\": \"✓ 已设置\",\n        \"needVoice\": \"请在上方设置音色\",\n        \"locatePanel\": \"定位到绑定镜头\",\n        \"locateVideo\": \"定位视频\",\n        \"play\": \"播放\",\n        \"pause\": \"暂停\",\n        \"locatePanelCta\": \"定位到镜头 {index}\",\n        \"editLine\": \"编辑台词\",\n        \"deleteLine\": \"删除台词\",\n        \"deleteAudio\": \"删除配音\"\n    },\n    \"lineEditor\": {\n        \"addTitle\": \"添加语音\",\n        \"editTitle\": \"编辑语音\",\n        \"contentLabel\": \"台词内容\",\n        \"contentPlaceholder\": \"请输入台词内容\",\n        \"speakerLabel\": \"发言人\",\n        \"speakerPlaceholder\": \"请输入发言人名称\",\n        \"selectSpeaker\": \"请选择发言人\",\n        \"noSpeakerOptions\": \"当前项目暂无可选发言人，请先分析台词生成发言人\",\n        \"bindPanelLabel\": \"绑定镜头\",\n        \"unboundPanel\": \"未绑定镜头\",\n        \"panelLabel\": \"镜头 {index}\",\n        \"saveAdd\": \"添加语音\",\n        \"saveEdit\": \"保存修改\"\n    },\n    \"empty\": {\n        \"title\": \"暂无台词数据\",\n        \"description\": \"从剧本中提取台词和发言人\",\n        \"analyzeButton\": \"分析台词\",\n        \"hint\": \"请先在资产库为角色上传参考音频\"\n    },\n    \"confirm\": {\n        \"deleteLine\": \"确定要删除这条台词吗？\\n\\n\\\"{content}\\\"\\n\\n此操作不可撤销。\",\n        \"deleteAudio\": \"确定要删除这条台词的配音吗？\\n\\n\\\"{content}\\\"\\n\\n此操作不可撤销。\"\n    },\n    \"errors\": {\n        \"saveFailed\": \"保存失败\",\n        \"analyzeFailed\": \"分析台词失败\",\n        \"generateFailed\": \"生成配音失败\",\n        \"batchFailed\": \"批量生成失败\",\n        \"downloadFailed\": \"下载失败\",\n        \"deleteFailed\": \"删除失败\",\n        \"addFailed\": \"添加语音失败\",\n        \"invalidLineInput\": \"台词内容和发言人不能为空\",\n        \"bindFailed\": \"绑定镜头失败\",\n        \"deleteAudioFailed\": \"删除配音失败\",\n        \"uploadFailed\": \"上传音频失败\",\n        \"voiceDesignFailed\": \"保存声音设计失败\",\n        \"emotionSaveFailed\": \"保存情绪设置失败\",\n        \"voiceGenerateFailed\": \"生成音频失败\"\n    },\n    \"alerts\": {\n        \"insufficientBalance\": \"余额不足\",\n        \"insufficientBalanceMsg\": \"账户余额不足，请充值后继续使用\",\n        \"noLinesToGenerate\": \"没有需要生成的台词（请先为角色上传参考音频）\",\n        \"generateComplete\": \"生成完成：{success}/{total} 条成功\",\n        \"generateFailed\": \"{count} 条失败\",\n        \"speakerVoiceSet\": \"已为 {speaker} 生成参考音频\",\n        \"speakerVoiceUploaded\": \"已为 {speaker} 上传参考音频\",\n        \"voiceDesignSet\": \"已为 {speaker} 设置 AI 设计的声音\"\n    },\n    \"common\": {\n        \"loading\": \"加载中...\",\n        \"save\": \"保存\",\n        \"cancel\": \"取消\",\n        \"cancelling\": \"取消中...\",\n        \"upload\": \"上传\",\n        \"download\": \"下载\",\n        \"generate\": \"生成\",\n        \"regenerate\": \"重新生成\"\n    },\n    \"assets\": {\n        \"image\": {\n            \"uploadFailed\": \"上传失败\"\n        },\n        \"stage\": {\n            \"analyzing\": \"分析中...\"\n        }\n    },\n    \"smartImport\": {\n        \"errors\": {\n            \"analyzeFailed\": \"分析失败\"\n        }\n    },\n    \"video\": {\n        \"panelCard\": {\n            \"play\": \"播放\"\n        }\n    },\n    \"tts\": {\n        \"generatedAudio\": \"生成的音频\",\n        \"browserNotSupport\": \"您的浏览器不支持音频播放\",\n        \"audioDuration\": \"音频时长：\",\n        \"subtitleCount\": \"字幕条数：\",\n        \"noAudio\": \"暂无音频\",\n        \"srtPreview\": \"SRT字幕预览\",\n        \"noSubtitle\": \"暂无字幕\",\n        \"stats\": \"生成统计\",\n        \"minute\": \"分\",\n        \"second\": \"秒\",\n        \"items\": \"条\",\n        \"completed\": \"✓ 已完成\",\n        \"regenerating\": \"重新生成中...\",\n        \"regenerateTTS\": \"重新生成TTS\",\n        \"nextStep\": \"下一步: 分析资产\",\n        \"readyTip\": \"点击进入资产分析阶段\",\n        \"needGenerate\": \"请先生成TTS音频\"\n    },\n    \"voiceCreate\": {\n        \"aiDesignMode\": \"AI 设计音色\",\n        \"uploadMode\": \"上传音频\",\n        \"dropOrClick\": \"拖放文件或点击选择\",\n        \"supportedFormats\": \"支持 MP3、WAV、OGG、M4A、AAC 格式\",\n        \"invalidFileType\": \"不支持的文件格式，请上传音频文件\",\n        \"fileTooLarge\": \"文件过大，最大支持 50MB\",\n        \"previewAudio\": \"试听音频\",\n        \"uploading\": \"上传中...\",\n        \"uploadFailed\": \"上传失败\",\n        \"uploadSuccess\": \"上传成功\"\n    },\n    \"voiceDesign\": {\n        \"presets\": {\n            \"maleBroadcaster\": \"男播音\",\n            \"gentleFemale\": \"温柔女\",\n            \"matureMale\": \"成熟男\",\n            \"livelyFemale\": \"活泼女\",\n            \"intellectualFemale\": \"知性女\",\n            \"narrator\": \"旁白\"\n        },\n        \"presetsPrompts\": {\n            \"maleBroadcaster\": \"沉稳的中年男性播音员，音色低沉浑厚，语速平稳，吐字清晰\",\n            \"gentleFemale\": \"温柔甜美的年轻女性，声音清脆悦耳，语调轻柔\",\n            \"matureMale\": \"成熟稳重的男性，声音富有磁性和感染力\",\n            \"livelyFemale\": \"活泼开朗的少女，声音甜美可爱，充满活力\",\n            \"intellectualFemale\": \"知性优雅的女性，声音清晰悦耳，语调平和\",\n            \"narrator\": \"富有感情的叙述者，声音温暖有故事感\"\n        },\n        \"defaultPreviewText\": \"你好，很高兴认识你。这是AI为你专属设计的声音，让我来为你展示它的特点。无论是温柔的对话，还是激动的讲述，我都能完美呈现。希望你喜欢这个声音，让我们一起创造精彩的内容吧。\",\n        \"pleaseSelectStyle\": \"请输入或选择声音风格\",\n        \"designVoiceFor\": \"为「{speaker}」设计AI声音\",\n        \"hasExistingVoice\": \"已有声音\",\n        \"selectStyle\": \"选择声音风格：\",\n        \"orCustomDescription\": \"或自定义描述：\",\n        \"describePlaceholder\": \"描述声音特征：年龄、性别、音色、语调...\",\n        \"generateSchemesPrefix\": \"生成\",\n        \"generateSchemesSuffix\": \"个声音方案\",\n        \"schemeCountAriaLabel\": \"生成声音方案数量\",\n        \"editPreviewText\": \"修改预览文本\",\n        \"generating3Schemes\": \"正在生成 3 个声音方案...\",\n        \"estimatedTime\": \"预计 15-30 秒\",\n        \"selectScheme\": \"选择声音方案：\",\n        \"schemeN\": \"方案 {n}\",\n        \"regenerate\": \"重新生成\",\n        \"confirmUse\": \"✓ 确认使用\",\n        \"confirmReplace\": \"确认替换声音？\",\n        \"replaceWarning\": \"的原有声音，不可撤回\",\n        \"confirmReplaceBtn\": \"确认替换\",\n        \"noVoiceGenerated\": \"未能生成任何声音\",\n        \"generationError\": \"生成声音失败\",\n        \"generateFailed\": \"生成第 {n} 个声音失败\",\n        \"preview\": \"试听\",\n        \"playing\": \"播放中\"\n    }\n}\n"
  },
  {
    "path": "messages/zh/workspace.json",
    "content": "{\n  \"title\": \"我的项目\",\n  \"subtitle\": \"管理您的AI动漫制作项目\",\n  \"newProject\": \"新建项目\",\n  \"searchPlaceholder\": \"搜索项目名称或描述...\",\n  \"searchButton\": \"搜索\",\n  \"clearButton\": \"清除\",\n  \"updatedAt\": \"更新于\",\n  \"noProjects\": \"还没有项目\",\n  \"noProjectsDesc\": \"创建您的第一个AI动漫制作项目\",\n  \"noResults\": \"没有找到匹配的项目\",\n  \"noResultsDesc\": \"尝试使用不同的搜索词\",\n  \"createProject\": \"新建项目\",\n  \"editProject\": \"编辑项目\",\n  \"deleteProject\": \"删除项目\",\n  \"deleteConfirm\": \"确定要删除项目\\\"{name}\\\"吗？此操作无法撤销。\",\n  \"projectName\": \"项目名称\",\n  \"projectNamePlaceholder\": \"输入项目名称\",\n  \"projectDescription\": \"项目描述（可选）\",\n  \"projectDescriptionPlaceholder\": \"输入项目描述\",\n  \"creating\": \"创建中...\",\n  \"saving\": \"保存中...\",\n  \"createFailed\": \"创建项目失败\",\n  \"analysisModelRequiredAfterCreate\": \"项目已创建。请先前往个人设置配置默认模型（至少设置分析模型），否则无法使用。\",\n  \"updateFailed\": \"更新项目失败\",\n  \"deleteFailed\": \"删除项目失败\",\n  \"totalProjects\": \"共 {count} 个项目\",\n  \"statsEpisodes\": \"章节数\",\n  \"statsImages\": \"图片数\",\n  \"statsVideos\": \"视频数\",\n  \"noContent\": \"暂无内容\",\n  \"modelNotConfigured\": {\n    \"before\": \"检测到尚未配置模型，请先前往\",\n    \"link\": \"设置中心\",\n    \"after\": \"配置模型，或在创建项目后于项目配置中自定义。\"\n  }\n}"
  },
  {
    "path": "messages/zh/workspaceDetail.json",
    "content": "{\n  \"globalAssets\": \"全局资产\",\n  \"createFailed\": \"创建失败\",\n  \"deleteFailed\": \"删除失败\",\n  \"renameFailed\": \"重命名失败\",\n  \"refreshFailed\": \"刷新失败\",\n  \"projectNotFound\": \"项目不存在\",\n  \"backToWorkspace\": \"返回工作区\",\n  \"episode\": \"剧集\",\n  \"modelSetup\": {\n    \"title\": \"开始创作前，请先配置默认模型\",\n    \"description\": \"当前账号还没有默认分析模型，智能分集和后续 AI 流程无法使用。请先完成默认模型配置。\",\n    \"configureNow\": \"立即配置分析模型\",\n    \"goProfile\": \"前往个人设置\",\n    \"modalTitle\": \"配置默认分析模型\",\n    \"modalDescription\": \"请选择一个可用的文本模型作为默认分析模型。\",\n    \"selectModelLabel\": \"默认分析模型\",\n    \"selectModelPlaceholder\": \"请选择分析模型\",\n    \"selectModelFirst\": \"请先选择分析模型\",\n    \"noModelOptions\": \"当前没有可用的文本模型，请先在个人设置中配置 Provider 和模型。\",\n    \"saveFailed\": \"保存默认分析模型失败，请稍后重试\"\n  },\n  \"sidebar\": {\n    \"dragToMove\": \"拖动调整位置\",\n    \"listTitle\": \"剧集列表\",\n    \"episodeCount\": \"{count}集\",\n    \"empty\": \"暂无剧集，点击下方创建\",\n    \"save\": \"保存\",\n    \"deleteConfirm\": \"确定删除「{name}」？\",\n    \"delete\": \"删除\",\n    \"cancel\": \"取消\",\n    \"rename\": \"重命名\",\n    \"newEpisodePlaceholder\": \"输入剧集名称...\",\n    \"create\": \"创建\",\n    \"addEpisode\": \"添加剧集\"\n  }\n}\n"
  },
  {
    "path": "messages/zh/worldContextModal.json",
    "content": "{\n    \"title\": \"世界观与人设\",\n    \"description\": \"定义全局通用的角色外貌、场景风格和环境描述\",\n    \"placeholder\": \"例如：\\n【男主】张三，25岁，黑色短发，总是穿着一件洗得发白的牛仔夹克，眼神忧郁。\\n【女主】李四，22岁，红发双马尾，性格活泼,喜欢穿洛丽塔风格的裙子。\\n【场景】2077年的赛博朋克城市，霓虹灯闪烁，终年下雨...\",\n    \"hint\": \"这些设定将被所有剧集继承，作为AI绘画的基础参考。\"\n}"
  },
  {
    "path": "middleware.ts",
    "content": "import createMiddleware from 'next-intl/middleware';\nimport { routing } from './src/i18n/routing';\n\nexport default createMiddleware(routing);\n\nexport const config = {\n    // 匹配所有路径，除了 api、_next/static、_next/image、favicon.ico 等\n    matcher: [\n        // 匹配所有路径\n        '/((?!api|m|_next/static|_next/image|favicon.ico|.*\\\\.png|.*\\\\.jpg|.*\\\\.jpeg|.*\\\\.svg|.*\\\\.gif|.*\\\\.ico).*)'\n    ]\n};\n"
  },
  {
    "path": "next.config.ts",
    "content": "import type { NextConfig } from \"next\";\nimport createNextIntlPlugin from 'next-intl/plugin';\n\nconst withNextIntl = createNextIntlPlugin('./src/i18n.ts');\n\nconst nextConfig: NextConfig = {\n  // 已删除 ignoreBuildErrors / ignoreDuringBuilds，构建保持严格门禁\n  // Next 15 的 allowedDevOrigins 是顶层配置，不属于 experimental\n  allowedDevOrigins: [\n    'http://192.168.31.218:3000',\n    'http://192.168.31.*:3000',\n  ],\n};\n\nexport default withNextIntl(nextConfig);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"waoowaoo\",\n  \"version\": \"0.3.0\",\n  \"private\": true,\n  \"engines\": {\n    \"node\": \">=18.18.0\",\n    \"npm\": \">=9.0.0\"\n  },\n  \"scripts\": {\n    \"postinstall\": \"prisma generate\",\n    \"prepare\": \"husky\",\n    \"dev\": \"npm run storage:init && concurrently --kill-others \\\"npm run dev:next\\\" \\\"npm run dev:worker\\\" \\\"npm run dev:watchdog\\\" \\\"npm run dev:board\\\"\",\n    \"dev:next\": \"cross-env NODE_OPTIONS=\\\"--no-deprecation\\\" next dev --turbopack -H 0.0.0.0\",\n    \"dev:worker\": \"tsx watch --env-file=.env src/lib/workers/index.ts\",\n    \"dev:watchdog\": \"tsx watch --env-file=.env scripts/watchdog.ts\",\n    \"dev:board\": \"tsx watch --env-file=.env scripts/bull-board.ts\",\n    \"dev:turbo\": \"next dev --turbopack -H 0.0.0.0\",\n    \"build\": \"prisma generate && next build\",\n    \"build:turbo\": \"next build --turbopack\",\n    \"start\": \"npm run storage:init && concurrently --kill-others \\\"npm run start:next\\\" \\\"npm run start:worker\\\" \\\"npm run start:watchdog\\\" \\\"npm run start:board\\\"\",\n    \"storage:init\": \"tsx --env-file=.env src/lib/storage/init.ts\",\n    \"start:next\": \"next start -H 0.0.0.0\",\n    \"start:worker\": \"tsx --env-file=.env src/lib/workers/index.ts\",\n    \"start:watchdog\": \"tsx --env-file=.env scripts/watchdog.ts\",\n    \"start:board\": \"tsx --env-file=.env scripts/bull-board.ts\",\n    \"stats:errors\": \"tsx scripts/task-error-stats.ts\",\n    \"check:api-handler\": \"node scripts/guards/api-route-contract-guard.mjs\",\n    \"check:logs\": \"tsx scripts/check-no-console.ts\",\n    \"check:log-semantic\": \"tsx scripts/check-log-semantic.ts\",\n    \"check:media-normalization\": \"tsx scripts/check-media-normalization.ts\",\n    \"check:no-api-direct-llm-call\": \"node scripts/guards/no-api-direct-llm-call.mjs\",\n    \"check:no-internal-task-sync-fallback\": \"node scripts/guards/no-internal-task-sync-fallback.mjs\",\n    \"check:no-media-provider-bypass\": \"node scripts/guards/no-media-provider-bypass.mjs\",\n    \"check:no-model-key-downgrade\": \"node scripts/guards/no-model-key-downgrade.mjs\",\n    \"check:no-provider-guessing\": \"node scripts/guards/no-provider-guessing.mjs\",\n    \"check:no-hardcoded-model-capabilities\": \"node scripts/guards/no-hardcoded-model-capabilities.mjs\",\n    \"check:capability-catalog\": \"node scripts/check-capability-catalog.mjs\",\n    \"check:pricing-catalog\": \"node scripts/check-pricing-catalog.mjs\",\n    \"check:model-config-contract\": \"node scripts/check-model-config-contract.mjs --strict\",\n    \"check:config-center-guards\": \"npm run check:no-model-key-downgrade && npm run check:no-provider-guessing && npm run check:no-hardcoded-model-capabilities && npm run check:capability-catalog && npm run check:pricing-catalog\",\n    \"check:outbound-image-unification\": \"tsx scripts/check-outbound-image-unification.ts\",\n    \"check:outbound-image-runtime-sample\": \"tsx scripts/check-outbound-image-runtime-sample.ts\",\n    \"check:outbound-image-success-rate\": \"tsx scripts/check-outbound-image-success-rate.ts\",\n    \"check:image-urls-contract\": \"tsx scripts/check-image-urls-contract.ts\",\n    \"verify:outbound-image\": \"cross-env BILLING_TEST_BOOTSTRAP=0 vitest run src/lib/media/outbound-image.test.ts src/lib/media/image-url.test.ts && npm run check:outbound-image-unification\",\n    \"check:task-loading\": \"node scripts/guards/task-loading-guard.mjs\",\n    \"check:task-target-states-no-polling\": \"node scripts/guards/task-target-states-no-polling-guard.mjs\",\n    \"check:no-server-mirror-state\": \"node scripts/guards/no-server-mirror-state.mjs\",\n    \"check:no-multiple-sources-of-truth\": \"node scripts/guards/no-multiple-sources-of-truth.mjs\",\n    \"check:file-line-count\": \"node scripts/guards/file-line-count-guard.mjs\",\n    \"check:no-duplicate-endpoint-entry\": \"node scripts/guards/no-duplicate-endpoint-entry.mjs\",\n    \"check:test-route-coverage\": \"node scripts/guards/test-route-coverage-guard.mjs\",\n    \"check:test-tasktype-coverage\": \"node scripts/guards/test-tasktype-coverage-guard.mjs\",\n    \"check:test-behavior-quality\": \"node scripts/guards/test-behavior-quality-guard.mjs\",\n    \"check:changed-test-impact\": \"node scripts/guards/changed-file-test-impact-guard.mjs\",\n    \"check:test-behavior-route-coverage\": \"node scripts/guards/test-behavior-route-coverage-guard.mjs\",\n    \"check:test-behavior-tasktype-coverage\": \"node scripts/guards/test-behavior-tasktype-coverage-guard.mjs\",\n    \"check:test-coverage-guards\": \"npm run check:test-behavior-quality && npm run check:test-route-coverage && npm run check:test-tasktype-coverage && npm run check:test-behavior-route-coverage && npm run check:test-behavior-tasktype-coverage\",\n    \"check:requirements-matrix\": \"cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/contracts/requirements-matrix.test.ts\",\n    \"check:image-reference-normalization\": \"node scripts/guards/image-reference-normalization-guard.mjs\",\n    \"check:task-submit-compensation\": \"node scripts/guards/task-submit-compensation-guard.mjs\",\n    \"check:locale-navigation\": \"node scripts/guards/locale-navigation-guard.mjs\",\n    \"check:prompt-i18n\": \"node scripts/guards/prompt-i18n-guard.mjs\",\n    \"check:prompt-i18n-regression\": \"node scripts/guards/prompt-semantic-regression.mjs\",\n    \"check:prompt-ab-regression\": \"node scripts/guards/prompt-ab-regression.mjs\",\n    \"check:prompt-json-canary\": \"node scripts/guards/prompt-json-canary-guard.mjs\",\n    \"billing:cleanup-pending-freezes\": \"tsx scripts/billing-cleanup-pending-freezes.ts\",\n    \"billing:reconcile-ledger\": \"tsx scripts/billing-reconcile-ledger.ts\",\n    \"cleanup:remove-legacy-voice-data\": \"tsx scripts/cleanup-remove-legacy-voice-data.ts\",\n    \"test:billing\": \"npm run test:billing:coverage\",\n    \"test:billing:unit\": \"cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit/billing\",\n    \"test:billing:integration\": \"cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/billing\",\n    \"test:billing:concurrency\": \"cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/concurrency/billing\",\n    \"test:billing:coverage\": \"cross-env BILLING_TEST_BOOTSTRAP=1 vitest run --coverage tests/unit/billing tests/integration/billing tests/concurrency/billing\",\n    \"test:guards\": \"npm run check:api-handler && npm run check:image-reference-normalization && npm run check:task-submit-compensation && npm run check:no-api-direct-llm-call && npm run check:test-coverage-guards && npm run check:changed-test-impact && npm run check:requirements-matrix && npm run check:locale-navigation\",\n    \"test:unit:all\": \"cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit\",\n    \"test:integration:api\": \"cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/api\",\n    \"test:integration:provider\": \"cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/provider\",\n    \"test:integration:chain\": \"cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/chain\",\n    \"test:integration:task\": \"cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/task\",\n    \"test:system\": \"cross-env SYSTEM_TEST_BOOTSTRAP=1 vitest run tests/system\",\n    \"test:regression:cases\": \"cross-env SYSTEM_TEST_BOOTSTRAP=1 vitest run tests/regression\",\n    \"test:behavior:unit\": \"cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit/helpers tests/unit/worker tests/unit/optimistic tests/unit/guards\",\n    \"test:behavior:api\": \"cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/api/contract\",\n    \"test:behavior:provider\": \"cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/provider\",\n    \"test:behavior:chain\": \"cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/chain\",\n    \"test:behavior:guards\": \"npm run check:api-handler && npm run check:image-reference-normalization && npm run check:task-submit-compensation && npm run check:test-coverage-guards && npm run check:requirements-matrix\",\n    \"test:behavior:full\": \"npm run test:behavior:guards && npm run test:behavior:unit && npm run test:behavior:api && npm run test:behavior:provider && npm run test:behavior:chain\",\n    \"test:coverage:core-baseline\": \"cross-env SYSTEM_TEST_BOOTSTRAP=1 vitest run --config vitest.core-coverage.config.ts tests/unit tests/integration/api tests/integration/provider tests/integration/chain tests/integration/task tests/system tests/regression\",\n    \"test:all\": \"npm run test:guards && npm run test:unit:all && npm run test:billing:integration && npm run test:billing:concurrency && npm run test:integration:api && npm run test:integration:provider && npm run test:integration:chain && npm run test:integration:task && npm run test:system && npm run test:regression:cases\",\n    \"test:regression\": \"npm run test:all\",\n    \"test:pr\": \"bash scripts/test-regression-runner.sh npm run test:all\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"lint:all\": \"npm run lint -- .\",\n    \"verify:commit\": \"npm run lint:all && npm run typecheck && npm run test:all\",\n    \"verify:push\": \"npm run lint:all && npm run typecheck && npm run test:all && npm run build\",\n    \"migrate:image-urls-contract\": \"tsx scripts/migrate-image-urls-contract.ts\",\n    \"migrate:model-config-contract\": \"tsx scripts/migrations/migrate-model-config-contract.ts\",\n    \"migrate:capability-selections\": \"tsx scripts/migrations/migrate-capability-selections.ts\",\n    \"migrate:gateway-route-openai-compat\": \"tsx scripts/migrations/migrate-gateway-route-openai-compat.ts\",\n    \"migrate:custom-pricing-v2\": \"tsx scripts/migrations/migrate-custom-pricing-v2.ts\",\n    \"migrate:graph-artifacts-unique-index\": \"tsx scripts/migrations/migrate-graph-artifacts-unique-index.ts\",\n    \"migrate:release-blockers\": \"tsx scripts/migrations/migrate-release-blockers.ts\",\n    \"backup:media-safety\": \"tsx scripts/media-safety-backup.ts\",\n    \"backup:media-restore-dry-run\": \"tsx scripts/media-restore-dry-run.ts\",\n    \"media:backfill-refs\": \"tsx scripts/media-backfill-refs.ts\",\n    \"backup:archive-legacy-media\": \"tsx scripts/media-archive-legacy-refs.ts\",\n    \"backup:unreferenced-media-index\": \"tsx scripts/media-build-unreferenced-index.ts\",\n    \"lint\": \"eslint\",\n    \"clean\": \"rimraf .next\"\n  },\n  \"dependencies\": {\n    \"@ai-sdk/google\": \"^3.0.22\",\n    \"@ai-sdk/openai\": \"^3.0.26\",\n    \"@ai-sdk/react\": \"^3.0.118\",\n    \"@aws-sdk/client-s3\": \"^3.883.0\",\n    \"@aws-sdk/s3-request-presigner\": \"^3.883.0\",\n    \"@bull-board/api\": \"^6.16.4\",\n    \"@bull-board/express\": \"^6.16.4\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@fal-ai/client\": \"^1.7.2\",\n    \"@google/genai\": \"^1.34.0\",\n    \"@next-auth/prisma-adapter\": \"^1.0.7\",\n    \"@openrouter/sdk\": \"^0.3.11\",\n    \"@prisma/client\": \"^6.19.2\",\n    \"@remotion/cli\": \"^4.0.405\",\n    \"@remotion/player\": \"^4.0.405\",\n    \"@tanstack/react-query\": \"^5.90.20\",\n    \"@types/archiver\": \"^7.0.0\",\n    \"@types/bcryptjs\": \"^3.0.0\",\n    \"@types/express\": \"^5.0.6\",\n    \"@vercel/og\": \"^0.8.6\",\n    \"ai\": \"^6.0.116\",\n    \"archiver\": \"^7.0.1\",\n    \"bcryptjs\": \"^3.0.2\",\n    \"bullmq\": \"^5.67.3\",\n    \"cos-nodejs-sdk-v5\": \"^2.15.4\",\n    \"express\": \"^5.2.1\",\n    \"file-saver\": \"^2.0.5\",\n    \"ioredis\": \"^5.9.2\",\n    \"jsonrepair\": \"^3.13.2\",\n    \"jszip\": \"^3.10.1\",\n    \"lru-cache\": \"^11.2.6\",\n    \"lucide-react\": \"^0.575.0\",\n    \"mammoth\": \"^1.11.0\",\n    \"mysql2\": \"^3.15.1\",\n    \"next\": \"^15.5.7\",\n    \"next-auth\": \"^4.24.11\",\n    \"next-intl\": \"^4.7.0\",\n    \"openai\": \"^6.8.1\",\n    \"prisma\": \"^6.19.2\",\n    \"react\": \"^19.1.2\",\n    \"react-dom\": \"^19.1.2\",\n    \"react-grab\": \"^0.1.20\",\n    \"react-hot-toast\": \"^2.6.0\",\n    \"remotion\": \"^4.0.405\",\n    \"sharp\": \"^0.34.5\",\n    \"undici\": \"^7.22.0\",\n    \"zod\": \"^3.25.76\"\n  },\n  \"devDependencies\": {\n    \"@eslint/eslintrc\": \"^3\",\n    \"@tailwindcss/postcss\": \"^4\",\n    \"@types/file-saver\": \"^2.0.7\",\n    \"@types/node\": \"^20\",\n    \"@types/react\": \"^19\",\n    \"@types/react-dom\": \"^19\",\n    \"@vitest/coverage-v8\": \"^2.1.8\",\n    \"concurrently\": \"^9.2.1\",\n    \"cross-env\": \"^10.1.0\",\n    \"eslint\": \"^9\",\n    \"eslint-config-next\": \"15.5.4\",\n    \"husky\": \"^9.1.7\",\n    \"rimraf\": \"^6.1.2\",\n    \"tailwindcss\": \"^4\",\n    \"tsx\": \"^4.20.5\",\n    \"typescript\": \"^5\",\n    \"vitest\": \"^2.1.8\"\n  }\n}\n"
  },
  {
    "path": "postcss.config.mjs",
    "content": "const config = {\n  plugins: [\"@tailwindcss/postcss\"],\n};\n\nexport default config;\n"
  },
  {
    "path": "prisma/schema.prisma",
    "content": "generator client {\n  provider = \"prisma-client-js\"\n}\n\ndatasource db {\n  provider = \"mysql\"\n  url      = env(\"DATABASE_URL\")\n}\n\nmodel Account {\n  id                String  @id @default(uuid())\n  userId            String\n  type              String\n  provider          String\n  providerAccountId String\n  refresh_token     String? @db.Text\n  access_token      String? @db.Text\n  expires_at        Int?\n  token_type        String?\n  scope             String?\n  id_token          String? @db.Text\n  session_state     String?\n  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([provider, providerAccountId])\n  @@index([userId])\n  @@map(\"account\")\n}\n\nmodel CharacterAppearance {\n  id                   String                  @id @default(uuid())\n  characterId          String\n  appearanceIndex      Int\n  changeReason         String\n  description          String?                 @db.Text\n  descriptions         String?                 @db.Text\n  imageUrl             String?                 @db.Text\n  imageUrls            String?                 @db.Text\n  selectedIndex        Int?\n  createdAt            DateTime                @default(now())\n  updatedAt            DateTime                @default(now()) @updatedAt\n  previousImageUrl     String?                 @db.Text\n  previousImageUrls    String?                 @db.Text\n  previousDescription  String?                 @db.Text // 上一次的描述词（用于撤回）\n  previousDescriptions String?                 @db.Text // 上一次的描述词数组（用于撤回）\n  imageMediaId         String?\n  imageMedia           MediaObject?            @relation(\"CharacterAppearanceImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  character            NovelPromotionCharacter @relation(fields: [characterId], references: [id], onDelete: Cascade)\n\n  @@unique([characterId, appearanceIndex])\n  @@index([characterId])\n  @@index([imageMediaId])\n  @@map(\"character_appearances\")\n}\n\nmodel LocationImage {\n  id                  String                   @id @default(uuid())\n  locationId          String\n  imageIndex          Int\n  description         String?                  @db.Text\n  imageUrl            String?                  @db.Text\n  isSelected          Boolean                  @default(false)\n  createdAt           DateTime                 @default(now())\n  updatedAt           DateTime                 @default(now()) @updatedAt\n  previousImageUrl    String?                  @db.Text\n  previousDescription String?                  @db.Text // 上一次的描述词（用于撤回）\n  imageMediaId        String?\n  imageMedia          MediaObject?             @relation(\"LocationImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  location            NovelPromotionLocation   @relation(\"LocationImages\", fields: [locationId], references: [id], onDelete: Cascade)\n  selectedByLocations NovelPromotionLocation[] @relation(\"SelectedLocationImage\")\n\n  @@unique([locationId, imageIndex])\n  @@index([locationId])\n  @@index([imageMediaId])\n  @@map(\"location_images\")\n}\n\nmodel NovelPromotionCharacter {\n  id                      String                @id @default(uuid())\n  novelPromotionProjectId String\n  name                    String\n  aliases                 String?               @db.Text\n  createdAt               DateTime              @default(now())\n  updatedAt               DateTime              @default(now()) @updatedAt\n  customVoiceUrl          String?               @db.Text\n  customVoiceMediaId      String?\n  customVoiceMedia        MediaObject?          @relation(\"NovelPromotionCharacterVoiceMedia\", fields: [customVoiceMediaId], references: [id], onDelete: SetNull)\n  voiceId                 String?\n  voiceType               String?\n  profileData             String?               @db.Text\n  profileConfirmed        Boolean               @default(false)\n  introduction            String?               @db.Text // 角色介绍（身份、关系、称呼映射，如\"我\"对应此角色）\n  sourceGlobalCharacterId String? // 🆕 来源全局角色ID（复制时记录）\n  appearances             CharacterAppearance[]\n  novelPromotionProject   NovelPromotionProject @relation(fields: [novelPromotionProjectId], references: [id], onDelete: Cascade)\n\n  @@index([novelPromotionProjectId])\n  @@index([customVoiceMediaId])\n  @@map(\"novel_promotion_characters\")\n}\n\nmodel NovelPromotionLocation {\n  id                      String                @id @default(uuid())\n  novelPromotionProjectId String\n  name                    String\n  summary                 String?               @db.Text // 场景简要描述（用途/人物关联）\n  createdAt               DateTime              @default(now())\n  updatedAt               DateTime              @default(now()) @updatedAt\n  sourceGlobalLocationId  String? // 🆕 来源全局场景ID（复制时记录）\n  selectedImageId         String?\n  selectedImage           LocationImage?        @relation(\"SelectedLocationImage\", fields: [selectedImageId], references: [id], onDelete: SetNull)\n  images                  LocationImage[]       @relation(\"LocationImages\")\n  novelPromotionProject   NovelPromotionProject @relation(fields: [novelPromotionProjectId], references: [id], onDelete: Cascade)\n\n  @@index([novelPromotionProjectId])\n  @@map(\"novel_promotion_locations\")\n}\n\nmodel NovelPromotionEpisode {\n  id                      String                     @id @default(uuid())\n  novelPromotionProjectId String\n  episodeNumber           Int\n  name                    String\n  description             String?                    @db.Text\n  novelText               String?                    @db.Text\n  audioUrl                String?                    @db.Text\n  audioMediaId            String?\n  audioMedia              MediaObject?               @relation(\"NovelPromotionEpisodeAudioMedia\", fields: [audioMediaId], references: [id], onDelete: SetNull)\n  srtContent              String?                    @db.Text\n  createdAt               DateTime                   @default(now())\n  updatedAt               DateTime                   @default(now()) @updatedAt\n  speakerVoices           String?                    @db.Text\n  clips                   NovelPromotionClip[]\n  novelPromotionProject   NovelPromotionProject      @relation(fields: [novelPromotionProjectId], references: [id], onDelete: Cascade)\n  shots                   NovelPromotionShot[]\n  storyboards             NovelPromotionStoryboard[]\n  voiceLines              NovelPromotionVoiceLine[]\n  editorProject           VideoEditorProject?\n\n  @@unique([novelPromotionProjectId, episodeNumber])\n  @@index([novelPromotionProjectId])\n  @@index([audioMediaId])\n  @@map(\"novel_promotion_episodes\")\n}\n\n// 视频编辑器项目 - 存储剪辑数据\nmodel VideoEditorProject {\n  id           String                @id @default(uuid())\n  episodeId    String                @unique\n  projectData  String                @db.Text // JSON 存储编辑项目数据\n  renderStatus String? // pending | rendering | completed | failed\n  renderTaskId String?\n  outputUrl    String?               @db.Text\n  createdAt    DateTime              @default(now())\n  updatedAt    DateTime              @default(now()) @updatedAt\n  episode      NovelPromotionEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n\n  @@map(\"video_editor_projects\")\n}\n\nmodel NovelPromotionClip {\n  id         String                    @id @default(uuid())\n  episodeId  String\n  start      Int?\n  end        Int?\n  duration   Int?\n  summary    String                    @db.Text\n  location   String?                   @db.Text\n  content    String                    @db.Text\n  createdAt  DateTime                  @default(now())\n  updatedAt  DateTime                  @default(now()) @updatedAt\n  characters String?                   @db.Text\n  endText    String?                   @db.Text\n  shotCount  Int?\n  startText  String?                   @db.Text\n  screenplay String?                   @db.Text\n  episode    NovelPromotionEpisode     @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n  shots      NovelPromotionShot[]\n  storyboard NovelPromotionStoryboard?\n\n  @@index([episodeId])\n  @@map(\"novel_promotion_clips\")\n}\n\nmodel NovelPromotionPanel {\n  id                String                    @id @default(uuid())\n  storyboardId      String\n  panelIndex        Int\n  panelNumber       Int?\n  shotType          String?                   @db.Text\n  cameraMove        String?                   @db.Text\n  description       String?                   @db.Text\n  location          String?                   @db.Text\n  characters        String?                   @db.Text\n  srtSegment        String?                   @db.Text\n  srtStart          Float?\n  srtEnd            Float?\n  duration          Float?\n  imagePrompt       String?                   @db.Text\n  imageUrl          String?                   @db.Text\n  imageMediaId      String?\n  imageMedia        MediaObject?              @relation(\"NovelPromotionPanelImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  imageHistory      String?                   @db.Text\n  videoPrompt       String?                   @db.Text\n  firstLastFramePrompt String?                @db.Text\n  videoUrl          String?                   @db.Text\n  videoGenerationMode String?                 @db.Text // 视频生成方式：normal | firstlastframe\n  videoMediaId      String?\n  videoMedia        MediaObject?              @relation(\"NovelPromotionPanelVideoMedia\", fields: [videoMediaId], references: [id], onDelete: SetNull)\n  createdAt         DateTime                  @default(now())\n  updatedAt         DateTime                  @default(now()) @updatedAt\n  sceneType         String?\n  candidateImages   String?                   @db.Text\n  linkedToNextPanel Boolean                   @default(false)\n  lipSyncTaskId     String?\n  lipSyncVideoUrl   String?\n  lipSyncVideoMediaId String?\n  lipSyncVideoMedia MediaObject?              @relation(\"NovelPromotionPanelLipSyncVideoMedia\", fields: [lipSyncVideoMediaId], references: [id], onDelete: SetNull)\n  sketchImageUrl    String?                   @db.Text\n  sketchImageMediaId String?\n  sketchImageMedia  MediaObject?              @relation(\"NovelPromotionPanelSketchMedia\", fields: [sketchImageMediaId], references: [id], onDelete: SetNull)\n  photographyRules  String?                   @db.Text\n  actingNotes       String?                   @db.Text // 演技指导数据 JSON\n  previousImageUrl  String?                   @db.Text\n  previousImageMediaId String?\n  previousImageMedia MediaObject?             @relation(\"NovelPromotionPanelPreviousImageMedia\", fields: [previousImageMediaId], references: [id], onDelete: SetNull)\n  storyboard        NovelPromotionStoryboard  @relation(fields: [storyboardId], references: [id], onDelete: Cascade)\n  matchedVoiceLines NovelPromotionVoiceLine[]\n\n  @@unique([storyboardId, panelIndex])\n  @@index([storyboardId])\n  @@index([imageMediaId])\n  @@index([videoMediaId])\n  @@index([lipSyncVideoMediaId])\n  @@index([sketchImageMediaId])\n  @@index([previousImageMediaId])\n  @@map(\"novel_promotion_panels\")\n}\n\nmodel NovelPromotionProject {\n  id              String                    @id @default(uuid())\n  projectId       String                    @unique\n  createdAt       DateTime                  @default(now())\n  updatedAt       DateTime                  @default(now()) @updatedAt\n  analysisModel   String? // 用户配置的分析模型（nullable，必须配置后才能使用）\n  imageModel      String? // 用户配置的图片模型\n  videoModel      String? // 用户配置的视频模型\n  audioModel      String? // 用户配置的语音模型\n  videoRatio      String                    @default(\"9:16\")\n  ttsRate         String                    @default(\"+50%\")\n  globalAssetText String?                   @db.Text\n  artStyle        String                    @default(\"american-comic\")\n  artStylePrompt  String?                   @db.Text\n  characterModel  String? // 用户配置的角色图片模型\n  locationModel   String? // 用户配置的场景图片模型\n  storyboardModel String? // 用户配置的分镜图片模型\n  editModel       String? // 用户配置的修图/编辑模型\n  videoResolution String                    @default(\"720p\")\n  capabilityOverrides String?              @db.Text\n  workflowMode    String                    @default(\"srt\")\n  lastEpisodeId   String?\n  imageResolution String                    @default(\"2K\")\n  importStatus    String?\n  characters      NovelPromotionCharacter[]\n  episodes        NovelPromotionEpisode[]\n  locations       NovelPromotionLocation[]\n  project         Project                   @relation(fields: [projectId], references: [id], onDelete: Cascade)\n\n  @@map(\"novel_promotion_projects\")\n}\n\nmodel NovelPromotionShot {\n  id              String                @id @default(uuid())\n  episodeId       String\n  clipId          String?\n  shotId          String\n  srtStart        Int\n  srtEnd          Int\n  srtDuration     Float\n  sequence        String?               @db.Text\n  locations       String?               @db.Text\n  characters      String?               @db.Text\n  plot            String?               @db.Text\n  imagePrompt     String?               @db.Text\n  scale           String?               @db.Text\n  module          String?               @db.Text\n  focus           String?               @db.Text\n  zhSummarize     String?               @db.Text\n  imageUrl        String?               @db.Text\n  imageMediaId    String?\n  imageMedia      MediaObject?          @relation(\"NovelPromotionShotImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  createdAt       DateTime              @default(now())\n  updatedAt       DateTime              @default(now()) @updatedAt\n  pov             String?               @db.Text\n  clip            NovelPromotionClip?   @relation(fields: [clipId], references: [id], onDelete: Cascade)\n  episode         NovelPromotionEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n\n  @@index([clipId])\n  @@index([episodeId])\n  @@index([shotId])\n  @@index([imageMediaId])\n  @@map(\"novel_promotion_shots\")\n}\n\nmodel NovelPromotionStoryboard {\n  id                  String                @id @default(uuid())\n  episodeId           String\n  clipId              String                @unique\n  storyboardImageUrl  String?               @db.Text\n  createdAt           DateTime              @default(now())\n  updatedAt           DateTime              @default(now()) @updatedAt\n  panelCount          Int                   @default(9)\n  storyboardTextJson  String?               @db.Text\n  imageHistory        String?               @db.Text\n  candidateImages     String?               @db.Text\n  lastError           String?\n  photographyPlan     String?               @db.Text\n  panels              NovelPromotionPanel[]\n  clip                NovelPromotionClip    @relation(fields: [clipId], references: [id], onDelete: Cascade)\n  episode             NovelPromotionEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n  supplementaryPanels SupplementaryPanel[]\n\n  @@index([clipId])\n  @@index([episodeId])\n  @@map(\"novel_promotion_storyboards\")\n}\n\nmodel SupplementaryPanel {\n  id            String                   @id @default(uuid())\n  storyboardId  String\n  sourceType    String\n  sourcePanelId String?\n  description   String?                  @db.Text\n  imagePrompt   String?                  @db.Text\n  imageUrl      String?                  @db.Text\n  imageMediaId  String?\n  imageMedia    MediaObject?             @relation(\"SupplementaryPanelImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  characters    String?                  @db.Text\n  location      String?                  @db.Text\n  createdAt     DateTime                 @default(now())\n  updatedAt     DateTime                 @default(now()) @updatedAt\n  storyboard    NovelPromotionStoryboard @relation(fields: [storyboardId], references: [id], onDelete: Cascade)\n\n  @@index([storyboardId])\n  @@index([imageMediaId])\n  @@map(\"supplementary_panels\")\n}\n\nmodel Project {\n  id                 String                 @id @default(uuid())\n  name               String\n  description        String?                @db.Text\n  mode               String                 @default(\"novel-promotion\")\n  userId             String\n  createdAt          DateTime               @default(now())\n  updatedAt          DateTime               @default(now()) @updatedAt\n  lastAccessedAt     DateTime?\n  novelPromotionData NovelPromotionProject?\n  user               User                   @relation(fields: [userId], references: [id], onDelete: Cascade)\n  usageCosts         UsageCost[]\n\n  @@index([userId])\n  @@map(\"projects\")\n}\n\nmodel Session {\n  id           String   @id @default(uuid())\n  sessionToken String   @unique(map: \"Session_sessionToken_key\")\n  userId       String\n  expires      DateTime\n  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([userId])\n  @@map(\"session\")\n}\n\nmodel UsageCost {\n  id        String   @id @default(uuid())\n  projectId String\n  userId    String\n  apiType   String\n  model     String\n  action    String\n  quantity  Int\n  unit      String\n  cost      Decimal  @db.Decimal(18, 6)\n  metadata  String?  @db.Text\n  createdAt DateTime @default(now())\n  project   Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)\n  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([apiType])\n  @@index([createdAt])\n  @@index([projectId])\n  @@index([userId])\n  @@map(\"usage_costs\")\n}\n\nmodel User {\n  id            String          @id @default(uuid())\n  name          String          @unique(map: \"User_name_key\")\n  email         String?\n  emailVerified DateTime?\n  image         String?\n  password      String?\n  createdAt     DateTime        @default(now())\n  updatedAt     DateTime        @default(now()) @updatedAt\n  accounts      Account[]\n  projects      Project[]\n  sessions      Session[]\n  usageCosts    UsageCost[]\n  balance       UserBalance?\n  preferences   UserPreference?\n\n  // 资产中心\n  globalAssetFolders GlobalAssetFolder[]\n  globalCharacters   GlobalCharacter[]\n  globalLocations    GlobalLocation[]\n  globalVoices       GlobalVoice[]\n  tasks              Task[]\n  taskEvents         TaskEvent[]\n  graphRuns          GraphRun[]\n  graphEvents        GraphEvent[]\n\n  @@map(\"user\")\n}\n\nmodel UserPreference {\n  id              String   @id @default(uuid())\n  userId          String   @unique\n  analysisModel   String? // 用户配置的分析模型（nullable，必须配置后才能使用）\n  characterModel  String? // 用户配置的角色图片模型\n  locationModel   String? // 用户配置的场景图片模型\n  storyboardModel String? // 用户配置的分镜图片模型\n  editModel       String? // 用户配置的修图模型\n  videoModel      String? // 用户配置的视频模型\n  audioModel      String? // 用户配置的语音模型\n  lipSyncModel    String? // 用户配置的口型同步模型\n  voiceDesignModel String? // 用户配置的音色设计模型\n  analysisConcurrency Int? // 分析流程并发上限\n  imageConcurrency Int? // 图像流程并发上限\n  videoConcurrency Int? // 视频流程并发上限\n  videoRatio      String   @default(\"9:16\")\n  videoResolution String   @default(\"720p\")\n  artStyle        String   @default(\"american-comic\")\n  ttsRate         String   @default(\"+50%\")\n  createdAt       DateTime @default(now())\n  updatedAt       DateTime @default(now()) @updatedAt\n  imageResolution String   @default(\"2K\")\n  capabilityDefaults String? @db.Text\n\n  // API Key 配置（极简版）\n  llmBaseUrl  String? @default(\"https://openrouter.ai/api/v1\")\n  llmApiKey   String? @db.Text // 加密存储\n  falApiKey   String? @db.Text // FAL（图片+视频+语音）\n  googleAiKey String? @db.Text // Google AI（Gemini 图片）\n  arkApiKey   String? @db.Text // 火山引擎（Seedream+Seedance）\n  qwenApiKey  String? @db.Text // 阿里百炼（声音设计）\n\n  // 自定义模型列表 + 价格（JSON）\n  customModels String? @db.Text\n\n  // 自定义 OpenAI 兼容提供商列表（JSON，包含加密的 API Key）\n  customProviders String? @db.Text\n\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@map(\"user_preferences\")\n}\n\nmodel VerificationToken {\n  identifier String\n  token      String   @unique(map: \"VerificationToken_token_key\")\n  expires    DateTime\n\n  @@unique([identifier, token])\n  @@map(\"verificationtoken\")\n}\n\nmodel NovelPromotionVoiceLine {\n  id                  String                @id @default(uuid())\n  episodeId           String\n  lineIndex           Int\n  speaker             String\n  content             String                @db.Text\n  voicePresetId       String?\n  audioUrl            String?               @db.Text\n  audioMediaId        String?\n  audioMedia          MediaObject?          @relation(\"NovelPromotionVoiceLineAudioMedia\", fields: [audioMediaId], references: [id], onDelete: SetNull)\n  createdAt           DateTime              @default(now())\n  updatedAt           DateTime              @default(now()) @updatedAt\n  emotionPrompt       String?               @db.Text\n  emotionStrength     Float?                @default(0.4)\n  matchedPanelIndex   Int?\n  matchedStoryboardId String?\n  audioDuration       Int?\n  matchedPanelId      String?\n  episode             NovelPromotionEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n  matchedPanel        NovelPromotionPanel?  @relation(fields: [matchedPanelId], references: [id])\n\n  @@unique([episodeId, lineIndex])\n  @@index([episodeId])\n  @@index([matchedPanelId])\n  @@index([audioMediaId])\n  @@map(\"novel_promotion_voice_lines\")\n}\n\nmodel VoicePreset {\n  id          String   @id @default(uuid())\n  name        String\n  audioUrl    String   @db.Text\n  audioMediaId String?\n  audioMedia  MediaObject? @relation(\"VoicePresetAudioMedia\", fields: [audioMediaId], references: [id], onDelete: SetNull)\n  description String?  @db.Text\n  gender      String?\n  isSystem    Boolean  @default(true)\n  createdAt   DateTime @default(now())\n\n  @@index([audioMediaId])\n  @@map(\"voice_presets\")\n}\n\nmodel UserBalance {\n  id           String   @id @default(uuid())\n  userId       String   @unique\n  balance      Decimal  @default(0) @db.Decimal(18, 6)\n  frozenAmount Decimal  @default(0) @db.Decimal(18, 6)\n  totalSpent   Decimal  @default(0) @db.Decimal(18, 6)\n  createdAt    DateTime @default(now())\n  updatedAt    DateTime @default(now()) @updatedAt\n  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@map(\"user_balances\")\n}\n\nmodel BalanceFreeze {\n  id        String   @id @default(uuid())\n  userId    String\n  amount    Decimal  @db.Decimal(18, 6)\n  status    String   @default(\"pending\")\n  source    String?  @db.VarChar(64)\n  taskId    String?\n  requestId String?\n  idempotencyKey String? @unique\n  metadata  String?  @db.Text\n  expiresAt DateTime?\n  createdAt DateTime @default(now())\n  updatedAt DateTime @default(now()) @updatedAt\n\n  @@index([userId])\n  @@index([status])\n  @@index([taskId])\n  @@map(\"balance_freezes\")\n}\n\nmodel BalanceTransaction {\n  id           String   @id @default(uuid())\n  userId       String\n  type         String\n  amount       Decimal  @db.Decimal(18, 6)\n  balanceAfter Decimal  @db.Decimal(18, 6)\n  description  String?  @db.Text\n  relatedId    String?\n  freezeId     String?\n  operatorId   String?  @db.VarChar(64)\n  externalOrderId String? @db.VarChar(128)\n  idempotencyKey String? @db.VarChar(128)\n  projectId    String?  @db.VarChar(128) // 关联项目 ID，用于流水展示项目名\n  episodeId    String?  @db.VarChar(128) // 关联集数 ID，用于流水展示集数\n  taskType     String?  @db.VarChar(64)  // 任务类型 key（与 action 一致），用于前端 i18n\n  billingMeta  String?  @db.Text         // 计费详情 JSON: { quantity, unit, model, resolution, duration, tokens... }\n  createdAt    DateTime @default(now())\n\n  @@index([userId])\n  @@index([type])\n  @@index([createdAt])\n  @@index([freezeId])\n  @@index([externalOrderId])\n  @@index([projectId])\n  @@unique([userId, type, idempotencyKey])\n  @@map(\"balance_transactions\")\n}\n\nmodel Task {\n  id               String    @id @default(uuid())\n  userId           String\n  projectId        String\n  episodeId        String?\n  type             String\n  targetType       String\n  targetId         String\n  status           String    @default(\"queued\")\n  progress         Int       @default(0)\n  attempt          Int       @default(0)\n  maxAttempts      Int       @default(5)\n  priority         Int       @default(0)\n  dedupeKey        String?   @unique\n  externalId       String?\n  payload          Json?\n  result           Json?\n  errorCode        String?\n  errorMessage     String?   @db.Text\n  billingInfo      Json?\n  billedAt         DateTime?\n  queuedAt         DateTime  @default(now())\n  startedAt        DateTime?\n  finishedAt       DateTime?\n  heartbeatAt      DateTime?\n  enqueuedAt       DateTime?\n  enqueueAttempts  Int       @default(0)\n  lastEnqueueError String?   @db.Text\n  createdAt        DateTime  @default(now())\n  updatedAt        DateTime  @updatedAt\n\n  user   User        @relation(fields: [userId], references: [id], onDelete: Cascade)\n  events TaskEvent[]\n\n  @@index([status])\n  @@index([type])\n  @@index([targetType, targetId])\n  @@index([projectId])\n  @@index([userId])\n  @@index([heartbeatAt])\n  @@map(\"tasks\")\n}\n\nmodel TaskEvent {\n  id        Int      @id @default(autoincrement())\n  taskId    String\n  projectId String\n  userId    String\n  eventType String\n  payload   Json?\n  createdAt DateTime @default(now())\n\n  task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([projectId, id])\n  @@index([taskId])\n  @@index([userId])\n  @@map(\"task_events\")\n}\n\nmodel GraphRun {\n  id                String             @id @default(uuid())\n  userId            String\n  projectId         String\n  episodeId         String?\n  workflowType      String\n  taskType          String?\n  taskId            String?            @unique\n  targetType        String\n  targetId          String\n  status            String             @default(\"queued\")\n  input             Json?\n  output            Json?\n  errorCode         String?\n  errorMessage      String?            @db.Text\n  cancelRequestedAt DateTime?\n  leaseOwner        String?\n  leaseExpiresAt    DateTime?\n  heartbeatAt       DateTime?\n  workflowVersion   Int                @default(1)\n  queuedAt          DateTime           @default(now())\n  startedAt         DateTime?\n  finishedAt        DateTime?\n  lastSeq           Int                @default(0)\n  createdAt         DateTime           @default(now())\n  updatedAt         DateTime           @updatedAt\n  user              User               @relation(fields: [userId], references: [id], onDelete: Cascade)\n  steps             GraphStep[]\n  attempts          GraphStepAttempt[]\n  events            GraphEvent[]\n  checkpoints       GraphCheckpoint[]\n  artifacts         GraphArtifact[]\n\n  @@index([projectId, status])\n  @@index([userId, createdAt])\n  @@index([taskId])\n  @@index([targetType, targetId])\n  @@index([workflowType, targetType, targetId, status])\n  @@index([leaseExpiresAt])\n  @@map(\"graph_runs\")\n}\n\nmodel GraphStep {\n  id               String             @id @default(uuid())\n  runId            String\n  stepKey          String\n  stepTitle        String\n  status           String             @default(\"pending\")\n  currentAttempt   Int                @default(0)\n  stepIndex        Int\n  stepTotal        Int\n  startedAt        DateTime?\n  finishedAt       DateTime?\n  lastErrorCode    String?\n  lastErrorMessage String?            @db.Text\n  createdAt        DateTime           @default(now())\n  updatedAt        DateTime           @updatedAt\n  run              GraphRun           @relation(fields: [runId], references: [id], onDelete: Cascade)\n  attempts         GraphStepAttempt[]\n\n  @@unique([runId, stepKey])\n  @@index([runId, status])\n  @@index([runId, stepIndex])\n  @@map(\"graph_steps\")\n}\n\nmodel GraphStepAttempt {\n  id              String     @id @default(uuid())\n  runId           String\n  stepKey         String\n  attempt         Int\n  status          String     @default(\"pending\")\n  provider        String?\n  modelKey        String?\n  inputHash       String?\n  input           Json?\n  outputText      String?    @db.Text\n  outputReasoning String?    @db.Text\n  usageJson       Json?\n  errorCode       String?\n  errorMessage    String?    @db.Text\n  startedAt       DateTime?\n  finishedAt      DateTime?\n  createdAt       DateTime   @default(now())\n  updatedAt       DateTime   @updatedAt\n  run             GraphRun   @relation(fields: [runId], references: [id], onDelete: Cascade)\n  step            GraphStep  @relation(fields: [runId, stepKey], references: [runId, stepKey], onDelete: Cascade)\n\n  @@unique([runId, stepKey, attempt])\n  @@index([runId, stepKey])\n  @@index([runId, createdAt])\n  @@map(\"graph_step_attempts\")\n}\n\nmodel GraphEvent {\n  id        BigInt   @id @default(autoincrement())\n  runId     String\n  projectId String\n  userId    String\n  seq       Int\n  eventType String\n  stepKey   String?\n  attempt   Int?\n  lane      String?\n  payload   Json?\n  createdAt DateTime @default(now())\n  run       GraphRun @relation(fields: [runId], references: [id], onDelete: Cascade)\n  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([runId, seq])\n  @@index([projectId, id])\n  @@index([runId, id])\n  @@index([userId, id])\n  @@map(\"graph_events\")\n}\n\nmodel GraphCheckpoint {\n  id         String   @id @default(uuid())\n  runId      String\n  nodeKey    String\n  version    Int\n  stateJson  Json\n  stateBytes Int\n  createdAt  DateTime @default(now())\n  run        GraphRun @relation(fields: [runId], references: [id], onDelete: Cascade)\n\n  @@unique([runId, nodeKey, version])\n  @@index([runId, createdAt])\n  @@map(\"graph_checkpoints\")\n}\n\nmodel GraphArtifact {\n  id          String   @id @default(uuid())\n  runId       String\n  stepKey     String?\n  artifactType String\n  refId       String\n  versionHash String?\n  payload     Json?\n  createdAt   DateTime @default(now())\n  run         GraphRun @relation(fields: [runId], references: [id], onDelete: Cascade)\n\n  @@unique([runId, stepKey, artifactType, refId])\n  @@index([runId])\n  @@index([runId, stepKey])\n  @@index([artifactType, refId])\n  @@map(\"graph_artifacts\")\n}\n\n// ==================== 资产中心 ====================\n\n// 资产文件夹（一层，不支持嵌套）\nmodel GlobalAssetFolder {\n  id        String   @id @default(uuid())\n  userId    String\n  name      String\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  user       User              @relation(fields: [userId], references: [id], onDelete: Cascade)\n  characters GlobalCharacter[]\n  locations  GlobalLocation[]\n  voices     GlobalVoice[]\n\n  @@index([userId])\n  @@map(\"global_asset_folders\")\n}\n\n// 全局角色（结构与 NovelPromotionCharacter 一致）\nmodel GlobalCharacter {\n  id               String   @id @default(uuid())\n  userId           String\n  folderId         String?\n  name             String\n  aliases          String?  @db.Text\n  profileData      String?  @db.Text\n  profileConfirmed Boolean  @default(false)\n  voiceId          String?\n  voiceType        String?\n  customVoiceUrl   String?  @db.Text\n  customVoiceMediaId String?\n  customVoiceMedia MediaObject? @relation(\"GlobalCharacterVoiceMedia\", fields: [customVoiceMediaId], references: [id], onDelete: SetNull)\n  globalVoiceId    String? // 绑定的全局音色 ID\n  createdAt        DateTime @default(now())\n  updatedAt        DateTime @updatedAt\n\n  user        User                        @relation(fields: [userId], references: [id], onDelete: Cascade)\n  folder      GlobalAssetFolder?          @relation(fields: [folderId], references: [id], onDelete: SetNull)\n  appearances GlobalCharacterAppearance[]\n\n  @@index([userId])\n  @@index([folderId])\n  @@index([customVoiceMediaId])\n  @@map(\"global_characters\")\n}\n\n// 全局角色形象（结构与 CharacterAppearance 一致）\nmodel GlobalCharacterAppearance {\n  id                   String   @id @default(uuid())\n  characterId          String\n  appearanceIndex      Int\n  changeReason         String   @default(\"default\")\n  artStyle             String?\n  description          String?  @db.Text\n  descriptions         String?  @db.Text\n  imageUrl             String?  @db.Text\n  imageMediaId         String?\n  imageMedia           MediaObject? @relation(\"GlobalCharacterAppearanceImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  imageUrls            String?  @db.Text\n  selectedIndex        Int?\n  previousImageUrl     String?  @db.Text\n  previousImageMediaId String?\n  previousImageMedia   MediaObject? @relation(\"GlobalCharacterAppearancePreviousImageMedia\", fields: [previousImageMediaId], references: [id], onDelete: SetNull)\n  previousImageUrls    String?  @db.Text\n  previousDescription  String?  @db.Text // 上一次的描述词（用于撤回）\n  previousDescriptions String?  @db.Text // 上一次的描述词数组（用于撤回）\n  createdAt            DateTime @default(now())\n  updatedAt            DateTime @updatedAt\n\n  character GlobalCharacter @relation(fields: [characterId], references: [id], onDelete: Cascade)\n\n  @@unique([characterId, appearanceIndex])\n  @@index([characterId])\n  @@index([imageMediaId])\n  @@index([previousImageMediaId])\n  @@map(\"global_character_appearances\")\n}\n\n// 全局场景（结构与 NovelPromotionLocation 一致）\nmodel GlobalLocation {\n  id        String   @id @default(uuid())\n  userId    String\n  folderId  String?\n  name      String\n  artStyle  String?\n  summary   String?  @db.Text\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  user   User                  @relation(fields: [userId], references: [id], onDelete: Cascade)\n  folder GlobalAssetFolder?    @relation(fields: [folderId], references: [id], onDelete: SetNull)\n  images GlobalLocationImage[]\n\n  @@index([userId])\n  @@index([folderId])\n  @@map(\"global_locations\")\n}\n\n// 全局场景图片（结构与 LocationImage 一致）\nmodel GlobalLocationImage {\n  id                  String   @id @default(uuid())\n  locationId          String\n  imageIndex          Int\n  description         String?  @db.Text\n  imageUrl            String?  @db.Text\n  imageMediaId        String?\n  imageMedia          MediaObject? @relation(\"GlobalLocationImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  isSelected          Boolean  @default(false)\n  previousImageUrl    String?  @db.Text\n  previousImageMediaId String?\n  previousImageMedia  MediaObject? @relation(\"GlobalLocationImagePreviousImageMedia\", fields: [previousImageMediaId], references: [id], onDelete: SetNull)\n  previousDescription String?  @db.Text // 上一次的描述词（用于撤回）\n  createdAt           DateTime @default(now())\n  updatedAt           DateTime @updatedAt\n\n  location GlobalLocation @relation(fields: [locationId], references: [id], onDelete: Cascade)\n\n  @@unique([locationId, imageIndex])\n  @@index([locationId])\n  @@index([imageMediaId])\n  @@index([previousImageMediaId])\n  @@map(\"global_location_images\")\n}\n\n// 全局音色库\nmodel GlobalVoice {\n  id             String   @id @default(uuid())\n  userId         String\n  folderId       String?\n  name           String // 音色名称\n  description    String?  @db.Text // 详细描述\n  voiceId        String? // qwen-tts-vd 的 voice ID\n  voiceType      String   @default(\"qwen-designed\") // qwen-designed | custom\n  customVoiceUrl String?  @db.Text // 上传的音频 URL（预览用）\n  customVoiceMediaId String?\n  customVoiceMedia MediaObject? @relation(\"GlobalVoiceCustomVoiceMedia\", fields: [customVoiceMediaId], references: [id], onDelete: SetNull)\n  voicePrompt    String?  @db.Text // AI 设计时的提示词\n  gender         String? // male | female | neutral\n  language       String   @default(\"zh\")\n  createdAt      DateTime @default(now())\n  updatedAt      DateTime @updatedAt\n\n  user   User               @relation(fields: [userId], references: [id], onDelete: Cascade)\n  folder GlobalAssetFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)\n\n  @@index([userId])\n  @@index([folderId])\n  @@index([customVoiceMediaId])\n  @@map(\"global_voices\")\n}\n\nmodel MediaObject {\n  id         String   @id @default(uuid())\n  publicId   String   @unique\n  storageKey String   @unique @db.VarChar(512)\n  sha256     String?\n  mimeType   String?\n  sizeBytes  BigInt?\n  width      Int?\n  height     Int?\n  durationMs Int?\n  createdAt  DateTime @default(now())\n  updatedAt  DateTime @default(now()) @updatedAt\n\n  characterAppearanceImages             CharacterAppearance[]       @relation(\"CharacterAppearanceImageMedia\")\n  locationImages                        LocationImage[]             @relation(\"LocationImageMedia\")\n  novelPromotionCharacterVoices         NovelPromotionCharacter[]   @relation(\"NovelPromotionCharacterVoiceMedia\")\n  novelPromotionEpisodeAudios           NovelPromotionEpisode[]     @relation(\"NovelPromotionEpisodeAudioMedia\")\n  novelPromotionPanelImages             NovelPromotionPanel[]       @relation(\"NovelPromotionPanelImageMedia\")\n  novelPromotionPanelVideos             NovelPromotionPanel[]       @relation(\"NovelPromotionPanelVideoMedia\")\n  novelPromotionPanelLipSyncVideos      NovelPromotionPanel[]       @relation(\"NovelPromotionPanelLipSyncVideoMedia\")\n  novelPromotionPanelSketchImages       NovelPromotionPanel[]       @relation(\"NovelPromotionPanelSketchMedia\")\n  novelPromotionPanelPreviousImages     NovelPromotionPanel[]       @relation(\"NovelPromotionPanelPreviousImageMedia\")\n  novelPromotionShotImages              NovelPromotionShot[]        @relation(\"NovelPromotionShotImageMedia\")\n  supplementaryPanelImages              SupplementaryPanel[]        @relation(\"SupplementaryPanelImageMedia\")\n  novelPromotionVoiceLineAudios         NovelPromotionVoiceLine[]   @relation(\"NovelPromotionVoiceLineAudioMedia\")\n  voicePresetAudios                     VoicePreset[]               @relation(\"VoicePresetAudioMedia\")\n  globalCharacterVoices                 GlobalCharacter[]           @relation(\"GlobalCharacterVoiceMedia\")\n  globalCharacterAppearanceImages       GlobalCharacterAppearance[] @relation(\"GlobalCharacterAppearanceImageMedia\")\n  globalCharacterAppearancePreviousImgs GlobalCharacterAppearance[] @relation(\"GlobalCharacterAppearancePreviousImageMedia\")\n  globalLocationImageImages             GlobalLocationImage[]       @relation(\"GlobalLocationImageMedia\")\n  globalLocationImagePreviousImages     GlobalLocationImage[]       @relation(\"GlobalLocationImagePreviousImageMedia\")\n  globalVoiceCustomVoices               GlobalVoice[]               @relation(\"GlobalVoiceCustomVoiceMedia\")\n\n  @@index([createdAt])\n  @@map(\"media_objects\")\n}\n\nmodel LegacyMediaRefBackup {\n  id          String   @id @default(uuid())\n  runId       String\n  tableName   String\n  rowId       String\n  fieldName   String\n  legacyValue String   @db.Text\n  checksum    String\n  createdAt   DateTime @default(now())\n\n  @@index([runId])\n  @@index([tableName, fieldName])\n  @@map(\"legacy_media_refs_backup\")\n}\n"
  },
  {
    "path": "prisma/schema.sqlit.prisma",
    "content": "generator client {\n  provider = \"prisma-client-js\"\n}\n\ndatasource db {\n  provider = \"sqlite\"\n  url      = env(\"DATABASE_URL\")\n}\n\nmodel Account {\n  id                String  @id @default(uuid())\n  userId            String\n  type              String\n  provider          String\n  providerAccountId String\n  refresh_token     String?\n  access_token      String?\n  expires_at        Int?\n  token_type        String?\n  scope             String?\n  id_token          String?\n  session_state     String?\n  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([provider, providerAccountId])\n  @@index([userId])\n  @@map(\"account\")\n}\n\nmodel CharacterAppearance {\n  id                   String                  @id @default(uuid())\n  characterId          String\n  appearanceIndex      Int\n  changeReason         String\n  description          String?\n  descriptions         String?\n  imageUrl             String?\n  imageUrls            String?\n  selectedIndex        Int?\n  createdAt            DateTime                @default(now())\n  updatedAt            DateTime                @default(now()) @updatedAt\n  previousImageUrl     String?\n  previousImageUrls    String?\n  previousDescription  String? // 上一次的描述词（用于撤回）\n  previousDescriptions String? // 上一次的描述词数组（用于撤回）\n  imageMediaId         String?\n  imageMedia           MediaObject?            @relation(\"CharacterAppearanceImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  character            NovelPromotionCharacter @relation(fields: [characterId], references: [id], onDelete: Cascade)\n\n  @@unique([characterId, appearanceIndex])\n  @@index([characterId])\n  @@index([imageMediaId])\n  @@map(\"character_appearances\")\n}\n\nmodel LocationImage {\n  id                  String                 @id @default(uuid())\n  locationId          String\n  imageIndex          Int\n  description         String?\n  imageUrl            String?\n  isSelected          Boolean                @default(false)\n  createdAt           DateTime               @default(now())\n  updatedAt           DateTime               @default(now()) @updatedAt\n  previousImageUrl    String?\n  previousDescription String? // 上一次的描述词（用于撤回）\n  imageMediaId        String?\n  imageMedia          MediaObject?             @relation(\"LocationImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  location            NovelPromotionLocation @relation(fields: [locationId], references: [id], onDelete: Cascade)\n\n  @@unique([locationId, imageIndex])\n  @@index([locationId])\n  @@index([imageMediaId])\n  @@map(\"location_images\")\n}\n\nmodel NovelPromotionCharacter {\n  id                      String                @id @default(uuid())\n  novelPromotionProjectId String\n  name                    String\n  aliases                 String?\n  createdAt               DateTime              @default(now())\n  updatedAt               DateTime              @default(now()) @updatedAt\n  customVoiceUrl          String?\n  customVoiceMediaId      String?\n  customVoiceMedia        MediaObject?          @relation(\"NovelPromotionCharacterVoiceMedia\", fields: [customVoiceMediaId], references: [id], onDelete: SetNull)\n  voiceId                 String?\n  voiceType               String?\n  profileData             String?\n  profileConfirmed        Boolean               @default(false)\n  introduction            String? // 角色介绍（身份、关系、称呼映射，如\"我\"对应此角色）\n  sourceGlobalCharacterId String? // 🆕 来源全局角色ID（复制时记录）\n  appearances             CharacterAppearance[]\n  novelPromotionProject   NovelPromotionProject @relation(fields: [novelPromotionProjectId], references: [id], onDelete: Cascade)\n\n  @@index([novelPromotionProjectId])\n  @@index([customVoiceMediaId])\n  @@map(\"novel_promotion_characters\")\n}\n\nmodel NovelPromotionLocation {\n  id                      String                @id @default(uuid())\n  novelPromotionProjectId String\n  name                    String\n  summary                 String? // 场景简要描述（用途/人物关联）\n  createdAt               DateTime              @default(now())\n  updatedAt               DateTime              @default(now()) @updatedAt\n  sourceGlobalLocationId  String? // 🆕 来源全局场景ID（复制时记录）\n  images                  LocationImage[]\n  novelPromotionProject   NovelPromotionProject @relation(fields: [novelPromotionProjectId], references: [id], onDelete: Cascade)\n\n  @@index([novelPromotionProjectId])\n  @@map(\"novel_promotion_locations\")\n}\n\nmodel NovelPromotionEpisode {\n  id                      String                     @id @default(uuid())\n  novelPromotionProjectId String\n  episodeNumber           Int\n  name                    String\n  description             String?\n  novelText               String?\n  audioUrl                String?\n  audioMediaId            String?\n  audioMedia              MediaObject?               @relation(\"NovelPromotionEpisodeAudioMedia\", fields: [audioMediaId], references: [id], onDelete: SetNull)\n  srtContent              String?\n  createdAt               DateTime                   @default(now())\n  updatedAt               DateTime                   @default(now()) @updatedAt\n  speakerVoices           String?\n  clips                   NovelPromotionClip[]\n  novelPromotionProject   NovelPromotionProject      @relation(fields: [novelPromotionProjectId], references: [id], onDelete: Cascade)\n  shots                   NovelPromotionShot[]\n  storyboards             NovelPromotionStoryboard[]\n  voiceLines              NovelPromotionVoiceLine[]\n  editorProject           VideoEditorProject?\n\n  @@unique([novelPromotionProjectId, episodeNumber])\n  @@index([novelPromotionProjectId])\n  @@index([audioMediaId])\n  @@map(\"novel_promotion_episodes\")\n}\n\n// 视频编辑器项目 - 存储剪辑数据\nmodel VideoEditorProject {\n  id           String                @id @default(uuid())\n  episodeId    String                @unique\n  projectData  String // JSON 存储编辑项目数据\n  renderStatus String? // pending | rendering | completed | failed\n  renderTaskId String?\n  outputUrl    String?\n  createdAt    DateTime              @default(now())\n  updatedAt    DateTime              @default(now()) @updatedAt\n  episode      NovelPromotionEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n\n  @@map(\"video_editor_projects\")\n}\n\nmodel NovelPromotionClip {\n  id         String                    @id @default(uuid())\n  episodeId  String\n  start      Int?\n  end        Int?\n  duration   Int?\n  summary    String\n  location   String?\n  content    String\n  createdAt  DateTime                  @default(now())\n  updatedAt  DateTime                  @default(now()) @updatedAt\n  characters String?\n  endText    String?\n  shotCount  Int?\n  startText  String?\n  screenplay String?\n  episode    NovelPromotionEpisode     @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n  shots      NovelPromotionShot[]\n  storyboard NovelPromotionStoryboard?\n\n  @@index([episodeId])\n  @@map(\"novel_promotion_clips\")\n}\n\nmodel NovelPromotionPanel {\n  id                String                    @id @default(uuid())\n  storyboardId      String\n  panelIndex        Int\n  panelNumber       Int?\n  shotType          String?\n  cameraMove        String?\n  description       String?\n  location          String?\n  characters        String?\n  srtSegment        String?\n  srtStart          Float?\n  srtEnd            Float?\n  duration          Float?\n  imagePrompt       String?\n  imageUrl          String?\n  imageMediaId      String?\n  imageMedia        MediaObject?              @relation(\"NovelPromotionPanelImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  imageHistory      String?\n  videoPrompt       String?\n  firstLastFramePrompt String?\n  videoUrl          String?\n  videoGenerationMode String?                 // 视频生成方式：normal | firstlastframe\n  videoMediaId      String?\n  videoMedia        MediaObject?              @relation(\"NovelPromotionPanelVideoMedia\", fields: [videoMediaId], references: [id], onDelete: SetNull)\n  createdAt         DateTime                  @default(now())\n  updatedAt         DateTime                  @default(now()) @updatedAt\n  sceneType         String?\n  candidateImages   String?\n  linkedToNextPanel Boolean                   @default(false)\n  lipSyncTaskId     String?\n  lipSyncVideoUrl   String?\n  lipSyncVideoMediaId String?\n  lipSyncVideoMedia MediaObject?              @relation(\"NovelPromotionPanelLipSyncVideoMedia\", fields: [lipSyncVideoMediaId], references: [id], onDelete: SetNull)\n  sketchImageUrl    String?\n  sketchImageMediaId String?\n  sketchImageMedia  MediaObject?              @relation(\"NovelPromotionPanelSketchMedia\", fields: [sketchImageMediaId], references: [id], onDelete: SetNull)\n  photographyRules  String?\n  actingNotes       String? // 演技指导数据 JSON\n  previousImageUrl  String?\n  previousImageMediaId String?\n  previousImageMedia MediaObject?             @relation(\"NovelPromotionPanelPreviousImageMedia\", fields: [previousImageMediaId], references: [id], onDelete: SetNull)\n  storyboard        NovelPromotionStoryboard  @relation(fields: [storyboardId], references: [id], onDelete: Cascade)\n  matchedVoiceLines NovelPromotionVoiceLine[]\n\n  @@unique([storyboardId, panelIndex])\n  @@index([storyboardId])\n  @@index([imageMediaId])\n  @@index([videoMediaId])\n  @@index([lipSyncVideoMediaId])\n  @@index([sketchImageMediaId])\n  @@index([previousImageMediaId])\n  @@map(\"novel_promotion_panels\")\n}\n\nmodel NovelPromotionProject {\n  id              String                    @id @default(uuid())\n  projectId       String                    @unique\n  createdAt       DateTime                  @default(now())\n  updatedAt       DateTime                  @default(now()) @updatedAt\n  analysisModel   String? // 用户配置的分析模型（nullable，必须配置后才能使用）\n  imageModel      String? // 用户配置的图片模型\n  videoModel      String? // 用户配置的视频模型\n  audioModel      String? // 用户配置的语音模型\n  videoRatio      String                    @default(\"9:16\")\n  ttsRate         String                    @default(\"+50%\")\n  globalAssetText String?\n  artStyle        String                    @default(\"american-comic\")\n  artStylePrompt  String?\n  characterModel  String? // 用户配置的角色图片模型\n  locationModel   String? // 用户配置的场景图片模型\n  storyboardModel String? // 用户配置的分镜图片模型\n  editModel       String? // 用户配置的修图/编辑模型\n  videoResolution String                    @default(\"720p\")\n  capabilityOverrides String?\n  workflowMode    String                    @default(\"srt\")\n  lastEpisodeId   String?\n  imageResolution String                    @default(\"2K\")\n  importStatus    String?\n  characters      NovelPromotionCharacter[]\n  episodes        NovelPromotionEpisode[]\n  locations       NovelPromotionLocation[]\n  project         Project                   @relation(fields: [projectId], references: [id], onDelete: Cascade)\n\n  @@map(\"novel_promotion_projects\")\n}\n\nmodel NovelPromotionShot {\n  id              String                @id @default(uuid())\n  episodeId       String\n  clipId          String?\n  shotId          String\n  srtStart        Int\n  srtEnd          Int\n  srtDuration     Float\n  sequence        String?\n  locations       String?\n  characters      String?\n  plot            String?\n  imagePrompt     String?\n  scale           String?\n  module          String?\n  focus           String?\n  zhSummarize     String?\n  imageUrl        String?\n  imageMediaId    String?\n  imageMedia      MediaObject?          @relation(\"NovelPromotionShotImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  createdAt       DateTime              @default(now())\n  updatedAt       DateTime              @default(now()) @updatedAt\n  pov             String?\n  clip            NovelPromotionClip?   @relation(fields: [clipId], references: [id], onDelete: Cascade)\n  episode         NovelPromotionEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n\n  @@index([clipId])\n  @@index([episodeId])\n  @@index([shotId])\n  @@index([imageMediaId])\n  @@map(\"novel_promotion_shots\")\n}\n\nmodel NovelPromotionStoryboard {\n  id                  String                @id @default(uuid())\n  episodeId           String\n  clipId              String                @unique\n  storyboardImageUrl  String?\n  createdAt           DateTime              @default(now())\n  updatedAt           DateTime              @default(now()) @updatedAt\n  panelCount          Int                   @default(9)\n  storyboardTextJson  String?\n  imageHistory        String?\n  candidateImages     String?\n  lastError           String?\n  photographyPlan     String?\n  panels              NovelPromotionPanel[]\n  clip                NovelPromotionClip    @relation(fields: [clipId], references: [id], onDelete: Cascade)\n  episode             NovelPromotionEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n  supplementaryPanels SupplementaryPanel[]\n\n  @@index([clipId])\n  @@index([episodeId])\n  @@map(\"novel_promotion_storyboards\")\n}\n\nmodel SupplementaryPanel {\n  id            String                   @id @default(uuid())\n  storyboardId  String\n  sourceType    String\n  sourcePanelId String?\n  description   String?\n  imagePrompt   String?\n  imageUrl      String?\n  imageMediaId  String?\n  imageMedia    MediaObject?             @relation(\"SupplementaryPanelImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  characters    String?\n  location      String?\n  createdAt     DateTime                 @default(now())\n  updatedAt     DateTime                 @default(now()) @updatedAt\n  storyboard    NovelPromotionStoryboard @relation(fields: [storyboardId], references: [id], onDelete: Cascade)\n\n  @@index([storyboardId])\n  @@index([imageMediaId])\n  @@map(\"supplementary_panels\")\n}\n\nmodel Project {\n  id                 String                 @id @default(uuid())\n  name               String\n  description        String?\n  mode               String                 @default(\"novel-promotion\")\n  userId             String\n  createdAt          DateTime               @default(now())\n  updatedAt          DateTime               @default(now()) @updatedAt\n  lastAccessedAt     DateTime?\n  novelPromotionData NovelPromotionProject?\n  user               User                   @relation(fields: [userId], references: [id], onDelete: Cascade)\n  usageCosts         UsageCost[]\n\n  @@index([userId])\n  @@map(\"projects\")\n}\n\nmodel Session {\n  id           String   @id @default(uuid())\n  sessionToken String   @unique(map: \"Session_sessionToken_key\")\n  userId       String\n  expires      DateTime\n  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([userId])\n  @@map(\"session\")\n}\n\nmodel UsageCost {\n  id        String   @id @default(uuid())\n  projectId String\n  userId    String\n  apiType   String\n  model     String\n  action    String\n  quantity  Int\n  unit      String\n  cost      Decimal\n  metadata  String?\n  createdAt DateTime @default(now())\n  project   Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)\n  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([apiType])\n  @@index([createdAt])\n  @@index([projectId])\n  @@index([userId])\n  @@map(\"usage_costs\")\n}\n\nmodel User {\n  id            String          @id @default(uuid())\n  name          String          @unique(map: \"User_name_key\")\n  email         String?\n  emailVerified DateTime?\n  image         String?\n  password      String?\n  createdAt     DateTime        @default(now())\n  updatedAt     DateTime        @default(now()) @updatedAt\n  accounts      Account[]\n  projects      Project[]\n  sessions      Session[]\n  usageCosts    UsageCost[]\n  balance       UserBalance?\n  preferences   UserPreference?\n\n  // 资产中心\n  globalAssetFolders GlobalAssetFolder[]\n  globalCharacters   GlobalCharacter[]\n  globalLocations    GlobalLocation[]\n  globalVoices       GlobalVoice[]\n  tasks              Task[]\n  taskEvents         TaskEvent[]\n\n  @@map(\"user\")\n}\n\nmodel UserPreference {\n  id              String   @id @default(uuid())\n  userId          String   @unique\n  analysisModel   String? // 用户配置的分析模型（nullable，必须配置后才能使用）\n  characterModel  String? // 用户配置的角色图片模型\n  locationModel   String? // 用户配置的场景图片模型\n  storyboardModel String? // 用户配置的分镜图片模型\n  editModel       String? // 用户配置的修图模型\n  videoModel      String? // 用户配置的视频模型\n  audioModel      String? // 用户配置的语音模型\n  lipSyncModel    String? // 用户配置的口型同步模型\n  voiceDesignModel String? // 用户配置的音色设计模型\n  analysisConcurrency Int? // 分析流程并发上限\n  imageConcurrency Int? // 图像流程并发上限\n  videoConcurrency Int? // 视频流程并发上限\n  videoRatio      String   @default(\"9:16\")\n  videoResolution String   @default(\"720p\")\n  artStyle        String   @default(\"american-comic\")\n  ttsRate         String   @default(\"+50%\")\n  createdAt       DateTime @default(now())\n  updatedAt       DateTime @default(now()) @updatedAt\n  imageResolution String   @default(\"2K\")\n  capabilityDefaults String?\n\n  // API Key 配置（极简版）\n  llmBaseUrl  String? @default(\"https://openrouter.ai/api/v1\")\n  llmApiKey   String? // 加密存储\n  falApiKey   String? // FAL（图片+视频+语音）\n  googleAiKey String? // Google AI（Gemini 图片）\n  arkApiKey   String? // 火山引擎（Seedream+Seedance）\n  qwenApiKey  String? // 阿里百炼（声音设计）\n\n  // 自定义模型列表 + 价格（JSON）\n  customModels String?\n\n  // 自定义 OpenAI 兼容提供商列表（JSON，包含加密的 API Key）\n  customProviders String?\n\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@map(\"user_preferences\")\n}\n\nmodel VerificationToken {\n  identifier String\n  token      String   @unique(map: \"VerificationToken_token_key\")\n  expires    DateTime\n\n  @@unique([identifier, token])\n  @@map(\"verificationtoken\")\n}\n\nmodel NovelPromotionVoiceLine {\n  id                  String                @id @default(uuid())\n  episodeId           String\n  lineIndex           Int\n  speaker             String\n  content             String\n  voicePresetId       String?\n  audioUrl            String?\n  audioMediaId        String?\n  audioMedia          MediaObject?          @relation(\"NovelPromotionVoiceLineAudioMedia\", fields: [audioMediaId], references: [id], onDelete: SetNull)\n  createdAt           DateTime              @default(now())\n  updatedAt           DateTime              @default(now()) @updatedAt\n  emotionPrompt       String?\n  emotionStrength     Float?                @default(0.4)\n  matchedPanelIndex   Int?\n  matchedStoryboardId String?\n  audioDuration       Int?\n  matchedPanelId      String?\n  episode             NovelPromotionEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)\n  matchedPanel        NovelPromotionPanel?  @relation(fields: [matchedPanelId], references: [id])\n\n  @@unique([episodeId, lineIndex])\n  @@index([episodeId])\n  @@index([matchedPanelId])\n  @@index([audioMediaId])\n  @@map(\"novel_promotion_voice_lines\")\n}\n\nmodel VoicePreset {\n  id          String   @id @default(uuid())\n  name        String\n  audioUrl    String\n  audioMediaId String?\n  audioMedia  MediaObject? @relation(\"VoicePresetAudioMedia\", fields: [audioMediaId], references: [id], onDelete: SetNull)\n  description String?\n  gender      String?\n  isSystem    Boolean  @default(true)\n  createdAt   DateTime @default(now())\n\n  @@index([audioMediaId])\n  @@map(\"voice_presets\")\n}\n\nmodel UserBalance {\n  id           String   @id @default(uuid())\n  userId       String   @unique\n  balance      Decimal  @default(0)\n  frozenAmount Decimal  @default(0)\n  totalSpent   Decimal  @default(0)\n  createdAt    DateTime @default(now())\n  updatedAt    DateTime @default(now()) @updatedAt\n  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@map(\"user_balances\")\n}\n\nmodel BalanceFreeze {\n  id        String   @id @default(uuid())\n  userId    String\n  amount    Decimal\n  status    String   @default(\"pending\")\n  source    String?\n  taskId    String?\n  requestId String?\n  idempotencyKey String? @unique\n  metadata  String?\n  expiresAt DateTime?\n  createdAt DateTime @default(now())\n  updatedAt DateTime @default(now()) @updatedAt\n\n  @@index([userId])\n  @@index([status])\n  @@index([taskId])\n  @@map(\"balance_freezes\")\n}\n\nmodel BalanceTransaction {\n  id           String   @id @default(uuid())\n  userId       String\n  type         String\n  amount       Decimal\n  balanceAfter Decimal\n  description  String?\n  relatedId    String?\n  freezeId     String?\n  operatorId   String?\n  externalOrderId String?\n  idempotencyKey String?\n  createdAt    DateTime @default(now())\n\n  @@index([userId])\n  @@index([type])\n  @@index([createdAt])\n  @@index([freezeId])\n  @@index([externalOrderId])\n  @@unique([userId, type, idempotencyKey])\n  @@map(\"balance_transactions\")\n}\n\nmodel Task {\n  id               String    @id @default(uuid())\n  userId           String\n  projectId        String\n  episodeId        String?\n  type             String\n  targetType       String\n  targetId         String\n  status           String    @default(\"queued\")\n  progress         Int       @default(0)\n  attempt          Int       @default(0)\n  maxAttempts      Int       @default(5)\n  priority         Int       @default(0)\n  dedupeKey        String?   @unique\n  externalId       String?\n  payload          Json?\n  result           Json?\n  errorCode        String?\n  errorMessage     String?\n  billingInfo      Json?\n  billedAt         DateTime?\n  queuedAt         DateTime  @default(now())\n  startedAt        DateTime?\n  finishedAt       DateTime?\n  heartbeatAt      DateTime?\n  enqueuedAt       DateTime?\n  enqueueAttempts  Int       @default(0)\n  lastEnqueueError String?\n  createdAt        DateTime  @default(now())\n  updatedAt        DateTime  @updatedAt\n\n  user   User        @relation(fields: [userId], references: [id], onDelete: Cascade)\n  events TaskEvent[]\n\n  @@index([status])\n  @@index([type])\n  @@index([targetType, targetId])\n  @@index([projectId])\n  @@index([userId])\n  @@index([heartbeatAt])\n  @@map(\"tasks\")\n}\n\nmodel TaskEvent {\n  id        Int      @id @default(autoincrement())\n  taskId    String\n  projectId String\n  userId    String\n  eventType String\n  payload   Json?\n  createdAt DateTime @default(now())\n\n  task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([projectId, id])\n  @@index([taskId])\n  @@index([userId])\n  @@map(\"task_events\")\n}\n\n// ==================== 资产中心 ====================\n\n// 资产文件夹（一层，不支持嵌套）\nmodel GlobalAssetFolder {\n  id        String   @id @default(uuid())\n  userId    String\n  name      String\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  user       User              @relation(fields: [userId], references: [id], onDelete: Cascade)\n  characters GlobalCharacter[]\n  locations  GlobalLocation[]\n  voices     GlobalVoice[]\n\n  @@index([userId])\n  @@map(\"global_asset_folders\")\n}\n\n// 全局角色（结构与 NovelPromotionCharacter 一致）\nmodel GlobalCharacter {\n  id               String   @id @default(uuid())\n  userId           String\n  folderId         String?\n  name             String\n  aliases          String?\n  profileData      String?\n  profileConfirmed Boolean  @default(false)\n  voiceId          String?\n  voiceType        String?\n  customVoiceUrl   String?\n  customVoiceMediaId String?\n  customVoiceMedia MediaObject? @relation(\"GlobalCharacterVoiceMedia\", fields: [customVoiceMediaId], references: [id], onDelete: SetNull)\n  globalVoiceId    String? // 绑定的全局音色 ID\n  createdAt        DateTime @default(now())\n  updatedAt        DateTime @updatedAt\n\n  user        User                        @relation(fields: [userId], references: [id], onDelete: Cascade)\n  folder      GlobalAssetFolder?          @relation(fields: [folderId], references: [id], onDelete: SetNull)\n  appearances GlobalCharacterAppearance[]\n\n  @@index([userId])\n  @@index([folderId])\n  @@index([customVoiceMediaId])\n  @@map(\"global_characters\")\n}\n\n// 全局角色形象（结构与 CharacterAppearance 一致）\nmodel GlobalCharacterAppearance {\n  id                   String   @id @default(uuid())\n  characterId          String\n  appearanceIndex      Int\n  changeReason         String   @default(\"default\")\n  artStyle             String?\n  description          String?\n  descriptions         String?\n  imageUrl             String?\n  imageMediaId         String?\n  imageMedia           MediaObject? @relation(\"GlobalCharacterAppearanceImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  imageUrls            String?\n  selectedIndex        Int?\n  previousImageUrl     String?\n  previousImageMediaId String?\n  previousImageMedia   MediaObject? @relation(\"GlobalCharacterAppearancePreviousImageMedia\", fields: [previousImageMediaId], references: [id], onDelete: SetNull)\n  previousImageUrls    String?\n  previousDescription  String? // 上一次的描述词（用于撤回）\n  previousDescriptions String? // 上一次的描述词数组（用于撤回）\n  createdAt            DateTime @default(now())\n  updatedAt            DateTime @updatedAt\n\n  character GlobalCharacter @relation(fields: [characterId], references: [id], onDelete: Cascade)\n\n  @@unique([characterId, appearanceIndex])\n  @@index([characterId])\n  @@index([imageMediaId])\n  @@index([previousImageMediaId])\n  @@map(\"global_character_appearances\")\n}\n\n// 全局场景（结构与 NovelPromotionLocation 一致）\nmodel GlobalLocation {\n  id        String   @id @default(uuid())\n  userId    String\n  folderId  String?\n  name      String\n  artStyle  String?\n  summary   String?\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  user   User                  @relation(fields: [userId], references: [id], onDelete: Cascade)\n  folder GlobalAssetFolder?    @relation(fields: [folderId], references: [id], onDelete: SetNull)\n  images GlobalLocationImage[]\n\n  @@index([userId])\n  @@index([folderId])\n  @@map(\"global_locations\")\n}\n\n// 全局场景图片（结构与 LocationImage 一致）\nmodel GlobalLocationImage {\n  id                  String   @id @default(uuid())\n  locationId          String\n  imageIndex          Int\n  description         String?\n  imageUrl            String?\n  imageMediaId        String?\n  imageMedia          MediaObject? @relation(\"GlobalLocationImageMedia\", fields: [imageMediaId], references: [id], onDelete: SetNull)\n  isSelected          Boolean  @default(false)\n  previousImageUrl    String?\n  previousImageMediaId String?\n  previousImageMedia  MediaObject? @relation(\"GlobalLocationImagePreviousImageMedia\", fields: [previousImageMediaId], references: [id], onDelete: SetNull)\n  previousDescription String? // 上一次的描述词（用于撤回）\n  createdAt           DateTime @default(now())\n  updatedAt           DateTime @updatedAt\n\n  location GlobalLocation @relation(fields: [locationId], references: [id], onDelete: Cascade)\n\n  @@unique([locationId, imageIndex])\n  @@index([locationId])\n  @@index([imageMediaId])\n  @@index([previousImageMediaId])\n  @@map(\"global_location_images\")\n}\n\n// 全局音色库\nmodel GlobalVoice {\n  id             String   @id @default(uuid())\n  userId         String\n  folderId       String?\n  name           String // 音色名称\n  description    String? // 详细描述\n  voiceId        String? // qwen-tts-vd 的 voice ID\n  voiceType      String   @default(\"qwen-designed\") // qwen-designed | custom\n  customVoiceUrl String? // 上传的音频 URL（预览用）\n  customVoiceMediaId String?\n  customVoiceMedia MediaObject? @relation(\"GlobalVoiceCustomVoiceMedia\", fields: [customVoiceMediaId], references: [id], onDelete: SetNull)\n  voicePrompt    String? // AI 设计时的提示词\n  gender         String? // male | female | neutral\n  language       String   @default(\"zh\")\n  createdAt      DateTime @default(now())\n  updatedAt      DateTime @updatedAt\n\n  user   User               @relation(fields: [userId], references: [id], onDelete: Cascade)\n  folder GlobalAssetFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)\n\n  @@index([userId])\n  @@index([folderId])\n  @@index([customVoiceMediaId])\n  @@map(\"global_voices\")\n}\n\nmodel MediaObject {\n  id         String   @id @default(uuid())\n  publicId   String   @unique\n  storageKey String   @unique\n  sha256     String?\n  mimeType   String?\n  sizeBytes  BigInt?\n  width      Int?\n  height     Int?\n  durationMs Int?\n  createdAt  DateTime @default(now())\n  updatedAt  DateTime @default(now()) @updatedAt\n\n  characterAppearanceImages             CharacterAppearance[]       @relation(\"CharacterAppearanceImageMedia\")\n  locationImages                        LocationImage[]             @relation(\"LocationImageMedia\")\n  novelPromotionCharacterVoices         NovelPromotionCharacter[]   @relation(\"NovelPromotionCharacterVoiceMedia\")\n  novelPromotionEpisodeAudios           NovelPromotionEpisode[]     @relation(\"NovelPromotionEpisodeAudioMedia\")\n  novelPromotionPanelImages             NovelPromotionPanel[]       @relation(\"NovelPromotionPanelImageMedia\")\n  novelPromotionPanelVideos             NovelPromotionPanel[]       @relation(\"NovelPromotionPanelVideoMedia\")\n  novelPromotionPanelLipSyncVideos      NovelPromotionPanel[]       @relation(\"NovelPromotionPanelLipSyncVideoMedia\")\n  novelPromotionPanelSketchImages       NovelPromotionPanel[]       @relation(\"NovelPromotionPanelSketchMedia\")\n  novelPromotionPanelPreviousImages     NovelPromotionPanel[]       @relation(\"NovelPromotionPanelPreviousImageMedia\")\n  novelPromotionShotImages              NovelPromotionShot[]        @relation(\"NovelPromotionShotImageMedia\")\n  supplementaryPanelImages              SupplementaryPanel[]        @relation(\"SupplementaryPanelImageMedia\")\n  novelPromotionVoiceLineAudios         NovelPromotionVoiceLine[]   @relation(\"NovelPromotionVoiceLineAudioMedia\")\n  voicePresetAudios                     VoicePreset[]               @relation(\"VoicePresetAudioMedia\")\n  globalCharacterVoices                 GlobalCharacter[]           @relation(\"GlobalCharacterVoiceMedia\")\n  globalCharacterAppearanceImages       GlobalCharacterAppearance[] @relation(\"GlobalCharacterAppearanceImageMedia\")\n  globalCharacterAppearancePreviousImgs GlobalCharacterAppearance[] @relation(\"GlobalCharacterAppearancePreviousImageMedia\")\n  globalLocationImageImages             GlobalLocationImage[]       @relation(\"GlobalLocationImageMedia\")\n  globalLocationImagePreviousImages     GlobalLocationImage[]       @relation(\"GlobalLocationImagePreviousImageMedia\")\n  globalVoiceCustomVoices               GlobalVoice[]               @relation(\"GlobalVoiceCustomVoiceMedia\")\n\n  @@index([createdAt])\n  @@map(\"media_objects\")\n}\n\nmodel LegacyMediaRefBackup {\n  id          String   @id @default(uuid())\n  runId       String\n  tableName   String\n  rowId       String\n  fieldName   String\n  legacyValue String\n  checksum    String\n  createdAt   DateTime @default(now())\n\n  @@index([runId])\n  @@index([tableName, fieldName])\n  @@map(\"legacy_media_refs_backup\")\n}\n"
  },
  {
    "path": "scripts/billing-cleanup-pending-freezes.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { toMoneyNumber } from '@/lib/billing/money'\n\ntype CleanupStats = {\n  scanned: number\n  stale: number\n  rolledBack: number\n  skipped: number\n  errors: number\n}\n\nfunction hasApplyFlag() {\n  return process.argv.includes('--apply')\n}\n\nfunction parseHoursArg(defaultHours: number) {\n  const arg = process.argv.find((item) => item.startsWith('--hours='))\n  if (!arg) return defaultHours\n  const value = Number(arg.slice('--hours='.length))\n  if (!Number.isFinite(value) || value <= 0) return defaultHours\n  return Math.floor(value)\n}\n\nfunction writeJson(payload: unknown) {\n  process.stdout.write(`${JSON.stringify(payload, null, 2)}\\n`)\n}\n\nfunction writeError(payload: unknown) {\n  process.stderr.write(`${typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)}\\n`)\n}\n\nasync function main() {\n  const apply = hasApplyFlag()\n  const hours = parseHoursArg(24)\n  const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000)\n\n  const pending = await prisma.balanceFreeze.findMany({\n    where: {\n      status: 'pending',\n      createdAt: { lt: cutoff },\n    },\n    orderBy: { createdAt: 'asc' },\n  })\n\n  const stats: CleanupStats = {\n    scanned: pending.length,\n    stale: pending.length,\n    rolledBack: 0,\n    skipped: 0,\n    errors: 0,\n  }\n\n  if (!apply) {\n    writeJson({\n      mode: 'dry-run',\n      hours,\n      cutoff: cutoff.toISOString(),\n      stalePendingCount: pending.length,\n      stalePending: pending.map((f) => ({\n        id: f.id,\n        userId: f.userId,\n        amount: toMoneyNumber(f.amount),\n        createdAt: f.createdAt.toISOString(),\n      })),\n    })\n    return\n  }\n\n  for (const freeze of pending) {\n    try {\n      await prisma.$transaction(async (tx) => {\n        const current = await tx.balanceFreeze.findUnique({\n          where: { id: freeze.id },\n        })\n        if (!current || current.status !== 'pending') {\n          stats.skipped += 1\n          return\n        }\n\n        const balance = await tx.userBalance.findUnique({\n          where: { userId: current.userId },\n        })\n        if (!balance) {\n          stats.skipped += 1\n          return\n        }\n\n        const frozenAmount = toMoneyNumber(balance.frozenAmount)\n        const freezeAmount = toMoneyNumber(current.amount)\n        const nextFrozenAmount = Math.max(0, frozenAmount - freezeAmount)\n        const frozenDelta = frozenAmount - nextFrozenAmount\n        const balanceIncrement = frozenDelta\n\n        await tx.userBalance.update({\n          where: { userId: current.userId },\n          data: {\n            balance: { increment: balanceIncrement },\n            frozenAmount: { decrement: frozenDelta },\n          },\n        })\n\n        await tx.balanceFreeze.update({\n          where: { id: current.id },\n          data: {\n            status: 'rolled_back',\n          },\n        })\n      })\n      stats.rolledBack += 1\n    } catch (error) {\n      stats.errors += 1\n      writeError({\n        tag: 'billing-cleanup-pending-freezes.rollback_failed',\n        freezeId: freeze.id,\n        userId: freeze.userId,\n        amount: toMoneyNumber(freeze.amount),\n        error: error instanceof Error ? error.message : String(error),\n      })\n    }\n  }\n\n  writeJson({\n    mode: 'apply',\n    hours,\n    cutoff: cutoff.toISOString(),\n    stats,\n  })\n}\n\nmain()\n  .catch((error) => {\n    writeError({\n      tag: 'billing-cleanup-pending-freezes.fatal',\n      error: error instanceof Error ? error.message : String(error),\n    })\n    process.exit(1)\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/billing-reconcile-ledger.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { roundMoney, toMoneyNumber } from '@/lib/billing/money'\n\ntype UserLedgerRow = {\n  userId: string\n  balance: number\n  frozenAmount: number\n  txNetAmount: number\n  ledgerAmount: number\n  diff: number\n}\n\nfunction hasStrictFlag() {\n  return process.argv.includes('--strict')\n}\n\nfunction write(payload: unknown) {\n  process.stdout.write(`${JSON.stringify(payload, null, 2)}\\n`)\n}\n\nasync function main() {\n  const strict = hasStrictFlag()\n\n  const [balances, txByUser, pendingFreezes] = await Promise.all([\n    prisma.userBalance.findMany({\n      select: {\n        userId: true,\n        balance: true,\n        frozenAmount: true,\n      },\n    }),\n    prisma.balanceTransaction.groupBy({\n      by: ['userId'],\n      _sum: { amount: true },\n    }),\n    prisma.balanceFreeze.findMany({\n      where: { status: 'pending' },\n      select: {\n        id: true,\n        userId: true,\n        taskId: true,\n        amount: true,\n        createdAt: true,\n      },\n      orderBy: { createdAt: 'asc' },\n    }),\n  ])\n\n  const txNetByUser = new Map<string, number>()\n  for (const row of txByUser) {\n    txNetByUser.set(row.userId, roundMoney(toMoneyNumber(row._sum.amount), 8))\n  }\n\n  const ledgerRows: UserLedgerRow[] = balances.map((row) => {\n    const balance = toMoneyNumber(row.balance)\n    const frozenAmount = toMoneyNumber(row.frozenAmount)\n    const txNetAmount = roundMoney(txNetByUser.get(row.userId) || 0, 8)\n    const ledgerAmount = roundMoney(balance + frozenAmount, 8)\n    return {\n      userId: row.userId,\n      balance,\n      frozenAmount,\n      txNetAmount,\n      ledgerAmount,\n      diff: roundMoney(ledgerAmount - txNetAmount, 8),\n    }\n  })\n\n  const nonZeroDiffUsers = ledgerRows.filter((row) => Math.abs(row.diff) > 1e-8)\n\n  const pendingTaskIds = pendingFreezes\n    .map((row) => row.taskId)\n    .filter((taskId): taskId is string => typeof taskId === 'string' && taskId.length > 0)\n  const tasks = pendingTaskIds.length > 0\n    ? await prisma.task.findMany({\n      where: { id: { in: pendingTaskIds } },\n      select: { id: true, status: true },\n    })\n    : []\n  const taskStatusById = new Map(tasks.map((row) => [row.id, row.status]))\n  const activeStatuses = new Set(['queued', 'processing'])\n  const orphanPendingFreezes = pendingFreezes.filter((freeze) => {\n    if (!freeze.taskId) return true\n    const status = taskStatusById.get(freeze.taskId)\n    if (!status) return true\n    return !activeStatuses.has(status)\n  })\n\n  const result = {\n    strict,\n    checkedAt: new Date().toISOString(),\n    totals: {\n      users: balances.length,\n      txUsers: txByUser.length,\n      pendingFreezes: pendingFreezes.length,\n      nonZeroDiffUsers: nonZeroDiffUsers.length,\n      orphanPendingFreezes: orphanPendingFreezes.length,\n    },\n    nonZeroDiffUsers,\n    orphanPendingFreezes: orphanPendingFreezes.map((row) => ({\n      id: row.id,\n      userId: row.userId,\n      taskId: row.taskId,\n      amount: toMoneyNumber(row.amount),\n      createdAt: row.createdAt.toISOString(),\n    })),\n  }\n\n  write(result)\n\n  if (strict && (nonZeroDiffUsers.length > 0 || orphanPendingFreezes.length > 0)) {\n    process.exitCode = 1\n  }\n}\n\nmain()\n  .catch((error) => {\n    write({\n      error: error instanceof Error ? error.message : String(error),\n    })\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/bull-board.ts",
    "content": "import { createScopedLogger } from '@/lib/logging/core'\nimport express, { type NextFunction, type Request, type Response } from 'express'\nimport { createBullBoard } from '@bull-board/api'\nimport { BullMQAdapter } from '@bull-board/api/bullMQAdapter'\nimport { ExpressAdapter } from '@bull-board/express'\nimport { imageQueue, textQueue, videoQueue, voiceQueue } from '@/lib/task/queues'\n\nconst host = process.env.BULL_BOARD_HOST || '127.0.0.1'\nconst port = Number.parseInt(process.env.BULL_BOARD_PORT || '3010', 10) || 3010\nconst basePath = process.env.BULL_BOARD_BASE_PATH || '/admin/queues'\nconst authUser = process.env.BULL_BOARD_USER\nconst authPassword = process.env.BULL_BOARD_PASSWORD\nconst logger = createScopedLogger({\n  module: 'ops.bull_board',\n})\n\nfunction unauthorized(res: Response) {\n  res.setHeader('WWW-Authenticate', 'Basic realm=\"BullMQ Board\"')\n  res.status(401).send('Authentication required')\n}\n\nfunction basicAuthMiddleware(req: Request, res: Response, next: NextFunction) {\n  if (!authUser && !authPassword) {\n    next()\n    return\n  }\n\n  const authorization = req.headers.authorization\n  if (!authorization?.startsWith('Basic ')) {\n    unauthorized(res)\n    return\n  }\n\n  const encoded = authorization.slice(6).trim()\n  let decoded = ''\n\n  try {\n    decoded = Buffer.from(encoded, 'base64').toString('utf8')\n  } catch {\n    unauthorized(res)\n    return\n  }\n\n  const index = decoded.indexOf(':')\n  if (index === -1) {\n    unauthorized(res)\n    return\n  }\n\n  const username = decoded.slice(0, index)\n  const password = decoded.slice(index + 1)\n  if (username !== (authUser || '') || password !== (authPassword || '')) {\n    unauthorized(res)\n    return\n  }\n\n  next()\n}\n\nconst serverAdapter = new ExpressAdapter()\nserverAdapter.setBasePath(basePath)\n\ncreateBullBoard({\n  queues: [\n    new BullMQAdapter(imageQueue),\n    new BullMQAdapter(videoQueue),\n    new BullMQAdapter(voiceQueue),\n    new BullMQAdapter(textQueue),\n  ],\n  serverAdapter,\n})\n\nconst app = express()\napp.disable('x-powered-by')\napp.use(basePath, basicAuthMiddleware, serverAdapter.getRouter())\n\nconst server = app.listen(port, host, () => {\n  const secured = authUser || authPassword ? 'enabled' : 'disabled'\n  logger.info({\n    action: 'bull_board.started',\n    message: 'bull board listening',\n    details: {\n      host,\n      port,\n      basePath,\n      auth: secured,\n    },\n  })\n})\n\nasync function shutdown(signal: string) {\n  logger.info({\n    action: 'bull_board.shutdown',\n    message: 'bull board shutting down',\n    details: {\n      signal,\n    },\n  })\n  await Promise.allSettled([imageQueue.close(), videoQueue.close(), voiceQueue.close(), textQueue.close()])\n  await new Promise<void>((resolve) => server.close(() => resolve()))\n  process.exit(0)\n}\n\nprocess.on('SIGINT', () => void shutdown('SIGINT'))\nprocess.on('SIGTERM', () => void shutdown('SIGTERM'))\n"
  },
  {
    "path": "scripts/check-api-handler.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { execSync } from 'node:child_process'\n\nconst ALLOWLIST = new Set([\n  'src/app/api/auth/[...nextauth]/route.ts',\n  'src/app/api/files/[...path]/route.ts',\n  'src/app/api/system/boot-id/route.ts',\n])\n\nfunction main() {\n  const output = execSync(\"rg --files src/app/api | rg 'route\\\\.ts$'\", { encoding: 'utf8' })\n  const files = output\n    .split('\\n')\n    .map((line) => line.trim())\n    .filter(Boolean)\n\n  const missing: string[] = []\n\n  for (const file of files) {\n    if (ALLOWLIST.has(file)) continue\n    const hasApiHandler = execSync(`rg -n \\\"apiHandler\\\" ${JSON.stringify(file)} || true`, { encoding: 'utf8' }).trim().length > 0\n    if (!hasApiHandler) {\n      missing.push(file)\n    }\n  }\n\n  if (missing.length > 0) {\n    _ulogError('[check-api-handler] missing apiHandler in:')\n    for (const file of missing) {\n      _ulogError(`- ${file}`)\n    }\n    process.exit(1)\n  }\n\n  _ulogInfo(`[check-api-handler] ok total=${files.length} allowlist=${ALLOWLIST.size}`)\n}\n\nmain()\n"
  },
  {
    "path": "scripts/check-capability-catalog.mjs",
    "content": "import { promises as fs } from 'node:fs'\nimport path from 'node:path'\n\n const CATALOG_DIR = path.resolve(process.cwd(), 'standards/capabilities')\nconst CAPABILITY_NAMESPACES = new Set(['llm', 'image', 'video', 'audio', 'lipsync'])\nconst CAPABILITY_NAMESPACE_ALLOWED_FIELDS = {\n  llm: new Set(['reasoningEffortOptions', 'fieldI18n']),\n  image: new Set(['resolutionOptions', 'fieldI18n']),\n  video: new Set([\n    'generationModeOptions',\n    'generateAudioOptions',\n    'durationOptions',\n    'fpsOptions',\n    'resolutionOptions',\n    'firstlastframe',\n    'supportGenerateAudio',\n    'fieldI18n',\n  ]),\n  audio: new Set(['voiceOptions', 'rateOptions', 'fieldI18n']),\n  lipsync: new Set(['modeOptions', 'fieldI18n']),\n}\nconst CAPABILITY_NAMESPACE_I18N_FIELDS = {\n  llm: { reasoningEffort: 'reasoningEffortOptions' },\n  image: { resolution: 'resolutionOptions' },\n  video: {\n    generationMode: 'generationModeOptions',\n    generateAudio: 'generateAudioOptions',\n    duration: 'durationOptions',\n    fps: 'fpsOptions',\n    resolution: 'resolutionOptions',\n  },\n  audio: { voice: 'voiceOptions', rate: 'rateOptions' },\n  lipsync: { mode: 'modeOptions' },\n}\nconst MODEL_TYPES = new Set(['llm', 'image', 'video', 'audio', 'lipsync'])\n\nfunction isRecord(value) {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isNonEmptyString(value) {\n  return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction isI18nKey(value) {\n  return isNonEmptyString(value) && value.includes('.')\n}\n\nfunction isStringArray(value) {\n  return Array.isArray(value) && value.every((item) => isNonEmptyString(item))\n}\n\nfunction isNumberArray(value) {\n  return Array.isArray(value) && value.every((item) => typeof item === 'number' && Number.isFinite(item))\n}\n\nfunction isBooleanArray(value) {\n  return Array.isArray(value) && value.every((item) => typeof item === 'boolean')\n}\n\nfunction parseModelKeyStrict(value) {\n  if (!isNonEmptyString(value)) return null\n  const raw = value.trim()\n  const marker = raw.indexOf('::')\n  if (marker === -1) return null\n  const provider = raw.slice(0, marker).trim()\n  const modelId = raw.slice(marker + 2).trim()\n  if (!provider || !modelId) return null\n  return { provider, modelId, modelKey: `${provider}::${modelId}` }\n}\n\nfunction pushIssue(issues, file, index, field, message) {\n  issues.push({ file, index, field, message })\n}\n\nfunction validateAllowedFields(issues, file, index, namespace, namespaceValue) {\n  if (!isRecord(namespaceValue)) return\n  const allowedFields = CAPABILITY_NAMESPACE_ALLOWED_FIELDS[namespace]\n  for (const field of Object.keys(namespaceValue)) {\n    if (allowedFields.has(field)) continue\n    if (field === 'i18n') {\n      pushIssue(issues, file, index, `capabilities.${namespace}.${field}`, 'use fieldI18n instead of i18n')\n      continue\n    }\n    pushIssue(issues, file, index, `capabilities.${namespace}.${field}`, `unknown capability field: ${field}`)\n  }\n}\n\nfunction validateFieldI18nMap(issues, file, index, namespace, namespaceValue) {\n  if (!isRecord(namespaceValue)) return\n  if (namespaceValue.fieldI18n === undefined) return\n  if (!isRecord(namespaceValue.fieldI18n)) {\n    pushIssue(issues, file, index, `capabilities.${namespace}.fieldI18n`, 'fieldI18n must be an object')\n    return\n  }\n\n  const allowedI18nFields = CAPABILITY_NAMESPACE_I18N_FIELDS[namespace]\n  for (const [fieldName, fieldConfig] of Object.entries(namespaceValue.fieldI18n)) {\n    if (!(fieldName in allowedI18nFields)) {\n      pushIssue(issues, file, index, `capabilities.${namespace}.fieldI18n.${fieldName}`, `unknown i18n field: ${fieldName}`)\n      continue\n    }\n    if (!isRecord(fieldConfig)) {\n      pushIssue(issues, file, index, `capabilities.${namespace}.fieldI18n.${fieldName}`, 'field i18n config must be an object')\n      continue\n    }\n\n    if (fieldConfig.labelKey !== undefined && !isI18nKey(fieldConfig.labelKey)) {\n      pushIssue(issues, file, index, `capabilities.${namespace}.fieldI18n.${fieldName}.labelKey`, 'labelKey must be an i18n key')\n    }\n    if (fieldConfig.unitKey !== undefined && !isI18nKey(fieldConfig.unitKey)) {\n      pushIssue(issues, file, index, `capabilities.${namespace}.fieldI18n.${fieldName}.unitKey`, 'unitKey must be an i18n key')\n    }\n    if (fieldConfig.optionLabelKeys !== undefined) {\n      if (!isRecord(fieldConfig.optionLabelKeys)) {\n        pushIssue(\n          issues,\n          file,\n          index,\n          `capabilities.${namespace}.fieldI18n.${fieldName}.optionLabelKeys`,\n          'optionLabelKeys must be an object',\n        )\n        continue\n      }\n      const optionFieldName = allowedI18nFields[fieldName]\n      const optionsRaw = namespaceValue[optionFieldName]\n      const allowedOptions = Array.isArray(optionsRaw) ? new Set(optionsRaw.map((value) => String(value))) : null\n      for (const [optionValue, optionLabel] of Object.entries(fieldConfig.optionLabelKeys)) {\n        if (!isI18nKey(optionLabel)) {\n          pushIssue(\n            issues,\n            file,\n            index,\n            `capabilities.${namespace}.fieldI18n.${fieldName}.optionLabelKeys.${optionValue}`,\n            'option label must be an i18n key',\n          )\n        }\n        if (allowedOptions && !allowedOptions.has(optionValue)) {\n          pushIssue(\n            issues,\n            file,\n            index,\n            `capabilities.${namespace}.fieldI18n.${fieldName}.optionLabelKeys.${optionValue}`,\n            `option ${optionValue} is not defined in ${optionFieldName}`,\n          )\n        }\n      }\n    }\n  }\n}\n\nfunction validateCapabilitiesForModelType(issues, file, index, modelType, capabilities) {\n  if (capabilities === undefined || capabilities === null) return\n  if (!isRecord(capabilities)) {\n    pushIssue(issues, file, index, 'capabilities', 'capabilities must be an object')\n    return\n  }\n\n  const expectedNamespace = modelType\n  for (const namespace of Object.keys(capabilities)) {\n    if (!CAPABILITY_NAMESPACES.has(namespace)) {\n      pushIssue(issues, file, index, `capabilities.${namespace}`, `unknown capabilities namespace: ${namespace}`)\n      continue\n    }\n    if (namespace !== expectedNamespace) {\n      pushIssue(\n        issues,\n        file,\n        index,\n        `capabilities.${namespace}`,\n        `namespace ${namespace} is not allowed for model type ${modelType}`,\n      )\n    }\n  }\n\n  const llm = capabilities.llm\n  if (llm !== undefined) {\n    if (!isRecord(llm)) {\n      pushIssue(issues, file, index, 'capabilities.llm', 'llm capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, file, index, 'llm', llm)\n      if (llm.reasoningEffortOptions !== undefined && !isStringArray(llm.reasoningEffortOptions)) {\n        pushIssue(issues, file, index, 'capabilities.llm.reasoningEffortOptions', 'must be string array')\n      }\n      validateFieldI18nMap(issues, file, index, 'llm', llm)\n    }\n  }\n\n  const image = capabilities.image\n  if (image !== undefined) {\n    if (!isRecord(image)) {\n      pushIssue(issues, file, index, 'capabilities.image', 'image capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, file, index, 'image', image)\n      if (image.resolutionOptions !== undefined && !isStringArray(image.resolutionOptions)) {\n        pushIssue(issues, file, index, 'capabilities.image.resolutionOptions', 'must be string array')\n      }\n      validateFieldI18nMap(issues, file, index, 'image', image)\n    }\n  }\n\n  const video = capabilities.video\n  if (video !== undefined) {\n    if (!isRecord(video)) {\n      pushIssue(issues, file, index, 'capabilities.video', 'video capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, file, index, 'video', video)\n      if (video.generationModeOptions !== undefined && !isStringArray(video.generationModeOptions)) {\n        pushIssue(issues, file, index, 'capabilities.video.generationModeOptions', 'must be string array')\n      }\n      if (video.generateAudioOptions !== undefined && !isBooleanArray(video.generateAudioOptions)) {\n        pushIssue(issues, file, index, 'capabilities.video.generateAudioOptions', 'must be boolean array')\n      }\n      if (video.durationOptions !== undefined && !isNumberArray(video.durationOptions)) {\n        pushIssue(issues, file, index, 'capabilities.video.durationOptions', 'must be number array')\n      }\n      if (video.fpsOptions !== undefined && !isNumberArray(video.fpsOptions)) {\n        pushIssue(issues, file, index, 'capabilities.video.fpsOptions', 'must be number array')\n      }\n      if (video.resolutionOptions !== undefined && !isStringArray(video.resolutionOptions)) {\n        pushIssue(issues, file, index, 'capabilities.video.resolutionOptions', 'must be string array')\n      }\n      if (video.supportGenerateAudio !== undefined && typeof video.supportGenerateAudio !== 'boolean') {\n        pushIssue(issues, file, index, 'capabilities.video.supportGenerateAudio', 'must be boolean')\n      }\n      if (video.firstlastframe !== undefined && typeof video.firstlastframe !== 'boolean') {\n        pushIssue(issues, file, index, 'capabilities.video.firstlastframe', 'must be boolean')\n      }\n      validateFieldI18nMap(issues, file, index, 'video', video)\n    }\n  }\n\n  const audio = capabilities.audio\n  if (audio !== undefined) {\n    if (!isRecord(audio)) {\n      pushIssue(issues, file, index, 'capabilities.audio', 'audio capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, file, index, 'audio', audio)\n      if (audio.voiceOptions !== undefined && !isStringArray(audio.voiceOptions)) {\n        pushIssue(issues, file, index, 'capabilities.audio.voiceOptions', 'must be string array')\n      }\n      if (audio.rateOptions !== undefined && !isStringArray(audio.rateOptions)) {\n        pushIssue(issues, file, index, 'capabilities.audio.rateOptions', 'must be string array')\n      }\n      validateFieldI18nMap(issues, file, index, 'audio', audio)\n    }\n  }\n\n  const lipsync = capabilities.lipsync\n  if (lipsync !== undefined) {\n    if (!isRecord(lipsync)) {\n      pushIssue(issues, file, index, 'capabilities.lipsync', 'lipsync capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, file, index, 'lipsync', lipsync)\n      if (lipsync.modeOptions !== undefined && !isStringArray(lipsync.modeOptions)) {\n        pushIssue(issues, file, index, 'capabilities.lipsync.modeOptions', 'must be string array')\n      }\n      validateFieldI18nMap(issues, file, index, 'lipsync', lipsync)\n    }\n  }\n}\n\nasync function listCatalogFiles() {\n  const entries = await fs.readdir(CATALOG_DIR, { withFileTypes: true })\n  return entries\n    .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))\n    .map((entry) => path.join(CATALOG_DIR, entry.name))\n}\n\nasync function readCatalog(filePath) {\n  const raw = await fs.readFile(filePath, 'utf8')\n  const parsed = JSON.parse(raw)\n  if (!Array.isArray(parsed)) {\n    throw new Error(`catalog must be an array: ${filePath}`)\n  }\n  return parsed\n}\n\nasync function main() {\n  const issues = []\n  const files = await listCatalogFiles()\n  if (files.length === 0) {\n    throw new Error(`no catalog files found in ${CATALOG_DIR}`)\n  }\n\n  for (const filePath of files) {\n    const catalogItems = await readCatalog(filePath)\n    for (let index = 0; index < catalogItems.length; index += 1) {\n      const item = catalogItems[index]\n      if (!isRecord(item)) {\n        pushIssue(issues, filePath, index, 'entry', 'entry must be an object')\n        continue\n      }\n\n      if (!isNonEmptyString(item.modelType) || !MODEL_TYPES.has(item.modelType)) {\n        pushIssue(issues, filePath, index, 'modelType', 'modelType must be llm/image/video/audio/lipsync')\n        continue\n      }\n\n      if (!isNonEmptyString(item.provider)) {\n        pushIssue(issues, filePath, index, 'provider', 'provider must be a non-empty string')\n      }\n      if (!isNonEmptyString(item.modelId)) {\n        pushIssue(issues, filePath, index, 'modelId', 'modelId must be a non-empty string')\n      }\n\n      const modelKey = `${item.provider || ''}::${item.modelId || ''}`\n      if (!parseModelKeyStrict(modelKey)) {\n        pushIssue(issues, filePath, index, 'modelKey', 'provider/modelId must compose a valid provider::modelId')\n      }\n\n      validateCapabilitiesForModelType(issues, filePath, index, item.modelType, item.capabilities)\n    }\n  }\n\n  if (issues.length === 0) {\n    process.stdout.write(`[check-capability-catalog] OK (${files.length} files)\\n`)\n    return\n  }\n\n  const maxPrint = 50\n  for (const issue of issues.slice(0, maxPrint)) {\n    process.stdout.write(`[check-capability-catalog] ${issue.file}#${issue.index} ${issue.field}: ${issue.message}\\n`)\n  }\n  if (issues.length > maxPrint) {\n    process.stdout.write(`[check-capability-catalog] ... ${issues.length - maxPrint} more issues\\n`)\n  }\n  process.exitCode = 1\n}\n\nmain().catch((error) => {\n  process.stderr.write(`[check-capability-catalog] failed: ${String(error)}\\n`)\n  process.exitCode = 1\n})\n"
  },
  {
    "path": "scripts/check-image-urls-contract.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { prisma } from '@/lib/prisma'\nimport { decodeImageUrlsFromDb } from '@/lib/contracts/image-urls-contract'\n\ntype AppearanceRow = {\n  id: string\n  imageUrls: string | null\n  previousImageUrls: string | null\n}\n\ntype DynamicModel = {\n  findMany: (args: unknown) => Promise<AppearanceRow[]>\n}\n\nconst BATCH_SIZE = 500\n\nconst MODELS: Array<{ name: string; model: string }> = [\n  { name: 'CharacterAppearance', model: 'characterAppearance' },\n  { name: 'GlobalCharacterAppearance', model: 'globalCharacterAppearance' },\n]\n\nconst prismaDynamic = prisma as unknown as Record<string, DynamicModel>\n\nfunction print(message: string) {\n  process.stdout.write(`${message}\\n`)\n}\n\nasync function checkModel(modelName: string, modelKey: string) {\n  const model = prismaDynamic[modelKey]\n  if (!model) {\n    throw new Error(`Prisma model not found: ${modelKey}`)\n  }\n\n  let scanned = 0\n  let violations = 0\n  const samples: Array<{ id: string; field: 'imageUrls' | 'previousImageUrls'; message: string; value: string | null }> = []\n  let cursor: string | null = null\n\n  while (true) {\n    const rows = await model.findMany({\n      select: {\n        id: true,\n        imageUrls: true,\n        previousImageUrls: true,\n      },\n      ...(cursor\n        ? {\n          cursor: { id: cursor },\n          skip: 1,\n        }\n        : {}),\n      orderBy: { id: 'asc' },\n      take: BATCH_SIZE,\n    })\n\n    if (rows.length === 0) break\n\n    for (const row of rows) {\n      scanned += 1\n\n      for (const fieldName of ['imageUrls', 'previousImageUrls'] as const) {\n        try {\n          decodeImageUrlsFromDb(row[fieldName], `${modelName}.${fieldName}`)\n        } catch (error) {\n          violations += 1\n          if (samples.length < 20) {\n            samples.push({\n              id: row.id,\n              field: fieldName,\n              message: error instanceof Error ? error.message : String(error),\n              value: row[fieldName],\n            })\n          }\n        }\n      }\n    }\n\n    cursor = rows[rows.length - 1]?.id || null\n  }\n\n  const summary = `[check-image-urls-contract] ${modelName}: scanned=${scanned} violations=${violations}`\n  _ulogInfo(summary)\n  print(summary)\n  if (samples.length > 0) {\n    _ulogError(`[check-image-urls-contract] ${modelName}: samples=${JSON.stringify(samples, null, 2)}`)\n  }\n\n  return { scanned, violations }\n}\n\nasync function main() {\n  let totalScanned = 0\n  let totalViolations = 0\n\n  for (const target of MODELS) {\n    const result = await checkModel(target.name, target.model)\n    totalScanned += result.scanned\n    totalViolations += result.violations\n  }\n\n  if (totalViolations > 0) {\n    _ulogError(`[check-image-urls-contract] failed scanned=${totalScanned} violations=${totalViolations}`)\n    print(`[check-image-urls-contract] failed scanned=${totalScanned} violations=${totalViolations}`)\n    process.exitCode = 1\n    return\n  }\n\n  print(`[check-image-urls-contract] ok scanned=${totalScanned}`)\n}\n\nmain()\n  .catch((error) => {\n    _ulogError('[check-image-urls-contract] failed:', error)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/check-log-semantic.ts",
    "content": "import fs from 'node:fs'\n\ntype Rule = {\n  file: string\n  patterns: string[]\n}\n\nconst RULES: Rule[] = [\n  {\n    file: 'src/lib/api-errors.ts',\n    patterns: ['x-request-id', 'api.request.start', 'api.request.finish', 'api.request.error'],\n  },\n  {\n    file: 'src/lib/workers/shared.ts',\n    patterns: ['worker.start', 'worker.completed', 'worker.failed', 'durationMs', 'errorCode'],\n  },\n  {\n    file: 'src/app/api/sse/route.ts',\n    patterns: ['sse.connect', 'sse.replay', 'sse.disconnect'],\n  },\n  {\n    file: 'scripts/watchdog.ts',\n    patterns: ['watchdog.started', 'watchdog.tick.ok', 'watchdog.tick.failed'],\n  },\n  {\n    file: 'scripts/bull-board.ts',\n    patterns: ['bull_board.started', 'bull_board.shutdown'],\n  },\n  {\n    file: 'src/lib/task/submitter.ts',\n    patterns: ['requestId', 'task.submit.created', 'task.submit.enqueued'],\n  },\n  {\n    file: 'src/lib/task/types.ts',\n    patterns: ['trace', 'requestId'],\n  },\n]\n\nfunction read(file: string) {\n  return fs.readFileSync(file, 'utf8')\n}\n\nfunction checkRules() {\n  const violations: string[] = []\n  for (const rule of RULES) {\n    const content = read(rule.file)\n    for (const pattern of rule.patterns) {\n      if (!content.includes(pattern)) {\n        violations.push(`${rule.file} missing \"${pattern}\"`)\n      }\n    }\n  }\n  return violations\n}\n\nfunction checkSubmitTaskRoutes() {\n  const root = 'src/app/api'\n  const files = walk(root).filter((file) => file.endsWith('/route.ts'))\n  const submitTaskFiles = files.filter((file) => read(file).includes('submitTask('))\n  const violations: string[] = []\n\n  for (const file of submitTaskFiles) {\n    const content = read(file)\n    if (!content.includes('getRequestId')) {\n      violations.push(`${file} uses submitTask but does not import getRequestId`)\n      continue\n    }\n    if (!content.includes('requestId: getRequestId(request)')) {\n      violations.push(`${file} uses submitTask but does not pass requestId`)\n    }\n  }\n\n  return { submitTaskFiles, violations }\n}\n\nfunction walk(dir: string): string[] {\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  const out: string[] = []\n\n  for (const entry of entries) {\n    const next = `${dir}/${entry.name}`\n    if (entry.isDirectory()) {\n      out.push(...walk(next))\n    } else {\n      out.push(next)\n    }\n  }\n\n  return out\n}\n\nfunction main() {\n  const violations = checkRules()\n  const submitTaskResult = checkSubmitTaskRoutes()\n  violations.push(...submitTaskResult.violations)\n\n  if (violations.length > 0) {\n    process.stderr.write('[check:log-semantic] semantic violations detected:\\n')\n    for (const violation of violations) {\n      process.stderr.write(`- ${violation}\\n`)\n    }\n    process.exit(1)\n  }\n\n  process.stdout.write(\n    `[check:log-semantic] ok rules=${RULES.length} submitTaskRoutes=${submitTaskResult.submitTaskFiles.length}\\n`,\n  )\n}\n\nmain()\n"
  },
  {
    "path": "scripts/check-media-normalization.ts",
    "content": "import { execSync } from 'node:child_process'\n\nconst TARGETS = ['src/app/api', 'src/lib']\n\nconst EXTRACT_ALLOWLIST = new Set<string>([\n  'src/lib/media/service.ts',\n  'src/lib/voice/generate-voice-line.ts',\n])\n\nconst FETCH_MEDIA_ALLOWLIST = new Set<string>([\n  'src/lib/media-process.ts',\n  'src/lib/image-cache.ts',\n  'src/lib/image-label.ts',\n  'src/lib/workers/utils.ts',\n  'src/app/api/novel-promotion/[projectId]/download-images/route.ts',\n  'src/app/api/novel-promotion/[projectId]/download-videos/route.ts',\n  'src/app/api/novel-promotion/[projectId]/download-voices/route.ts',\n  'src/app/api/novel-promotion/[projectId]/update-asset-label/route.ts',\n  'src/app/api/novel-promotion/[projectId]/voice-generate/route.ts',\n  'src/app/api/novel-promotion/[projectId]/video-proxy/route.ts',\n])\n\nfunction run(cmd: string): string {\n  try {\n    return execSync(cmd, { encoding: 'utf8' })\n  } catch (error: unknown) {\n    if (error && typeof error === 'object' && 'stdout' in error) {\n      const stdout = (error as { stdout?: unknown }).stdout\n      return typeof stdout === 'string' ? stdout : ''\n    }\n    return ''\n  }\n}\n\nfunction parseLines(output: string): string[] {\n  return output\n    .split('\\n')\n    .map((line) => line.trim())\n    .filter(Boolean)\n}\n\nfunction getFile(line: string): string {\n  return line.split(':', 1)[0] || ''\n}\n\nfunction getCode(line: string): string {\n  const parts = line.split(':')\n  return parts.slice(2).join(':').trim()\n}\n\nfunction extractFetchArg(code: string): string {\n  const matched = code.match(/fetch\\(\\s*([^)]+)\\)/)\n  return matched?.[1]?.trim() || ''\n}\n\nfunction isSafeFetchArg(arg: string): boolean {\n  if (!arg) return false\n  if (/^toFetchableUrl\\(/.test(arg)) return true\n  if (/^['\"`]/.test(arg)) return true\n  if (/^new URL\\(/.test(arg)) return true\n  return false\n}\n\nfunction isMediaLikeFetchArg(arg: string): boolean {\n  return /(image|video|audio|signed).*url/i.test(arg) || /url.*(image|video|audio|signed)/i.test(arg)\n}\n\nfunction main() {\n  const targetExpr = TARGETS.join(' ')\n\n  // 规则 1：业务代码中不允许直接调用 extractStorageKey（统一走 resolveStorageKeyFromMediaValue）\n  const extractOutput = run(`rg -n \"extractStorageKey\\\\\\\\(\" ${targetExpr}`)\n  const extractLines = parseLines(extractOutput)\n  const extractViolations = extractLines.filter((line) => {\n    const file = getFile(line)\n    if (file.startsWith('src/lib/storage/')) return false\n    return !EXTRACT_ALLOWLIST.has(file)\n  })\n\n  // 规则 2：媒体相关 fetch 必须包裹 toFetchableUrl\n  const fetchOutput = run(`rg -n \"fetch\\\\\\\\(\" ${targetExpr}`)\n  const fetchLines = parseLines(fetchOutput)\n  const fetchViolations = fetchLines.filter((line) => {\n    const file = getFile(line)\n    if (!FETCH_MEDIA_ALLOWLIST.has(file)) return false\n    const code = getCode(line)\n    const arg = extractFetchArg(code)\n    if (!isMediaLikeFetchArg(arg)) return false\n    return !isSafeFetchArg(arg)\n  })\n\n  const violations = [\n    ...extractViolations.map((line) => `extractStorageKey forbidden: ${line}`),\n    ...fetchViolations.map((line) => `fetch without toFetchableUrl: ${line}`),\n  ]\n\n  if (violations.length > 0) {\n    process.stderr.write('[check:media-normalization] found violations:\\n')\n    for (const item of violations) {\n      process.stderr.write(`- ${item}\\n`)\n    }\n    process.exit(1)\n  }\n\n  process.stdout.write(\n    `[check:media-normalization] ok extract_scanned=${extractLines.length} fetch_scanned=${fetchLines.length} allow_extract=${EXTRACT_ALLOWLIST.size} allow_fetch=${FETCH_MEDIA_ALLOWLIST.size}\\n`,\n  )\n}\n\nmain()\n"
  },
  {
    "path": "scripts/check-model-config-contract.mjs",
    "content": "let prisma\n\nconst STRICT = process.argv.includes('--strict')\nconst MODEL_FIELDS = [\n  'analysisModel',\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n  'videoModel',\n]\nconst MAX_SAMPLES = 200\nconst CAPABILITY_NAMESPACES = new Set(['llm', 'image', 'video', 'audio', 'lipsync'])\nconst MODEL_TYPES = new Set(['llm', 'image', 'video', 'audio', 'lipsync'])\nconst CAPABILITY_NAMESPACE_ALLOWED_FIELDS = {\n  llm: new Set(['reasoningEffortOptions', 'fieldI18n']),\n  image: new Set(['resolutionOptions', 'fieldI18n']),\n  video: new Set([\n    'durationOptions',\n    'fpsOptions',\n    'resolutionOptions',\n    'firstlastframe',\n    'supportGenerateAudio',\n    'fieldI18n',\n  ]),\n  audio: new Set(['voiceOptions', 'rateOptions', 'fieldI18n']),\n  lipsync: new Set(['modeOptions', 'fieldI18n']),\n}\n\nconst CAPABILITY_NAMESPACE_I18N_FIELDS = {\n  llm: {\n    reasoningEffort: 'reasoningEffortOptions',\n  },\n  image: {\n    resolution: 'resolutionOptions',\n  },\n  video: {\n    duration: 'durationOptions',\n    fps: 'fpsOptions',\n    resolution: 'resolutionOptions',\n  },\n  audio: {\n    voice: 'voiceOptions',\n    rate: 'rateOptions',\n  },\n  lipsync: {\n    mode: 'modeOptions',\n  },\n}\n\nfunction isRecord(value) {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isNonEmptyString(value) {\n  return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction isStringArray(value) {\n  return Array.isArray(value) && value.every((item) => isNonEmptyString(item))\n}\n\nfunction isNumberArray(value) {\n  return Array.isArray(value) && value.every((item) => typeof item === 'number' && Number.isFinite(item))\n}\n\nfunction parseModelKeyStrict(value) {\n  if (!isNonEmptyString(value)) return null\n  const raw = value.trim()\n  const marker = raw.indexOf('::')\n  if (marker === -1) return null\n  const provider = raw.slice(0, marker).trim()\n  const modelId = raw.slice(marker + 2).trim()\n  if (!provider || !modelId) return null\n  return {\n    provider,\n    modelId,\n    modelKey: `${provider}::${modelId}`,\n  }\n}\n\nfunction addSample(summary, sample) {\n  if (summary.samples.length >= MAX_SAMPLES) return\n  summary.samples.push(sample)\n}\n\nfunction pushIssue(issues, field, message) {\n  issues.push({ field, message })\n}\n\nfunction isI18nKey(value) {\n  return isNonEmptyString(value) && value.includes('.')\n}\n\nfunction validateAllowedFields(issues, namespace, namespaceValue) {\n  if (!isRecord(namespaceValue)) return\n  const allowedFields = CAPABILITY_NAMESPACE_ALLOWED_FIELDS[namespace]\n  for (const field of Object.keys(namespaceValue)) {\n    if (allowedFields.has(field)) continue\n    if (field === 'i18n') {\n      pushIssue(issues, `capabilities.${namespace}.${field}`, 'use fieldI18n instead of i18n')\n      continue\n    }\n    pushIssue(issues, `capabilities.${namespace}.${field}`, `unknown capability field: ${field}`)\n  }\n}\n\nfunction validateFieldI18nMap(issues, namespace, namespaceValue) {\n  if (!isRecord(namespaceValue)) return\n  if (namespaceValue.fieldI18n === undefined) return\n  if (!isRecord(namespaceValue.fieldI18n)) {\n    pushIssue(issues, `capabilities.${namespace}.fieldI18n`, 'fieldI18n must be an object')\n    return\n  }\n\n  const allowedI18nFields = CAPABILITY_NAMESPACE_I18N_FIELDS[namespace]\n  for (const [fieldName, fieldConfig] of Object.entries(namespaceValue.fieldI18n)) {\n    if (!(fieldName in allowedI18nFields)) {\n      pushIssue(\n        issues,\n        `capabilities.${namespace}.fieldI18n.${fieldName}`,\n        `unknown i18n field: ${fieldName}`,\n      )\n      continue\n    }\n    if (!isRecord(fieldConfig)) {\n      pushIssue(\n        issues,\n        `capabilities.${namespace}.fieldI18n.${fieldName}`,\n        'field i18n config must be an object',\n      )\n      continue\n    }\n\n    if (fieldConfig.labelKey !== undefined && !isI18nKey(fieldConfig.labelKey)) {\n      pushIssue(\n        issues,\n        `capabilities.${namespace}.fieldI18n.${fieldName}.labelKey`,\n        'labelKey must be an i18n key',\n      )\n    }\n    if (fieldConfig.unitKey !== undefined && !isI18nKey(fieldConfig.unitKey)) {\n      pushIssue(\n        issues,\n        `capabilities.${namespace}.fieldI18n.${fieldName}.unitKey`,\n        'unitKey must be an i18n key',\n      )\n    }\n    if (fieldConfig.optionLabelKeys !== undefined) {\n      if (!isRecord(fieldConfig.optionLabelKeys)) {\n        pushIssue(\n          issues,\n          `capabilities.${namespace}.fieldI18n.${fieldName}.optionLabelKeys`,\n          'optionLabelKeys must be an object',\n        )\n        continue\n      }\n\n      const optionFieldName = allowedI18nFields[fieldName]\n      const allowedOptionsRaw = namespaceValue[optionFieldName]\n      const allowedOptions = Array.isArray(allowedOptionsRaw)\n        ? new Set(allowedOptionsRaw.map((value) => String(value)))\n        : null\n\n      for (const [optionValue, optionLabelKey] of Object.entries(fieldConfig.optionLabelKeys)) {\n        if (!isI18nKey(optionLabelKey)) {\n          pushIssue(\n            issues,\n            `capabilities.${namespace}.fieldI18n.${fieldName}.optionLabelKeys.${optionValue}`,\n            'option label must be an i18n key',\n          )\n        }\n        if (allowedOptions && !allowedOptions.has(optionValue)) {\n          pushIssue(\n            issues,\n            `capabilities.${namespace}.fieldI18n.${fieldName}.optionLabelKeys.${optionValue}`,\n            `option ${optionValue} is not defined in ${optionFieldName}`,\n          )\n        }\n      }\n    }\n  }\n}\n\nfunction validateCapabilities(modelType, capabilities) {\n  const issues = []\n  if (!MODEL_TYPES.has(modelType)) {\n    pushIssue(issues, 'type', 'type must be llm/image/video/audio/lipsync')\n    return issues\n  }\n  if (capabilities === undefined || capabilities === null) return issues\n  if (!isRecord(capabilities)) {\n    pushIssue(issues, 'capabilities', 'capabilities must be an object')\n    return issues\n  }\n\n  for (const namespace of Object.keys(capabilities)) {\n    if (!CAPABILITY_NAMESPACES.has(namespace)) {\n      pushIssue(issues, `capabilities.${namespace}`, `unknown capabilities namespace: ${namespace}`)\n      continue\n    }\n    if (namespace !== modelType) {\n      pushIssue(issues, `capabilities.${namespace}`, `namespace ${namespace} is not allowed for model type ${modelType}`)\n    }\n  }\n\n  const llm = capabilities.llm\n  if (llm !== undefined) {\n    if (!isRecord(llm)) {\n      pushIssue(issues, 'capabilities.llm', 'llm capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, 'llm', llm)\n      if (llm.reasoningEffortOptions !== undefined && !isStringArray(llm.reasoningEffortOptions)) {\n        pushIssue(issues, 'capabilities.llm.reasoningEffortOptions', 'must be string array')\n      }\n      validateFieldI18nMap(issues, 'llm', llm)\n    }\n  }\n\n  const image = capabilities.image\n  if (image !== undefined) {\n    if (!isRecord(image)) {\n      pushIssue(issues, 'capabilities.image', 'image capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, 'image', image)\n      if (image.resolutionOptions !== undefined && !isStringArray(image.resolutionOptions)) {\n        pushIssue(issues, 'capabilities.image.resolutionOptions', 'must be string array')\n      }\n      validateFieldI18nMap(issues, 'image', image)\n    }\n  }\n\n  const video = capabilities.video\n  if (video !== undefined) {\n    if (!isRecord(video)) {\n      pushIssue(issues, 'capabilities.video', 'video capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, 'video', video)\n      if (video.durationOptions !== undefined && !isNumberArray(video.durationOptions)) {\n        pushIssue(issues, 'capabilities.video.durationOptions', 'must be number array')\n      }\n      if (video.fpsOptions !== undefined && !isNumberArray(video.fpsOptions)) {\n        pushIssue(issues, 'capabilities.video.fpsOptions', 'must be number array')\n      }\n      if (video.resolutionOptions !== undefined && !isStringArray(video.resolutionOptions)) {\n        pushIssue(issues, 'capabilities.video.resolutionOptions', 'must be string array')\n      }\n      if (video.supportGenerateAudio !== undefined && typeof video.supportGenerateAudio !== 'boolean') {\n        pushIssue(issues, 'capabilities.video.supportGenerateAudio', 'must be boolean')\n      }\n      if (video.firstlastframe !== undefined && typeof video.firstlastframe !== 'boolean') {\n        pushIssue(issues, 'capabilities.video.firstlastframe', 'must be boolean')\n      }\n      validateFieldI18nMap(issues, 'video', video)\n    }\n  }\n\n  const audio = capabilities.audio\n  if (audio !== undefined) {\n    if (!isRecord(audio)) {\n      pushIssue(issues, 'capabilities.audio', 'audio capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, 'audio', audio)\n      if (audio.voiceOptions !== undefined && !isStringArray(audio.voiceOptions)) {\n        pushIssue(issues, 'capabilities.audio.voiceOptions', 'must be string array')\n      }\n      if (audio.rateOptions !== undefined && !isStringArray(audio.rateOptions)) {\n        pushIssue(issues, 'capabilities.audio.rateOptions', 'must be string array')\n      }\n      validateFieldI18nMap(issues, 'audio', audio)\n    }\n  }\n\n  const lipsync = capabilities.lipsync\n  if (lipsync !== undefined) {\n    if (!isRecord(lipsync)) {\n      pushIssue(issues, 'capabilities.lipsync', 'lipsync capabilities must be an object')\n    } else {\n      validateAllowedFields(issues, 'lipsync', lipsync)\n      if (lipsync.modeOptions !== undefined && !isStringArray(lipsync.modeOptions)) {\n        pushIssue(issues, 'capabilities.lipsync.modeOptions', 'must be string array')\n      }\n      validateFieldI18nMap(issues, 'lipsync', lipsync)\n    }\n  }\n\n  return issues\n}\n\nasync function main() {\n  let PrismaClient\n  try {\n    ({ PrismaClient } = await import('@prisma/client'))\n  } catch {\n    throw new Error('MISSING_DEPENDENCY: @prisma/client is not installed, run npm install first')\n  }\n\n  prisma = new PrismaClient()\n  const summary = {\n    generatedAt: new Date().toISOString(),\n    userPreference: {\n      total: 0,\n      invalidModelKeyFields: 0,\n      invalidCustomModelsJson: 0,\n      invalidCustomModelShape: 0,\n      invalidCapabilities: 0,\n    },\n    novelPromotionProject: {\n      total: 0,\n      invalidModelKeyFields: 0,\n    },\n    samples: [],\n  }\n\n  const userPrefs = await prisma.userPreference.findMany({\n    select: {\n      id: true,\n      customModels: true,\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n    },\n  })\n\n  for (const pref of userPrefs) {\n    summary.userPreference.total += 1\n    for (const field of MODEL_FIELDS) {\n      const rawValue = pref[field]\n      if (!rawValue) continue\n      if (!parseModelKeyStrict(rawValue)) {\n        summary.userPreference.invalidModelKeyFields += 1\n        addSample(summary, {\n          table: 'userPreference',\n          rowId: pref.id,\n          field,\n          reason: 'model field is not provider::modelId',\n        })\n      }\n    }\n\n    if (!pref.customModels) continue\n    let parsedCustomModels\n    try {\n      parsedCustomModels = JSON.parse(pref.customModels)\n    } catch {\n      summary.userPreference.invalidCustomModelsJson += 1\n      addSample(summary, {\n        table: 'userPreference',\n        rowId: pref.id,\n        field: 'customModels',\n        reason: 'invalid JSON',\n      })\n      continue\n    }\n    if (!Array.isArray(parsedCustomModels)) {\n      summary.userPreference.invalidCustomModelsJson += 1\n      addSample(summary, {\n        table: 'userPreference',\n        rowId: pref.id,\n        field: 'customModels',\n        reason: 'customModels is not array',\n      })\n      continue\n    }\n\n    for (let index = 0; index < parsedCustomModels.length; index += 1) {\n      const modelRaw = parsedCustomModels[index]\n      if (!isRecord(modelRaw)) {\n        summary.userPreference.invalidCustomModelShape += 1\n        addSample(summary, {\n          table: 'userPreference',\n          rowId: pref.id,\n          field: `customModels[${index}]`,\n          reason: 'model item is not object',\n        })\n        continue\n      }\n\n      const modelKey = isNonEmptyString(modelRaw.modelKey) ? modelRaw.modelKey.trim() : ''\n      const provider = isNonEmptyString(modelRaw.provider) ? modelRaw.provider.trim() : ''\n      const modelId = isNonEmptyString(modelRaw.modelId) ? modelRaw.modelId.trim() : ''\n      const parsed = parseModelKeyStrict(modelKey)\n      if (!parsed || parsed.provider !== provider || parsed.modelId !== modelId) {\n        summary.userPreference.invalidCustomModelShape += 1\n        addSample(summary, {\n          table: 'userPreference',\n          rowId: pref.id,\n          field: `customModels[${index}].modelKey`,\n          reason: 'modelKey/provider/modelId mismatch',\n        })\n      }\n\n      const modelType = isNonEmptyString(modelRaw.type) ? modelRaw.type.trim() : ''\n      const capabilityIssues = validateCapabilities(modelType, modelRaw.capabilities)\n      if (capabilityIssues.length > 0) {\n        summary.userPreference.invalidCapabilities += 1\n        addSample(summary, {\n          table: 'userPreference',\n          rowId: pref.id,\n          field: capabilityIssues[0].field,\n          reason: capabilityIssues[0].message,\n        })\n      }\n    }\n  }\n\n  const projects = await prisma.novelPromotionProject.findMany({\n    select: {\n      id: true,\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n    },\n  })\n\n  for (const project of projects) {\n    summary.novelPromotionProject.total += 1\n    for (const field of MODEL_FIELDS) {\n      const rawValue = project[field]\n      if (!rawValue) continue\n      if (!parseModelKeyStrict(rawValue)) {\n        summary.novelPromotionProject.invalidModelKeyFields += 1\n        addSample(summary, {\n          table: 'novelPromotionProject',\n          rowId: project.id,\n          field,\n          reason: 'model field is not provider::modelId',\n        })\n      }\n    }\n  }\n\n  process.stdout.write(`${JSON.stringify(summary, null, 2)}\\n`)\n\n  if (!STRICT) return\n  const hasViolations = summary.userPreference.invalidModelKeyFields > 0\n    || summary.userPreference.invalidCustomModelsJson > 0\n    || summary.userPreference.invalidCustomModelShape > 0\n    || summary.userPreference.invalidCapabilities > 0\n    || summary.novelPromotionProject.invalidModelKeyFields > 0\n\n  if (hasViolations) {\n    process.exitCode = 1\n  }\n}\n\nmain()\n  .catch((error) => {\n    process.stderr.write(`[check-model-config-contract] failed: ${String(error)}\\n`)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    if (prisma) {\n      await prisma.$disconnect()\n    }\n  })\n"
  },
  {
    "path": "scripts/check-no-console.ts",
    "content": "import { execSync } from 'node:child_process'\n\nconst ALLOWLIST = new Set<string>([\n  'src/lib/logging/core.ts',\n  'src/lib/logging/config.ts',\n  'src/lib/logging/context.ts',\n  'src/lib/logging/redact.ts',\n  'scripts/check-no-console.ts',\n  'scripts/guards/no-api-direct-llm-call.mjs',\n  'scripts/guards/no-internal-task-sync-fallback.mjs',\n  'scripts/guards/no-media-provider-bypass.mjs',\n  'scripts/guards/no-server-mirror-state.mjs',\n  'scripts/guards/task-loading-guard.mjs',\n  'scripts/guards/task-target-states-no-polling-guard.mjs',\n])\n\nfunction run(cmd: string): string {\n  try {\n    return execSync(cmd, { encoding: 'utf8' })\n  } catch (error: unknown) {\n    if (error && typeof error === 'object' && 'stdout' in error) {\n      const stdout = (error as { stdout?: unknown }).stdout\n      return typeof stdout === 'string' ? stdout : ''\n    }\n    return ''\n  }\n}\n\nfunction main() {\n  const output = run(`rg -n \"console\\\\\\\\.(log|info|warn|error|debug)\\\\\\\\(\" src scripts`)\n  const lines = output\n    .split('\\n')\n    .map((line) => line.trim())\n    .filter(Boolean)\n\n  const violations = lines.filter((line) => {\n    const file = line.split(':', 1)[0]\n    return !ALLOWLIST.has(file)\n  })\n\n  if (violations.length > 0) {\n    process.stderr.write('[check:logs] found forbidden console usage:\\n')\n    for (const line of violations) {\n      process.stderr.write(`- ${line}\\n`)\n    }\n    process.exit(1)\n  }\n\n  process.stdout.write(`[check:logs] ok scanned=${lines.length} allowlist=${ALLOWLIST.size}\\n`)\n}\n\nmain()\n"
  },
  {
    "path": "scripts/check-outbound-image-runtime-sample.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { TASK_TYPE } from '@/lib/task/types'\n\ntype AnyJson = unknown\n\ntype Match = {\n  path: string\n  value: string\n}\n\ntype Options = {\n  minutes: number\n  limit: number\n  projectId: string | null\n  strictNoData: boolean\n  includeEvents: boolean\n  maxEventsPerTask: number\n  json: boolean\n}\n\ntype FailureType = 'normalize' | 'model' | 'cancelled' | 'other'\n\nconst MODEL_ERROR_CODES = new Set([\n  'GENERATION_FAILED',\n  'GENERATION_TIMEOUT',\n  'RATE_LIMIT',\n  'EXTERNAL_ERROR',\n  'SENSITIVE_CONTENT',\n])\n\nfunction parseNumberArg(name: string, fallback: number): number {\n  const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))\n  if (!raw) return fallback\n  const value = Number.parseInt(raw.split('=')[1] || '', 10)\n  return Number.isFinite(value) && value > 0 ? value : fallback\n}\n\nfunction parseStringArg(name: string): string | null {\n  const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))\n  if (!raw) return null\n  const value = (raw.split('=')[1] || '').trim()\n  return value || null\n}\n\nfunction parseBooleanArg(name: string, fallback = false): boolean {\n  const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))\n  if (!raw) return fallback\n  const value = (raw.split('=')[1] || '').trim().toLowerCase()\n  return value === '1' || value === 'true' || value === 'yes' || value === 'on'\n}\n\nfunction parseOptions(): Options {\n  return {\n    minutes: parseNumberArg('minutes', 60 * 24),\n    limit: parseNumberArg('limit', 200),\n    projectId: parseStringArg('projectId'),\n    strictNoData: parseBooleanArg('strictNoData', false),\n    includeEvents: parseBooleanArg('includeEvents', false),\n    maxEventsPerTask: parseNumberArg('maxEventsPerTask', 40),\n    json: parseBooleanArg('json', false),\n  }\n}\n\nfunction toExcerpt(value: string, max = 180): string {\n  if (value.length <= max) return value\n  return `${value.slice(0, max)}...`\n}\n\nfunction findStringMatches(\n  value: AnyJson,\n  predicate: (input: string) => boolean,\n  path = '$',\n  matches: Match[] = [],\n): Match[] {\n  if (typeof value === 'string') {\n    if (predicate(value)) matches.push({ path, value })\n    return matches\n  }\n  if (Array.isArray(value)) {\n    value.forEach((item, index) => {\n      findStringMatches(item, predicate, `${path}[${index}]`, matches)\n    })\n    return matches\n  }\n  if (value && typeof value === 'object') {\n    for (const [key, next] of Object.entries(value as Record<string, unknown>)) {\n      findStringMatches(next, predicate, `${path}.${key}`, matches)\n    }\n  }\n  return matches\n}\n\nfunction classifyFailure(task: {\n  errorCode: string | null\n  errorMessage: string | null\n  result: AnyJson | null\n  events: Array<{ payload: AnyJson | null }>\n}): FailureType {\n  const code = (task.errorCode || '').trim().toUpperCase()\n  const normalizeRe = /normalize|video_frame_normalize|normalizeReferenceImagesForGeneration|reference image normalize failed|outbound image input is empty|relative_path_rejected/i\n  const modelRe = /generation failed|provider|upstream|rate limit|timed out|timeout|sensitive/i\n\n  if (code === 'TASK_CANCELLED') return 'cancelled'\n  if (MODEL_ERROR_CODES.has(code)) return 'model'\n  if (code) {\n    const explicitNormalizeCode = code === 'INVALID_PARAMS' || code === 'OUTBOUND_IMAGE_FETCH_FAILED'\n    if (explicitNormalizeCode) return 'normalize'\n    return 'other'\n  }\n\n  const values: string[] = []\n  if (code) values.push(code)\n  if (task.errorMessage) values.push(task.errorMessage)\n  if (task.result) {\n    for (const hit of findStringMatches(task.result, () => true)) {\n      values.push(hit.value)\n    }\n  }\n  for (const event of task.events) {\n    if (!event.payload) continue\n    for (const hit of findStringMatches(event.payload, () => true)) {\n      values.push(hit.value)\n    }\n  }\n\n  if (values.some((item) => normalizeRe.test(item))) return 'normalize'\n  if (values.some((item) => modelRe.test(item))) return 'model'\n  return 'other'\n}\n\nasync function main() {\n  const options = parseOptions()\n  const since = new Date(Date.now() - options.minutes * 60_000)\n  const monitoredTypes = [\n    TASK_TYPE.MODIFY_ASSET_IMAGE,\n    TASK_TYPE.ASSET_HUB_MODIFY,\n    TASK_TYPE.VIDEO_PANEL,\n  ]\n\n  const tasks = await prisma.task.findMany({\n    where: {\n      type: { in: monitoredTypes },\n      createdAt: { gte: since },\n      ...(options.projectId ? { projectId: options.projectId } : {}),\n    },\n    select: {\n      id: true,\n      type: true,\n      status: true,\n      projectId: true,\n      targetType: true,\n      targetId: true,\n      createdAt: true,\n      errorCode: true,\n      errorMessage: true,\n      payload: true,\n      result: true,\n    },\n    orderBy: { createdAt: 'desc' },\n    take: options.limit,\n  })\n\n  if (tasks.length === 0) {\n    process.stdout.write(\n      `[check:outbound-image-runtime-sample] no data window=${options.minutes}m limit=${options.limit} strictNoData=${options.strictNoData}\\n`,\n    )\n    if (options.strictNoData) process.exit(2)\n    return\n  }\n\n  const eventsByTaskId = new Map<string, Array<{ eventType: string; payload: AnyJson | null; createdAt: Date }>>()\n  let eventCount = 0\n  if (options.includeEvents) {\n    for (const task of tasks) {\n      const rows = await prisma.taskEvent.findMany({\n        where: { taskId: task.id },\n        select: {\n          taskId: true,\n          eventType: true,\n          payload: true,\n          createdAt: true,\n        },\n        orderBy: { id: 'desc' },\n        take: options.maxEventsPerTask,\n      })\n      const ordered = [...rows].reverse()\n      eventCount += ordered.length\n      if (ordered.length > 0) {\n        eventsByTaskId.set(\n          task.id,\n          ordered.map((event) => ({\n            eventType: event.eventType,\n            payload: event.payload,\n            createdAt: event.createdAt,\n          })),\n        )\n      }\n    }\n  }\n\n  const nextImagePredicate = (input: string) => input.includes('/_next/image')\n  const hits: Array<{\n    taskId: string\n    taskType: string\n    source: 'task.payload' | 'task.result' | 'task.event'\n    path: string\n    value: string\n  }> = []\n\n  let failedCount = 0\n  const failedByClass: Record<FailureType, number> = {\n    normalize: 0,\n    model: 0,\n    cancelled: 0,\n    other: 0,\n  }\n  const failedByCode: Record<string, number> = {}\n\n  for (const task of tasks) {\n    const taskEventsForTask = eventsByTaskId.get(task.id) || []\n\n    if (task.payload) {\n      for (const match of findStringMatches(task.payload, nextImagePredicate)) {\n        hits.push({\n          taskId: task.id,\n          taskType: task.type,\n          source: 'task.payload',\n          path: match.path,\n          value: match.value,\n        })\n      }\n    }\n\n    if (task.result) {\n      for (const match of findStringMatches(task.result, nextImagePredicate)) {\n        hits.push({\n          taskId: task.id,\n          taskType: task.type,\n          source: 'task.result',\n          path: match.path,\n          value: match.value,\n        })\n      }\n    }\n\n    for (const event of taskEventsForTask) {\n      if (!event.payload) continue\n      for (const match of findStringMatches(event.payload, nextImagePredicate)) {\n        hits.push({\n          taskId: task.id,\n          taskType: task.type,\n          source: 'task.event',\n          path: match.path,\n          value: match.value,\n        })\n      }\n    }\n\n    if (task.status === 'failed') {\n      failedCount += 1\n      const code = (task.errorCode || 'UNKNOWN').trim() || 'UNKNOWN'\n      failedByCode[code] = (failedByCode[code] || 0) + 1\n      const failureType = classifyFailure({\n        errorCode: task.errorCode,\n        errorMessage: task.errorMessage,\n        result: task.result,\n        events: taskEventsForTask,\n      })\n      failedByClass[failureType] += 1\n    }\n  }\n\n  const typeCount = tasks.reduce<Record<string, number>>((acc, item) => {\n    acc[item.type] = (acc[item.type] || 0) + 1\n    return acc\n  }, {})\n\n  process.stdout.write(\n    `[check:outbound-image-runtime-sample] window=${options.minutes}m sampled=${tasks.length} events=${eventCount} includeEvents=${options.includeEvents} next_image_hits=${hits.length}\\n`,\n  )\n  process.stdout.write(`[check:outbound-image-runtime-sample] task_types=${JSON.stringify(typeCount)}\\n`)\n  process.stdout.write(\n    `[check:outbound-image-runtime-sample] failures total=${failedCount} normalize=${failedByClass.normalize} model=${failedByClass.model} cancelled=${failedByClass.cancelled} other=${failedByClass.other} by_code=${JSON.stringify(failedByCode)}\\n`,\n  )\n\n  if (options.json) {\n    process.stdout.write(\n      `${JSON.stringify({\n        windowMinutes: options.minutes,\n        sampled: tasks.length,\n        events: eventCount,\n        includeEvents: options.includeEvents,\n        nextImageHits: hits.length,\n        taskTypes: typeCount,\n        failures: {\n          total: failedCount,\n          byClass: failedByClass,\n          byCode: failedByCode,\n        },\n      })}\\n`,\n    )\n  }\n\n  if (hits.length > 0) {\n    process.stderr.write('[check:outbound-image-runtime-sample] found /_next/image contamination:\\n')\n    for (const hit of hits.slice(0, 20)) {\n      process.stderr.write(\n        `- task=${hit.taskId} type=${hit.taskType} source=${hit.source} path=${hit.path} value=${toExcerpt(hit.value)}\\n`,\n      )\n    }\n    process.exit(1)\n  }\n}\n\nmain()\n  .catch((error) => {\n    const message = error instanceof Error ? error.message : String(error)\n    process.stderr.write(`[check:outbound-image-runtime-sample] failed: ${message}\\n`)\n    process.exit(1)\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/check-outbound-image-success-rate.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'\n\ntype StatusCount = Record<string, number>\n\ntype WindowSummary = {\n  total: number\n  finishedTotal: number\n  completed: number\n  failed: number\n  successRate: number | null\n  byStatus: StatusCount\n  byType: Record<string, number>\n}\n\ntype Options = {\n  minutes: number\n  baselineMinutes: number\n  baselineOffsetMinutes: number\n  projectId: string | null\n  tolerancePct: number\n  minFinishedSamples: number\n  strict: boolean\n  json: boolean\n}\n\nconst DEFAULT_MINUTES = 60 * 24 * 7\nconst DEFAULT_TOLERANCE_PCT = 2\nconst DEFAULT_MIN_FINISHED_SAMPLES = 20\n\nfunction parseNumberArg(name: string, fallback: number): number {\n  const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))\n  if (!raw) return fallback\n  const value = Number.parseFloat(raw.split('=')[1] || '')\n  return Number.isFinite(value) && value > 0 ? value : fallback\n}\n\nfunction parseBooleanArg(name: string, fallback = false): boolean {\n  const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))\n  if (!raw) return fallback\n  const value = (raw.split('=')[1] || '').trim().toLowerCase()\n  return value === '1' || value === 'true' || value === 'yes' || value === 'on'\n}\n\nfunction parseStringArg(name: string): string | null {\n  const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))\n  if (!raw) return null\n  const value = (raw.split('=')[1] || '').trim()\n  return value || null\n}\n\nfunction parseOptions(): Options {\n  const minutes = parseNumberArg('minutes', DEFAULT_MINUTES)\n  const baselineMinutes = parseNumberArg('baselineMinutes', minutes)\n  const baselineOffsetMinutes = parseNumberArg('baselineOffsetMinutes', minutes)\n  return {\n    minutes,\n    baselineMinutes,\n    baselineOffsetMinutes,\n    projectId: parseStringArg('projectId'),\n    tolerancePct: parseNumberArg('tolerancePct', DEFAULT_TOLERANCE_PCT),\n    minFinishedSamples: parseNumberArg('minFinishedSamples', DEFAULT_MIN_FINISHED_SAMPLES),\n    strict: parseBooleanArg('strict', false),\n    json: parseBooleanArg('json', false),\n  }\n}\n\nfunction asPct(value: number | null): string {\n  return value === null ? 'N/A' : `${value.toFixed(2)}%`\n}\n\nfunction getSuccessRate(completed: number, failed: number): number | null {\n  const total = completed + failed\n  if (total <= 0) return null\n  return (completed / total) * 100\n}\n\nfunction summarizeRows(\n  rows: Array<{ status: string; type: string }>,\n): WindowSummary {\n  const byStatus: StatusCount = {}\n  const byType: Record<string, number> = {}\n  for (const row of rows) {\n    byStatus[row.status] = (byStatus[row.status] || 0) + 1\n    byType[row.type] = (byType[row.type] || 0) + 1\n  }\n\n  const completed = byStatus[TASK_STATUS.COMPLETED] || 0\n  const failed = byStatus[TASK_STATUS.FAILED] || 0\n  const finishedTotal = completed + failed\n\n  return {\n    total: rows.length,\n    finishedTotal,\n    completed,\n    failed,\n    successRate: getSuccessRate(completed, failed),\n    byStatus,\n    byType,\n  }\n}\n\nasync function fetchWindowSummary(params: {\n  from: Date\n  to: Date\n  projectId: string | null\n}) {\n  const monitoredTypes = [\n    TASK_TYPE.MODIFY_ASSET_IMAGE,\n    TASK_TYPE.ASSET_HUB_MODIFY,\n    TASK_TYPE.VIDEO_PANEL,\n  ]\n\n  const rows = await prisma.task.findMany({\n    where: {\n      type: { in: monitoredTypes },\n      createdAt: {\n        gte: params.from,\n        lt: params.to,\n      },\n      ...(params.projectId ? { projectId: params.projectId } : {}),\n    },\n    select: {\n      status: true,\n      type: true,\n    },\n  })\n\n  return summarizeRows(rows)\n}\n\nasync function main() {\n  const options = parseOptions()\n  const now = Date.now()\n\n  const currentEnd = new Date(now)\n  const currentStart = new Date(now - options.minutes * 60_000)\n\n  const baselineEnd = new Date(now - options.baselineOffsetMinutes * 60_000)\n  const baselineStart = new Date(baselineEnd.getTime() - options.baselineMinutes * 60_000)\n\n  const [current, baseline] = await Promise.all([\n    fetchWindowSummary({\n      from: currentStart,\n      to: currentEnd,\n      projectId: options.projectId,\n    }),\n    fetchWindowSummary({\n      from: baselineStart,\n      to: baselineEnd,\n      projectId: options.projectId,\n    }),\n  ])\n\n  const hasEnoughCurrent = current.finishedTotal >= options.minFinishedSamples\n  const hasEnoughBaseline = baseline.finishedTotal >= options.minFinishedSamples\n  const hasEnoughSamples = hasEnoughCurrent && hasEnoughBaseline\n\n  const rateDeltaPct =\n    current.successRate !== null && baseline.successRate !== null\n      ? current.successRate - baseline.successRate\n      : null\n\n  const meetsTolerance =\n    rateDeltaPct !== null\n      ? rateDeltaPct >= -Math.abs(options.tolerancePct)\n      : false\n\n  const status = hasEnoughSamples\n    ? meetsTolerance\n      ? 'pass'\n      : 'fail'\n    : 'blocked'\n\n  process.stdout.write(\n    `[check:outbound-image-success-rate] current=${asPct(current.successRate)} baseline=${asPct(baseline.successRate)} delta=${asPct(rateDeltaPct)} tolerance=-${Math.abs(options.tolerancePct).toFixed(2)}% status=${status}\\n`,\n  )\n  process.stdout.write(\n    `[check:outbound-image-success-rate] current_finished=${current.finishedTotal} baseline_finished=${baseline.finishedTotal} min_required=${options.minFinishedSamples}\\n`,\n  )\n  process.stdout.write(\n    `[check:outbound-image-success-rate] current_by_type=${JSON.stringify(current.byType)} baseline_by_type=${JSON.stringify(baseline.byType)}\\n`,\n  )\n\n  if (options.json) {\n    process.stdout.write(\n      `${JSON.stringify({\n        status,\n        tolerancePct: options.tolerancePct,\n        minFinishedSamples: options.minFinishedSamples,\n        windows: {\n          current: {\n            from: currentStart.toISOString(),\n            to: currentEnd.toISOString(),\n            ...current,\n          },\n          baseline: {\n            from: baselineStart.toISOString(),\n            to: baselineEnd.toISOString(),\n            ...baseline,\n          },\n        },\n        rateDeltaPct,\n        hasEnoughSamples,\n      })}\\n`,\n    )\n  }\n\n  if (!options.strict) return\n\n  if (status === 'pass') return\n  if (status === 'blocked') process.exit(2)\n  process.exit(1)\n}\n\nmain()\n  .catch((error) => {\n    const message = error instanceof Error ? error.message : String(error)\n    process.stderr.write(`[check:outbound-image-success-rate] failed: ${message}\\n`)\n    process.exit(1)\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/check-outbound-image-unification.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\n\ntype Rule = {\n  file: string\n  pattern: RegExp\n  message: string\n}\n\nfunction readFile(relativePath: string): string {\n  const fullPath = path.resolve(process.cwd(), relativePath)\n  return fs.readFileSync(fullPath, 'utf8')\n}\n\nconst mustIncludeRules: Rule[] = [\n  {\n    file: 'src/lib/media/outbound-image.ts',\n    pattern: /export\\s+async\\s+function\\s+normalizeToOriginalMediaUrl\\s*\\(/,\n    message: 'missing normalizeToOriginalMediaUrl export',\n  },\n  {\n    file: 'src/lib/media/outbound-image.ts',\n    pattern: /export\\s+async\\s+function\\s+normalizeToBase64ForGeneration\\s*\\(/,\n    message: 'missing normalizeToBase64ForGeneration export',\n  },\n  {\n    file: 'src/lib/media/outbound-image.ts',\n    pattern: /export\\s+async\\s+function\\s+normalizeReferenceImagesForGeneration\\s*\\(/,\n    message: 'missing normalizeReferenceImagesForGeneration export',\n  },\n  {\n    file: 'src/lib/media/outbound-image.ts',\n    pattern: /class\\s+OutboundImageNormalizeError\\s+extends\\s+Error/,\n    message: 'outbound-image.ts must expose structured normalize error type',\n  },\n  {\n    file: 'src/lib/media/outbound-image.ts',\n    pattern: /OUTBOUND_IMAGE_FETCH_FAILED/,\n    message: 'outbound-image.ts must classify fetch failures with structured error codes',\n  },\n  {\n    file: 'src/lib/media/outbound-image.ts',\n    pattern: /OUTBOUND_IMAGE_REFERENCE_ALL_FAILED/,\n    message: 'outbound-image.ts must fail explicitly when all references fail to normalize',\n  },\n  {\n    file: 'src/lib/workers/handlers/image-task-handlers-core.ts',\n    pattern: /normalizeToBase64ForGeneration\\(currentUrl\\)/,\n    message: 'image-task-handlers-core.ts must convert currentUrl to base64 before outbound',\n  },\n  {\n    file: 'src/lib/workers/handlers/image-task-handlers-core.ts',\n    pattern: /normalizeReferenceImagesForGeneration\\(extraReferenceInputs\\)/,\n    message: 'image-task-handlers-core.ts must normalize extra references before outbound',\n  },\n  {\n    file: 'src/lib/workers/video.worker.ts',\n    pattern: /const\\s+sourceImageBase64\\s*=\\s*await\\s+normalizeToBase64ForGeneration\\(sourceImageUrl\\)/,\n    message: 'video.worker.ts must normalize source frame to base64',\n  },\n  {\n    file: 'src/lib/workers/video.worker.ts',\n    pattern: /lastFrameImageBase64\\s*=\\s*await\\s+normalizeToBase64ForGeneration\\(lastFrameUrl\\)/,\n    message: 'video.worker.ts must normalize last frame to base64',\n  },\n  {\n    file: 'src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts',\n    pattern: /sanitizeImageInputsForTaskPayload/,\n    message: 'modify-asset-image route must sanitize image inputs',\n  },\n  {\n    file: 'src/app/api/novel-promotion/[projectId]/modify-storyboard-image/route.ts',\n    pattern: /sanitizeImageInputsForTaskPayload/,\n    message: 'modify-storyboard-image route must sanitize image inputs',\n  },\n  {\n    file: 'src/app/api/asset-hub/modify-image/route.ts',\n    pattern: /sanitizeImageInputsForTaskPayload/,\n    message: 'asset-hub modify-image route must sanitize image inputs',\n  },\n  {\n    file: 'src/components/ui/ImagePreviewModal.tsx',\n    pattern: /import\\s+\\{\\s*resolveOriginalImageUrl,\\s*toDisplayImageUrl\\s*\\}\\s+from\\s+'@\\/lib\\/media\\/image-url'/,\n    message: 'ImagePreviewModal must use shared image-url helpers',\n  },\n  {\n    file: 'src/lib/novel-promotion/stages/video-stage-runtime-core.tsx',\n    pattern: /onPreviewImage=\\{setPreviewImage\\}/,\n    message: 'Video stage runtime must wire preview callback to VideoPanelCard',\n  },\n  {\n    file: 'src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/types.ts',\n    pattern: /onPreviewImage\\?:\\s*\\(imageUrl:\\s*string\\)\\s*=>\\s*void/,\n    message: 'VideoPanelCard runtime props must expose onPreviewImage',\n  },\n  {\n    file: 'src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/VideoPanelCardHeader.tsx',\n    pattern: /className=\"absolute left-1\\/2 top-1\\/2 z-10 h-16 w-16 -translate-x-1\\/2 -translate-y-1\\/2 rounded-full\"/,\n    message: 'VideoPanelCard play trigger must be centered small button (preview/play separation)',\n  },\n]\n\nconst mustNotIncludeRules: Rule[] = [\n  {\n    file: 'src/lib/workers/handlers/image-task-handlers-core.ts',\n    pattern: /referenceImages:\\s*\\[currentUrl\\]/,\n    message: 'image-task-handlers-core.ts must not pass raw currentUrl directly as outbound reference',\n  },\n  {\n    file: 'src/lib/workers/video.worker.ts',\n    pattern: /imageUrl:\\s*sourceImageUrl/,\n    message: 'video.worker.ts must not pass raw sourceImageUrl to generator',\n  },\n  {\n    file: 'src/lib/media/outbound-image.ts',\n    pattern: /return\\s+await\\s+toFetchableAbsoluteUrl\\(mediaPath\\)/,\n    message: 'outbound-image.ts must not silently fallback when /m route cannot resolve storage key',\n  },\n  {\n    file: 'src/lib/media/outbound-image.ts',\n    pattern: /export\\s+async\\s+function\\s+imageUrlToBase64\\s*\\(/,\n    message: 'outbound-image.ts must not keep legacy imageUrlToBase64 alias after phase 2 migration',\n  },\n  {\n    file: 'src/lib/media/outbound-image.ts',\n    pattern: /return\\s+await\\s+toFetchableAbsoluteUrl\\(unwrappedInput\\)/,\n    message: 'outbound-image.ts must not silently fallback unknown inputs to fetchable url',\n  },\n]\n\nfunction main() {\n  const errors: string[] = []\n  const cache = new Map<string, string>()\n\n  const getContent = (file: string) => {\n    if (!cache.has(file)) cache.set(file, readFile(file))\n    return cache.get(file) as string\n  }\n\n  for (const rule of mustIncludeRules) {\n    const content = getContent(rule.file)\n    if (!rule.pattern.test(content)) {\n      errors.push(`${rule.file}: ${rule.message}`)\n    }\n  }\n\n  for (const rule of mustNotIncludeRules) {\n    const content = getContent(rule.file)\n    if (rule.pattern.test(content)) {\n      errors.push(`${rule.file}: ${rule.message}`)\n    }\n  }\n\n  if (errors.length > 0) {\n    process.stderr.write('[check:outbound-image-unification] found violations:\\n')\n    for (const error of errors) {\n      process.stderr.write(`- ${error}\\n`)\n    }\n    process.exit(1)\n  }\n\n  process.stdout.write(\n    `[check:outbound-image-unification] ok include_checks=${mustIncludeRules.length} exclude_checks=${mustNotIncludeRules.length}\\n`,\n  )\n}\n\nmain()\n"
  },
  {
    "path": "scripts/check-pricing-catalog.mjs",
    "content": "import { promises as fs } from 'node:fs'\nimport path from 'node:path'\n\nconst CATALOG_DIR = path.resolve(process.cwd(), 'standards/pricing')\nconst CAPABILITY_CATALOG_FILE = path.resolve(process.cwd(), 'standards/capabilities/image-video.catalog.json')\nconst API_TYPES = new Set(['text', 'image', 'video', 'voice', 'voice-design', 'lip-sync'])\nconst PRICING_MODES = new Set(['flat', 'capability'])\nconst TEXT_TOKEN_TYPES = new Set(['input', 'output'])\n\nfunction isRecord(value) {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isNonEmptyString(value) {\n  return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction isCapabilityValue(value) {\n  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction isFiniteNumber(value) {\n  return typeof value === 'number' && Number.isFinite(value)\n}\n\nfunction pushIssue(issues, file, index, field, message) {\n  issues.push({ file, index, field, message })\n}\n\nfunction getProviderKey(providerId) {\n  const marker = providerId.indexOf(':')\n  return marker === -1 ? providerId : providerId.slice(0, marker)\n}\n\nfunction buildModelKey(modelType, provider, modelId) {\n  return `${modelType}::${provider}::${modelId}`\n}\n\nasync function listCatalogFiles() {\n  const entries = await fs.readdir(CATALOG_DIR, { withFileTypes: true })\n  return entries\n    .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))\n    .map((entry) => path.join(CATALOG_DIR, entry.name))\n}\n\nasync function readCatalog(filePath) {\n  const raw = await fs.readFile(filePath, 'utf8')\n  const parsed = JSON.parse(raw)\n  if (!Array.isArray(parsed)) {\n    throw new Error(`catalog must be an array: ${filePath}`)\n  }\n  return parsed\n}\n\nasync function readCapabilityCatalog() {\n  const raw = await fs.readFile(CAPABILITY_CATALOG_FILE, 'utf8')\n  const parsed = JSON.parse(raw)\n  if (!Array.isArray(parsed)) {\n    throw new Error(`capability catalog must be an array: ${CAPABILITY_CATALOG_FILE}`)\n  }\n  return parsed\n}\n\nfunction extractCapabilityOptionFields(modelType, capabilities) {\n  if (!isRecord(capabilities)) return new Set()\n  const namespace = capabilities[modelType]\n  if (!isRecord(namespace)) return new Set()\n\n  const fields = new Set()\n  for (const [key, value] of Object.entries(namespace)) {\n    if (!key.endsWith('Options')) continue\n    if (!Array.isArray(value) || value.length === 0) continue\n    const field = key.slice(0, -'Options'.length)\n    fields.add(field)\n  }\n  return fields\n}\n\nfunction buildCapabilityOptionFieldMap(capabilityEntries) {\n  const map = new Map()\n  for (const entry of capabilityEntries) {\n    if (!isRecord(entry)) continue\n    const modelType = typeof entry.modelType === 'string' ? entry.modelType.trim() : ''\n    const provider = typeof entry.provider === 'string' ? entry.provider.trim() : ''\n    const modelId = typeof entry.modelId === 'string' ? entry.modelId.trim() : ''\n    if (!modelType || !provider || !modelId) continue\n\n    const fields = extractCapabilityOptionFields(modelType, entry.capabilities)\n    map.set(buildModelKey(modelType, provider, modelId), fields)\n    const providerKey = getProviderKey(provider)\n    const fallbackKey = buildModelKey(modelType, providerKey, modelId)\n    if (!map.has(fallbackKey)) {\n      map.set(fallbackKey, fields)\n    }\n  }\n  return map\n}\n\nfunction validateTier(issues, file, index, tier, tierIndex) {\n  if (!isRecord(tier)) {\n    pushIssue(issues, file, index, `pricing.tiers[${tierIndex}]`, 'tier must be object')\n    return\n  }\n\n  if (!isRecord(tier.when) || Object.keys(tier.when).length === 0) {\n    pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when`, 'when must be non-empty object')\n  } else {\n    for (const [field, value] of Object.entries(tier.when)) {\n      if (!isCapabilityValue(value)) {\n        pushIssue(\n          issues,\n          file,\n          index,\n          `pricing.tiers[${tierIndex}].when.${field}`,\n          'condition value must be string/number/boolean',\n        )\n      }\n    }\n  }\n\n  if (!isFiniteNumber(tier.amount) || tier.amount < 0) {\n    pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].amount`, 'amount must be finite number >= 0')\n  }\n}\n\nfunction validateTextCapabilityTiers(issues, file, index, tiers) {\n  const seenTokenTypes = new Set()\n\n  for (let tierIndex = 0; tierIndex < tiers.length; tierIndex += 1) {\n    const tier = tiers[tierIndex]\n    if (!isRecord(tier) || !isRecord(tier.when)) continue\n\n    const whenFields = Object.keys(tier.when)\n    if (whenFields.length !== 1 || whenFields[0] !== 'tokenType') {\n      pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when`, 'text capability tier must only contain tokenType')\n      continue\n    }\n\n    const tokenType = tier.when.tokenType\n    if (typeof tokenType !== 'string' || !TEXT_TOKEN_TYPES.has(tokenType)) {\n      pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when.tokenType`, 'tokenType must be input or output')\n      continue\n    }\n\n    if (seenTokenTypes.has(tokenType)) {\n      pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when.tokenType`, `duplicate tokenType tier: ${tokenType}`)\n      continue\n    }\n    seenTokenTypes.add(tokenType)\n  }\n\n  for (const requiredTokenType of TEXT_TOKEN_TYPES) {\n    if (!seenTokenTypes.has(requiredTokenType)) {\n      pushIssue(issues, file, index, 'pricing.tiers', `missing text tier tokenType=${requiredTokenType}`)\n    }\n  }\n}\n\nfunction validateMediaCapabilityTierFields(issues, file, index, item, tiers, capabilityOptionFieldsMap) {\n  const modelType = item.apiType\n  const provider = item.provider\n  const modelId = item.modelId\n  const modelKey = buildModelKey(modelType, provider, modelId)\n  const fallbackKey = buildModelKey(modelType, getProviderKey(provider), modelId)\n  const optionFields = capabilityOptionFieldsMap.get(modelKey) || capabilityOptionFieldsMap.get(fallbackKey)\n\n  if (!optionFields || optionFields.size === 0) {\n    pushIssue(issues, file, index, 'pricing.tiers', `no capability option fields found for ${modelType} ${provider}/${modelId}`)\n    return\n  }\n\n  for (let tierIndex = 0; tierIndex < tiers.length; tierIndex += 1) {\n    const tier = tiers[tierIndex]\n    if (!isRecord(tier) || !isRecord(tier.when)) continue\n    for (const field of Object.keys(tier.when)) {\n      if (!optionFields.has(field)) {\n        pushIssue(\n          issues,\n          file,\n          index,\n          `pricing.tiers[${tierIndex}].when.${field}`,\n          `field ${field} is not declared in capabilities options for ${modelType} ${provider}/${modelId}`,\n        )\n      }\n    }\n  }\n}\n\nfunction validateDuplicateCapabilityTiers(issues, file, index, tiers) {\n  const seen = new Set()\n  for (let tierIndex = 0; tierIndex < tiers.length; tierIndex += 1) {\n    const tier = tiers[tierIndex]\n    if (!isRecord(tier) || !isRecord(tier.when)) continue\n    const signature = JSON.stringify(Object.entries(tier.when).sort((left, right) => left[0].localeCompare(right[0])))\n    if (seen.has(signature)) {\n      pushIssue(issues, file, index, `pricing.tiers[${tierIndex}].when`, 'duplicate capability tier condition')\n      continue\n    }\n    seen.add(signature)\n  }\n}\n\nfunction validatePricing(issues, file, index, item, capabilityOptionFieldsMap) {\n  const pricing = item.pricing\n  if (!isRecord(pricing)) {\n    pushIssue(issues, file, index, 'pricing', 'pricing must be object')\n    return\n  }\n\n  if (!isNonEmptyString(pricing.mode) || !PRICING_MODES.has(pricing.mode)) {\n    pushIssue(issues, file, index, 'pricing.mode', 'pricing.mode must be flat or capability')\n    return\n  }\n\n  if (pricing.mode === 'flat') {\n    if (!isFiniteNumber(pricing.flatAmount) || pricing.flatAmount < 0) {\n      pushIssue(issues, file, index, 'pricing.flatAmount', 'flatAmount must be finite number >= 0')\n    }\n    return\n  }\n\n  if (!Array.isArray(pricing.tiers) || pricing.tiers.length === 0) {\n    pushIssue(issues, file, index, 'pricing.tiers', 'tiers must be non-empty array')\n    return\n  }\n\n  for (let tierIndex = 0; tierIndex < pricing.tiers.length; tierIndex += 1) {\n    validateTier(issues, file, index, pricing.tiers[tierIndex], tierIndex)\n  }\n\n  validateDuplicateCapabilityTiers(issues, file, index, pricing.tiers)\n\n  if (item.apiType === 'text') {\n    validateTextCapabilityTiers(issues, file, index, pricing.tiers)\n    return\n  }\n\n  if (item.apiType === 'image' || item.apiType === 'video') {\n    validateMediaCapabilityTierFields(issues, file, index, item, pricing.tiers, capabilityOptionFieldsMap)\n  }\n}\n\nasync function main() {\n  const issues = []\n  const files = await listCatalogFiles()\n  const capabilityCatalog = await readCapabilityCatalog()\n  const capabilityOptionFieldsMap = buildCapabilityOptionFieldMap(capabilityCatalog)\n  if (files.length === 0) {\n    throw new Error(`no pricing files found in ${CATALOG_DIR}`)\n  }\n\n  for (const filePath of files) {\n    const items = await readCatalog(filePath)\n    for (let index = 0; index < items.length; index += 1) {\n      const item = items[index]\n      if (!isRecord(item)) {\n        pushIssue(issues, filePath, index, 'entry', 'entry must be object')\n        continue\n      }\n\n      if (!isNonEmptyString(item.apiType) || !API_TYPES.has(item.apiType)) {\n        pushIssue(issues, filePath, index, 'apiType', 'apiType must be one of text/image/video/voice/voice-design/lip-sync')\n      }\n      if (!isNonEmptyString(item.provider)) {\n        pushIssue(issues, filePath, index, 'provider', 'provider must be non-empty string')\n      }\n      if (!isNonEmptyString(item.modelId)) {\n        pushIssue(issues, filePath, index, 'modelId', 'modelId must be non-empty string')\n      }\n\n      validatePricing(issues, filePath, index, item, capabilityOptionFieldsMap)\n    }\n  }\n\n  if (issues.length === 0) {\n    process.stdout.write(`[check-pricing-catalog] OK (${files.length} files)\\n`)\n    return\n  }\n\n  const maxPrint = 50\n  for (const issue of issues.slice(0, maxPrint)) {\n    process.stdout.write(`[check-pricing-catalog] ${issue.file}#${issue.index} ${issue.field}: ${issue.message}\\n`)\n  }\n  if (issues.length > maxPrint) {\n    process.stdout.write(`[check-pricing-catalog] ... ${issues.length - maxPrint} more issues\\n`)\n  }\n  process.exitCode = 1\n}\n\nmain().catch((error) => {\n  process.stderr.write(`[check-pricing-catalog] failed: ${String(error)}\\n`)\n  process.exitCode = 1\n})\n"
  },
  {
    "path": "scripts/cleanup-remove-legacy-voice-data.ts",
    "content": "import { prisma } from '@/lib/prisma'\n\ntype CharacterVoiceRecord = {\n  id: string\n  customVoiceUrl: string | null\n}\n\ntype SpeakerVoiceConfig = {\n  voiceType?: unknown\n  voiceId?: unknown\n  audioUrl?: unknown\n  [key: string]: unknown\n}\n\ntype CleanupSummary = {\n  projectCharactersUpdated: number\n  globalCharactersUpdated: number\n  episodeSpeakerVoicesUpdated: number\n  episodeSpeakerVoicesCleared: number\n  invalidSpeakerVoicesSkipped: number\n}\n\nfunction hasPlayableAudioUrl(value: unknown) {\n  return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction normalizeVoiceType(customVoiceUrl: string | null) {\n  return hasPlayableAudioUrl(customVoiceUrl) ? 'custom' : null\n}\n\nasync function cleanupCharacterTable(records: CharacterVoiceRecord[], table: 'project' | 'global') {\n  let updated = 0\n  for (const row of records) {\n    const nextVoiceType = normalizeVoiceType(row.customVoiceUrl)\n    if (table === 'project') {\n      await prisma.novelPromotionCharacter.update({\n        where: { id: row.id },\n        data: {\n          voiceType: nextVoiceType,\n          voiceId: null,\n        },\n      })\n    } else {\n      await prisma.globalCharacter.update({\n        where: { id: row.id },\n        data: {\n          voiceType: nextVoiceType,\n          voiceId: null,\n        },\n      })\n    }\n    updated += 1\n  }\n  return updated\n}\n\nfunction normalizeSpeakerVoices(payload: string): {\n  ok: true\n  changed: boolean\n  cleared: boolean\n  next: string | null\n} | {\n  ok: false\n} {\n  let parsed: unknown\n  try {\n    parsed = JSON.parse(payload)\n  } catch {\n    return { ok: false }\n  }\n\n  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n    return { ok: false }\n  }\n\n  const source = parsed as Record<string, unknown>\n  const next: Record<string, SpeakerVoiceConfig> = {}\n  let changed = false\n\n  for (const [speaker, value] of Object.entries(source)) {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) {\n      return { ok: false }\n    }\n\n    const config = { ...(value as SpeakerVoiceConfig) }\n    if (config.voiceType === 'azure') {\n      if (hasPlayableAudioUrl(config.audioUrl)) {\n        config.voiceType = 'custom'\n        config.voiceId = null\n        next[speaker] = config\n      } else {\n        // No usable audio, drop stale azure speaker config.\n      }\n      changed = true\n      continue\n    }\n\n    next[speaker] = config\n  }\n\n  const keys = Object.keys(next)\n  if (keys.length === 0) {\n    return {\n      ok: true,\n      changed,\n      cleared: true,\n      next: null,\n    }\n  }\n\n  return {\n    ok: true,\n    changed,\n    cleared: false,\n    next: changed ? JSON.stringify(next) : payload,\n  }\n}\n\nasync function main() {\n  const summary: CleanupSummary = {\n    projectCharactersUpdated: 0,\n    globalCharactersUpdated: 0,\n    episodeSpeakerVoicesUpdated: 0,\n    episodeSpeakerVoicesCleared: 0,\n    invalidSpeakerVoicesSkipped: 0,\n  }\n\n  const [projectCharacters, globalCharacters] = await Promise.all([\n    prisma.novelPromotionCharacter.findMany({\n      where: { voiceType: 'azure' },\n      select: {\n        id: true,\n        customVoiceUrl: true,\n      },\n    }),\n    prisma.globalCharacter.findMany({\n      where: { voiceType: 'azure' },\n      select: {\n        id: true,\n        customVoiceUrl: true,\n      },\n    }),\n  ])\n\n  summary.projectCharactersUpdated = await cleanupCharacterTable(projectCharacters, 'project')\n  summary.globalCharactersUpdated = await cleanupCharacterTable(globalCharacters, 'global')\n\n  const episodes = await prisma.novelPromotionEpisode.findMany({\n    where: {\n      speakerVoices: { not: null },\n    },\n    select: {\n      id: true,\n      speakerVoices: true,\n    },\n  })\n\n  for (const row of episodes) {\n    const speakerVoices = row.speakerVoices\n    if (!speakerVoices || !speakerVoices.includes('\"voiceType\":\"azure\"')) {\n      continue\n    }\n    const normalized = normalizeSpeakerVoices(speakerVoices)\n    if (!normalized.ok) {\n      summary.invalidSpeakerVoicesSkipped += 1\n      continue\n    }\n    if (!normalized.changed) {\n      continue\n    }\n    await prisma.novelPromotionEpisode.update({\n      where: { id: row.id },\n      data: {\n        speakerVoices: normalized.next,\n      },\n    })\n    summary.episodeSpeakerVoicesUpdated += 1\n    if (normalized.cleared) {\n      summary.episodeSpeakerVoicesCleared += 1\n    }\n  }\n\n  process.stdout.write(`${JSON.stringify({\n    ok: true,\n    checkedAt: new Date().toISOString(),\n    summary,\n  }, null, 2)}\\n`)\n}\n\nmain()\n  .catch((error) => {\n    process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\\n`)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/diagnose-project.ts",
    "content": "/**\n * 诊断项目任务状态\n * 运行: npx tsx scripts/diagnose-project.ts <projectId>\n */\nimport { config } from 'dotenv'\nconfig()\n\nimport { prisma } from '../src/lib/prisma'\n\nasync function diagnoseProject(projectId: string) {\n  console.log(`🔍 诊断项目: ${projectId}\\n`)\n\n  // 1. 检查项目是否存在\n  console.log('1️⃣ 项目基本信息:')\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    include: {\n      novelPromotionData: true\n    }\n  })\n  \n  if (!project) {\n    console.log('  ❌ 项目不存在')\n    process.exit(1)\n  }\n  \n  console.log(`  名称: ${project.name}`)\n  console.log(`  模式: ${project.mode}`)\n  console.log(`  用户ID: ${project.userId}`)\n\n  // 2. 检查 NovelPromotionProject\n  console.log('\\n2️⃣ 小说推广项目配置:')\n  const novelData = project.novelPromotionData\n  if (!novelData) {\n    console.log('  ❌ novelPromotionData 未创建')\n  } else {\n    console.log(`  ID: ${novelData.id}`)\n    console.log(`  视频比例: ${novelData.videoRatio || '未设置'}`)\n    console.log(`  画风提示: ${novelData.artStylePrompt || '未设置'}`)\n  }\n\n  // 3. 检查场景和场景图片\n  console.log('\\n3️⃣ 场景资产:')\n  const novelProjectId = novelData?.id\n  if (!novelProjectId) {\n    console.log('  ❌ 无法获取 novelPromotionProject ID')\n    process.exit(1)\n  }\n  \n  const locations = await prisma.novelPromotionLocation.findMany({\n    where: { novelPromotionProjectId: novelProjectId },\n    include: {\n      images: {\n        orderBy: { imageIndex: 'asc' }\n      }\n    }\n  })\n  \n  console.log(`  场景数量: ${locations.length}`)\n  \n  for (const loc of locations) {\n    console.log(`\\n  📍 ${loc.name} (${loc.id})`)\n    console.log(`     图片数量: ${loc.images?.length || 0}`)\n    \n    for (const img of loc.images || []) {\n      console.log(`     - [${img.imageIndex}] imageUrl: ${img.imageUrl || 'null'}`)\n      console.log(`       isSelected: ${img.isSelected}`)\n      console.log(`       description: ${img.description || 'null'}`)\n\n      // 检查 MediaObject\n      if (img.imageUrl) {\n        const media = await prisma.mediaObject.findFirst({\n          where: { \n            OR: [\n              { storageKey: img.imageUrl },\n              { storageKey: { contains: img.imageUrl.split('/').pop() || '' } }\n            ]\n          }\n        })\n        if (media) {\n          console.log(`       ✅ MediaObject: ${media.publicId}`)\n        } else {\n          console.log(`       ⚠️ 未找到 MediaObject`)\n        }\n      }\n    }\n  }\n\n  // 4. 检查最近的任务\n  console.log('\\n4️⃣ 最近的任务:')\n  const tasks = await prisma.task.findMany({\n    where: { projectId },\n    orderBy: { createdAt: 'desc' },\n    take: 10\n  })\n  \n  console.log(`  任务数量: ${tasks.length}`)\n  \n  for (const task of tasks) {\n    console.log(`\\n  📝 ${task.type} (${task.id})`)\n    console.log(`     状态: ${task.status}`)\n    console.log(`     目标: ${task.targetType} / ${task.targetId}`)\n    console.log(`     创建时间: ${task.createdAt}`)\n    console.log(`     更新时间: ${task.updatedAt}`)\n\n    if (task.errorMessage || task.errorCode) {\n      console.log(`     ❌ 错误码: ${task.errorCode || 'N/A'}`)\n      console.log(`     ❌ 错误信息: ${task.errorMessage?.substring(0, 200) || 'N/A'}`)\n    }\n\n    // 获取任务事件\n    const events = await prisma.taskEvent.findMany({\n      where: { taskId: task.id },\n      orderBy: { createdAt: 'desc' },\n      take: 3\n    })\n    \n    if (events.length > 0) {\n      console.log(`     最近事件:`)\n      for (const event of events) {\n        console.log(`       - ${event.eventType}: ${JSON.stringify(event.payload).substring(0, 100)}`)\n      }\n    }\n  }\n\n  // 5. 检查 Worker 队列状态\n  console.log('\\n5️⃣ 检查 Worker 配置:')\n  console.log(`  REDIS_HOST: ${process.env.REDIS_HOST || '未设置'}`)\n  console.log(`  REDIS_PORT: ${process.env.REDIS_PORT || '未设置'}`)\n  \n  // 尝试连接 Redis\n  try {\n    const { Redis } = await import('ioredis')\n    const redis = new Redis({\n      host: process.env.REDIS_HOST || 'localhost',\n      port: parseInt(process.env.REDIS_PORT || '6379'),\n      maxRetriesPerRequest: 3,\n      connectTimeout: 5000\n    })\n    \n    const pingResult = await redis.ping()\n    console.log(`  ✅ Redis 连接: ${pingResult}`)\n    \n    // 检查 BullMQ 队列\n    const queueKeys = await redis.keys('bull:*:id')\n    console.log(`  BullMQ 队列数量: ${queueKeys.length}`)\n    \n    for (const key of queueKeys.slice(0, 5)) {\n      const queueName = key.replace('bull:', '').replace(':id', '')\n      const jobCounts = await redis.hgetall(`bull:${queueName}:id`)\n      console.log(`    - ${queueName}`)\n    }\n    \n    redis.disconnect()\n  } catch (error) {\n    console.log(`  ❌ Redis 连接失败:`, error)\n  }\n\n  // 6. 检查模型配置\n  console.log('\\n6️⃣ 检查用户模型配置:')\n  const userPreference = await prisma.userPreference.findUnique({\n    where: { userId: project.userId }\n  })\n\n  if (!userPreference) {\n    console.log('  ❌ 用户偏好配置不存在')\n  } else {\n    console.log(`  角色模型: ${userPreference.characterModel || '未设置'}`)\n    console.log(`  场景模型: ${userPreference.locationModel || '未设置'}`)\n    console.log(`  视频模型: ${userPreference.videoModel || '未设置'}`)\n    console.log(`  编辑模型: ${userPreference.editModel || '未设置'}`)\n    console.log(`  口型同步模型: ${userPreference.lipSyncModel || '未设置'}`)\n    console.log(`  分析模型: ${userPreference.analysisModel || '未设置'}`)\n  }\n\n  console.log('\\n✨ 诊断完成!')\n  \n  await prisma.$disconnect()\n}\n\nconst projectId = process.argv[2]\nif (!projectId) {\n  console.log('用法: npx tsx scripts/diagnose-project.ts <projectId>')\n  console.log('示例: npx tsx scripts/diagnose-project.ts fae709e9-9215-4b3f-9f53-dad871f09896')\n  process.exit(1)\n}\n\ndiagnoseProject(projectId).catch(console.error)\n"
  },
  {
    "path": "scripts/guards/api-route-contract-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\nimport { pathToFileURL } from 'url'\n\nconst root = process.cwd()\nconst apiDir = path.join(root, 'src', 'app', 'api')\n\nexport const API_HANDLER_ALLOWLIST = new Set([\n  'src/app/api/auth/[...nextauth]/route.ts',\n  'src/app/api/files/[...path]/route.ts',\n  'src/app/api/system/boot-id/route.ts',\n])\n\nexport const PUBLIC_ROUTE_ALLOWLIST = new Set([\n  'src/app/api/auth/[...nextauth]/route.ts',\n  'src/app/api/auth/register/route.ts',\n  'src/app/api/cos/image/route.ts',\n  'src/app/api/files/[...path]/route.ts',\n  'src/app/api/storage/sign/route.ts',\n  'src/app/api/system/boot-id/route.ts',\n])\n\nconst AUTH_CALL_PATTERNS = [\n  /\\brequireUserAuth\\s*\\(/,\n  /\\brequireProjectAuth\\s*\\(/,\n  /\\brequireProjectAuthLight\\s*\\(/,\n]\n\nfunction fail(title, details = []) {\n  process.stderr.write(`\\n[api-route-contract-guard] ${title}\\n`)\n  for (const detail of details) {\n    process.stderr.write(`  - ${detail}\\n`)\n  }\n  process.exit(1)\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (entry.name === 'route.ts') out.push(fullPath)\n  }\n  return out\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction hasApiHandlerWrapper(content) {\n  return /\\bapiHandler\\s*\\(/.test(content)\n}\n\nfunction hasRequiredAuth(content) {\n  return AUTH_CALL_PATTERNS.some((pattern) => pattern.test(content))\n}\n\nexport function inspectRouteContract(relPath, content) {\n  const violations = []\n\n  if (!API_HANDLER_ALLOWLIST.has(relPath) && !hasApiHandlerWrapper(content)) {\n    violations.push(`${relPath} missing apiHandler wrapper`)\n  }\n\n  if (!PUBLIC_ROUTE_ALLOWLIST.has(relPath) && !hasRequiredAuth(content)) {\n    violations.push(`${relPath} missing requireUserAuth/requireProjectAuth/requireProjectAuthLight`)\n  }\n\n  return violations\n}\n\nexport function findApiRouteContractViolations(scanRoot = root) {\n  const routesRoot = path.join(scanRoot, 'src', 'app', 'api')\n  return walk(routesRoot)\n    .map((fullPath) => {\n      const relPath = path.relative(scanRoot, fullPath).split(path.sep).join('/')\n      const content = fs.readFileSync(fullPath, 'utf8')\n      return inspectRouteContract(relPath, content)\n    })\n    .flat()\n}\n\nexport function main() {\n  if (!fs.existsSync(apiDir)) {\n    fail('Missing src/app/api directory')\n  }\n\n  const violations = walk(apiDir)\n    .map((fullPath) => {\n      const relPath = toRel(fullPath)\n      const content = fs.readFileSync(fullPath, 'utf8')\n      return inspectRouteContract(relPath, content)\n    })\n    .flat()\n\n  if (violations.length > 0) {\n    fail('Found API route contract violations', violations)\n  }\n\n  process.stdout.write(\n    `[api-route-contract-guard] OK routes=${walk(apiDir).length} public=${PUBLIC_ROUTE_ALLOWLIST.size} apiHandlerExceptions=${API_HANDLER_ALLOWLIST.size}\\n`,\n  )\n}\n\nif (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {\n  main()\n}\n"
  },
  {
    "path": "scripts/guards/changed-file-test-impact-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport { execSync } from 'node:child_process'\nimport { pathToFileURL } from 'node:url'\n\nconst RULES = [\n  {\n    name: 'api',\n    source: /^src\\/app\\/api\\//,\n    tests: [/^tests\\/integration\\/api\\/contract\\//, /^tests\\/system\\//, /^tests\\/regression\\//],\n    message: 'changing src/app/api/** requires a matching contract, system, or regression test change',\n  },\n  {\n    name: 'worker',\n    source: /^src\\/lib\\/workers\\//,\n    tests: [/^tests\\/unit\\/worker\\//, /^tests\\/system\\//, /^tests\\/regression\\//],\n    message: 'changing src/lib/workers/** requires a matching worker, system, or regression test change',\n  },\n  {\n    name: 'task',\n    source: /^src\\/lib\\/task\\//,\n    tests: [/^tests\\/unit\\/task\\//, /^tests\\/system\\//, /^tests\\/regression\\//],\n    message: 'changing src/lib/task/** requires a matching task, system, or regression test change',\n  },\n  {\n    name: 'media',\n    source: /^src\\/lib\\/media\\//,\n    tests: [/^tests\\/unit\\//, /^tests\\/system\\//, /^tests\\/regression\\//],\n    message: 'changing src/lib/media/** requires a matching unit, system, or regression test change',\n  },\n  {\n    name: 'provider',\n    source: /^src\\/lib\\/(generator-api|generators|model-gateway|lipsync|providers)\\//,\n    tests: [/^tests\\/unit\\/(providers|model-gateway|llm)\\//, /^tests\\/integration\\/provider\\//, /^tests\\/system\\//, /^tests\\/regression\\//],\n    message: 'changing provider/gateway code requires provider contract, system, or regression test change',\n  },\n]\n\nfunction normalizeChangedFiles(rawFiles) {\n  return rawFiles\n    .flatMap((item) => item.split(/[\\n,]/))\n    .map((item) => item.trim())\n    .filter(Boolean)\n}\n\nfunction readGitChangedFiles() {\n  try {\n    const output = execSync('git diff --name-only --cached', {\n      cwd: process.cwd(),\n      encoding: 'utf8',\n      stdio: ['ignore', 'pipe', 'ignore'],\n    })\n    return normalizeChangedFiles([output])\n  } catch {\n    return []\n  }\n}\n\nexport function inspectChangedFiles(changedFiles) {\n  const changed = normalizeChangedFiles(changedFiles)\n  const changedTests = changed.filter((file) => file.startsWith('tests/'))\n  const violations = []\n\n  for (const rule of RULES) {\n    const impactedSources = changed.filter((file) => rule.source.test(file))\n    if (impactedSources.length === 0) continue\n    const hasMatchingTestChange = changedTests.some((file) => rule.tests.some((pattern) => pattern.test(file)))\n    if (!hasMatchingTestChange) {\n      violations.push(`${rule.name}: ${rule.message}; sources=${impactedSources.join(',')}`)\n    }\n  }\n\n  return violations\n}\n\nfunction fail(violations) {\n  console.error('\\n[changed-file-test-impact-guard] Missing matching test changes')\n  for (const violation of violations) {\n    console.error(`  - ${violation}`)\n  }\n  process.exit(1)\n}\n\nfunction runCli() {\n  const inputFiles = process.argv.slice(2)\n  const changedFiles = inputFiles.length > 0\n    ? normalizeChangedFiles(inputFiles)\n    : normalizeChangedFiles([process.env.TEST_IMPACT_CHANGED_FILES || '', ...readGitChangedFiles()])\n\n  if (changedFiles.length === 0) {\n    console.log('[changed-file-test-impact-guard] SKIP no changed files detected')\n    process.exit(0)\n  }\n\n  const violations = inspectChangedFiles(changedFiles)\n  if (violations.length > 0) {\n    fail(violations)\n  }\n\n  console.log(`[changed-file-test-impact-guard] OK files=${changedFiles.length}`)\n}\n\nconst entryHref = process.argv[1] ? pathToFileURL(process.argv[1]).href : null\nif (entryHref && import.meta.url === entryHref) {\n  runCli()\n}\n"
  },
  {
    "path": "scripts/guards/file-line-count-guard.mjs",
    "content": "#!/usr/bin/env node\nimport fs from 'fs'\nimport path from 'path'\n\nconst ROOT = process.cwd()\n\nconst RULES = [\n  {\n    label: 'component',\n    dir: 'src',\n    include: (relPath) =>\n      relPath.includes('/components/')\n      && /\\.(ts|tsx)$/.test(relPath),\n    limit: 500,\n  },\n  {\n    label: 'hook',\n    dir: 'src',\n    include: (relPath) =>\n      (relPath.includes('/hooks/') || /\\/use[A-Z].+\\.(ts|tsx)$/.test(relPath))\n      && /\\.(ts|tsx)$/.test(relPath),\n    limit: 400,\n  },\n  {\n    label: 'worker-handler',\n    dir: 'src/lib/workers/handlers',\n    include: (relPath) => /\\.(ts|tsx)$/.test(relPath),\n    limit: 300,\n  },\n  {\n    label: 'mutation',\n    dir: 'src/lib/query/mutations',\n    include: (relPath) => /\\.(ts|tsx)$/.test(relPath) && !relPath.endsWith('/index.ts'),\n    limit: 300,\n  },\n]\n\nconst walkFiles = (absDir, relBase = '') => {\n  if (!fs.existsSync(absDir)) return []\n  const entries = fs.readdirSync(absDir, { withFileTypes: true })\n  const out = []\n  for (const entry of entries) {\n    const abs = path.join(absDir, entry.name)\n    const rel = path.join(relBase, entry.name).replace(/\\\\/g, '/')\n    if (entry.isDirectory()) {\n      out.push(...walkFiles(abs, rel))\n      continue\n    }\n    out.push({ absPath: abs, relPath: rel })\n  }\n  return out\n}\n\nconst countLines = (absPath) => {\n  const raw = fs.readFileSync(absPath, 'utf8')\n  if (raw.length === 0) return 0\n  return raw.split('\\n').length\n}\n\nconst violations = []\n\nfor (const rule of RULES) {\n  const absDir = path.join(ROOT, rule.dir)\n  const files = walkFiles(absDir, rule.dir).filter((f) => rule.include(f.relPath))\n  for (const file of files) {\n    const lineCount = countLines(file.absPath)\n    if (lineCount > rule.limit) {\n      violations.push({\n        label: rule.label,\n        relPath: file.relPath,\n        lineCount,\n        limit: rule.limit,\n      })\n    }\n  }\n}\n\nif (violations.length === 0) {\n  process.stdout.write('[file-line-count-guard] PASS\\n')\n  process.exit(0)\n}\n\nprocess.stderr.write('[file-line-count-guard] FAIL: file size budget exceeded\\n')\nfor (const violation of violations) {\n  process.stderr.write(\n    `- [${violation.label}] ${violation.relPath}: ${violation.lineCount} > ${violation.limit}\\n`,\n  )\n}\nprocess.exit(1)\n"
  },
  {
    "path": "scripts/guards/image-reference-normalization-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\nimport { pathToFileURL } from 'url'\n\nconst root = process.cwd()\nconst handlersDir = path.join(root, 'src', 'lib', 'workers', 'handlers')\n\nexport const NORMALIZATION_HELPER_ALLOWLIST = new Set([\n  'src/lib/workers/handlers/image-task-handler-shared.ts',\n])\n\nconst ACCEPTED_NORMALIZATION_MARKERS = [\n  /\\bnormalizeReferenceImagesForGeneration\\s*\\(/,\n  /\\bnormalizeToBase64ForGeneration\\s*\\(/,\n  /\\bgenerateLabeledImageToCos\\s*\\(/,\n]\n\nfunction fail(title, details = []) {\n  process.stderr.write(`\\n[image-reference-normalization-guard] ${title}\\n`)\n  for (const detail of details) {\n    process.stderr.write(`  - ${detail}\\n`)\n  }\n  process.exit(1)\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (entry.name.endsWith('.ts')) out.push(fullPath)\n  }\n  return out\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction usesGenerationReferenceImages(content) {\n  return /\\bresolveImageSourceFromGeneration\\s*\\(/.test(content) && /\\breferenceImages\\s*:/.test(content)\n}\n\nfunction hasNormalizationMarker(content) {\n  return ACCEPTED_NORMALIZATION_MARKERS.some((pattern) => pattern.test(content))\n}\n\nexport function inspectImageReferenceNormalization(relPath, content) {\n  if (NORMALIZATION_HELPER_ALLOWLIST.has(relPath)) return []\n  if (!usesGenerationReferenceImages(content)) return []\n  if (hasNormalizationMarker(content)) return []\n  return [\n    `${relPath} uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateLabeledImageToCos`,\n  ]\n}\n\nexport function findImageReferenceNormalizationViolations(scanRoot = root) {\n  const scanDir = path.join(scanRoot, 'src', 'lib', 'workers', 'handlers')\n  return walk(scanDir)\n    .map((fullPath) => {\n      const relPath = path.relative(scanRoot, fullPath).split(path.sep).join('/')\n      const content = fs.readFileSync(fullPath, 'utf8')\n      return inspectImageReferenceNormalization(relPath, content)\n    })\n    .flat()\n}\n\nexport function main() {\n  if (!fs.existsSync(handlersDir)) {\n    fail('Missing src/lib/workers/handlers directory')\n  }\n\n  const handlerFiles = walk(handlersDir)\n  const violations = handlerFiles\n    .map((fullPath) => {\n      const relPath = toRel(fullPath)\n      const content = fs.readFileSync(fullPath, 'utf8')\n      return inspectImageReferenceNormalization(relPath, content)\n    })\n    .flat()\n\n  if (violations.length > 0) {\n    fail('Found image reference normalization violations', violations)\n  }\n\n  process.stdout.write(\n    `[image-reference-normalization-guard] OK handlers=${handlerFiles.length} allowlist=${NORMALIZATION_HELPER_ALLOWLIST.size}\\n`,\n  )\n}\n\nif (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {\n  main()\n}\n"
  },
  {
    "path": "scripts/guards/locale-navigation-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\n\nconst scanDirectories = [\n  'src/app/[locale]',\n]\n\nconst extraFiles = [\n  'src/components/Navbar.tsx',\n  'src/components/LanguageSwitcher.tsx',\n]\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (sourceExtensions.has(path.extname(fullPath))) {\n      out.push(fullPath)\n    }\n  }\n  return out\n}\n\nfunction gatherTargetFiles() {\n  const files = scanDirectories.flatMap((dir) => walk(path.join(root, dir)))\n  for (const relPath of extraFiles) {\n    const fullPath = path.join(root, relPath)\n    if (fs.existsSync(fullPath)) {\n      files.push(fullPath)\n    }\n  }\n  return Array.from(new Set(files))\n}\n\nfunction findViolations(content, relPath) {\n  const violations = []\n  const lines = content.split('\\n')\n\n  const nextLinkImport = /from\\s+['\"]next\\/link['\"]/\n  const nextNavigationUseRouterImport = /import\\s*{[\\s\\S]*?\\buseRouter\\b[\\s\\S]*?}\\s*from\\s*['\"]next\\/navigation['\"]/m\n  const rootHrefLiteral = /\\bhref\\s*=\\s*[\"']\\//\n  const rootHrefTemplate = /\\bhref\\s*=\\s*{`\\//\n  const rootRouterCall = /\\brouter\\.(push|replace|prefetch)\\s*\\(\\s*[\"'`]\\//\n\n  const nextLinkIndex = content.search(nextLinkImport)\n  if (nextLinkIndex >= 0) {\n    const lineNo = content.slice(0, nextLinkIndex).split('\\n').length\n    violations.push(`${relPath}:${lineNo} do not import next/link in locale navigation surface; use @/i18n/navigation Link`)\n  }\n\n  const nextNavigationRouterIndex = content.search(nextNavigationUseRouterImport)\n  if (nextNavigationRouterIndex >= 0) {\n    const lineNo = content.slice(0, nextNavigationRouterIndex).split('\\n').length\n    violations.push(`${relPath}:${lineNo} do not import useRouter from next/navigation in locale navigation surface; use @/i18n/navigation useRouter`)\n  }\n\n  for (let index = 0; index < lines.length; index += 1) {\n    const line = lines[index]\n    const lineNo = index + 1\n    if (rootHrefLiteral.test(line) || rootHrefTemplate.test(line)) {\n      violations.push(`${relPath}:${lineNo} do not use root-literal href; use Link href={{ pathname: '...' }} via @/i18n/navigation`)\n    }\n    if (rootRouterCall.test(line)) {\n      violations.push(`${relPath}:${lineNo} do not use root-literal router navigation; use router.push/replace({ pathname: '...' }) via @/i18n/navigation`)\n    }\n  }\n\n  return violations\n}\n\nconst violations = []\nfor (const filePath of gatherTargetFiles()) {\n  const content = fs.readFileSync(filePath, 'utf8')\n  violations.push(...findViolations(content, toRel(filePath)))\n}\n\nif (violations.length > 0) {\n  console.error('\\n[locale-navigation-guard] violations found:')\n  for (const violation of violations) {\n    console.error(`  - ${violation}`)\n  }\n  process.exit(1)\n}\n\nconsole.log('[locale-navigation-guard] OK')\n"
  },
  {
    "path": "scripts/guards/no-api-direct-llm-call.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst scanRoots = ['src/app/api', 'src/pages/api']\nconst allowedPrefixes = []\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\n\nfunction fail(title, details = []) {\n  console.error(`\\n[no-api-direct-llm-call] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    const ext = path.extname(entry.name)\n    if (sourceExtensions.has(ext)) {\n      out.push(fullPath)\n    }\n  }\n  return out\n}\n\nfunction isAllowedFile(relPath) {\n  return allowedPrefixes.some((prefix) => relPath.startsWith(prefix))\n}\n\nfunction collectViolations(fullPath) {\n  const relPath = toRel(fullPath)\n  if (isAllowedFile(relPath)) return []\n\n  const content = fs.readFileSync(fullPath, 'utf8')\n  const lines = content.split('\\n')\n  const violations = []\n\n  for (let i = 0; i < lines.length; i += 1) {\n    const line = lines[i]\n    if (/from\\s+['\"]@\\/lib\\/llm-client['\"]/.test(line)) {\n      violations.push(`${relPath}:${i + 1} forbidden import from '@/lib/llm-client'`)\n    }\n    if (/\\bchatCompletion[A-Za-z0-9_]*\\s*\\(/.test(line)) {\n      violations.push(`${relPath}:${i + 1} forbidden direct chatCompletion* call`)\n    }\n    if (/\\bisInternalTaskExecution\\b/.test(line)) {\n      violations.push(`${relPath}:${i + 1} forbidden dual-track fallback marker isInternalTaskExecution`)\n    }\n  }\n\n  return violations\n}\n\nconst allFiles = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))\nconst violations = allFiles.flatMap((fullPath) => collectViolations(fullPath))\n\nif (violations.length > 0) {\n  fail('Found forbidden direct LLM execution in production API routes', violations)\n}\n\nconsole.log('[no-api-direct-llm-call] OK')\n"
  },
  {
    "path": "scripts/guards/no-duplicate-endpoint-entry.mjs",
    "content": "#!/usr/bin/env node\nimport fs from 'fs'\nimport path from 'path'\n\nconst ROOT = process.cwd()\nconst API_ROOT = path.join(ROOT, 'src', 'app', 'api')\n\nconst KNOWN_DUPLICATE_GROUPS = [\n  {\n    key: 'user-llm-test-connection',\n    candidates: [\n      'src/app/api/user/api-config/test-connection/route.ts',\n      'src/app/api/user/test-llm-provider/route.ts',\n    ],\n  },\n]\n\nconst exists = (relPath) => fs.existsSync(path.join(ROOT, relPath))\n\nconst failures = []\nfor (const group of KNOWN_DUPLICATE_GROUPS) {\n  const present = group.candidates.filter(exists)\n  if (present.length > 1) {\n    failures.push({ key: group.key, present })\n  }\n}\n\nif (!fs.existsSync(API_ROOT)) {\n  process.stdout.write('[no-duplicate-endpoint-entry] PASS (api dir missing)\\n')\n  process.exit(0)\n}\n\nif (failures.length === 0) {\n  process.stdout.write('[no-duplicate-endpoint-entry] PASS\\n')\n  process.exit(0)\n}\n\nprocess.stderr.write('[no-duplicate-endpoint-entry] FAIL: duplicated endpoint entry detected\\n')\nfor (const failure of failures) {\n  process.stderr.write(`- ${failure.key}\\n`)\n  for (const relPath of failure.present) {\n    process.stderr.write(`  - ${relPath}\\n`)\n  }\n}\nprocess.exit(1)\n"
  },
  {
    "path": "scripts/guards/no-hardcoded-model-capabilities.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\nconst scanRoots = ['src']\nconst allowConstantDefinitionsIn = new Set([\n  'src/lib/constants.ts',\n])\nconst forbiddenCapabilityConstants = [\n  'VIDEO_MODELS',\n  'FIRST_LAST_FRAME_MODELS',\n  'AUDIO_SUPPORTED_MODELS',\n  'BANANA_MODELS',\n  'BANANA_RESOLUTION_OPTIONS',\n]\n\nfunction fail(title, details = []) {\n  console.error(`\\n[no-hardcoded-model-capabilities] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (sourceExtensions.has(path.extname(entry.name))) {\n      out.push(fullPath)\n    }\n  }\n  return out\n}\n\nconst files = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))\nconst violations = []\n\nfor (const fullPath of files) {\n  const relPath = toRel(fullPath)\n  if (allowConstantDefinitionsIn.has(relPath)) continue\n\n  const lines = fs.readFileSync(fullPath, 'utf8').split('\\n')\n  for (let index = 0; index < lines.length; index += 1) {\n    const line = lines[index]\n    for (const token of forbiddenCapabilityConstants) {\n      const tokenPattern = new RegExp(`\\\\b${token}\\\\b`)\n      if (tokenPattern.test(line)) {\n        violations.push(`${relPath}:${index + 1} forbidden hardcoded model capability token ${token}`)\n      }\n    }\n  }\n}\n\nif (violations.length > 0) {\n  fail('Found hardcoded model capability usage', violations)\n}\n\nconsole.log('[no-hardcoded-model-capabilities] OK')\n"
  },
  {
    "path": "scripts/guards/no-internal-task-sync-fallback.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst scanRoots = ['src/app/api', 'src/pages/api']\nconst allowedPrefixes = ['src/app/api/ui-review/', 'src/pages/api/ui-review/']\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\n\nfunction fail(title, details = []) {\n  console.error(`\\n[no-internal-task-sync-fallback] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (sourceExtensions.has(path.extname(entry.name))) {\n      out.push(fullPath)\n    }\n  }\n  return out\n}\n\nfunction isAllowedFile(relPath) {\n  return allowedPrefixes.some((prefix) => relPath.startsWith(prefix))\n}\n\nfunction collectViolations(fullPath) {\n  const relPath = toRel(fullPath)\n  if (isAllowedFile(relPath)) return []\n\n  const content = fs.readFileSync(fullPath, 'utf8')\n  const lines = content.split('\\n')\n  const violations = []\n\n  for (let i = 0; i < lines.length; i += 1) {\n    const line = lines[i]\n    if (/\\bisInternalTaskExecution\\b/.test(line)) {\n      violations.push(`${relPath}:${i + 1} forbidden dual-track fallback marker isInternalTaskExecution`)\n    }\n    if (/\\bshouldRunSyncTask\\s*\\(/.test(line)) {\n      violations.push(`${relPath}:${i + 1} forbidden sync-mode branch helper shouldRunSyncTask`)\n    }\n  }\n\n  if (/\\bmaybeSubmitLLMTask\\s*\\(/.test(content) && !/sync mode is disabled for this route/.test(content)) {\n    violations.push(`${relPath} missing explicit sync-disabled guard after maybeSubmitLLMTask`)\n  }\n\n  return violations\n}\n\nconst allFiles = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))\nconst violations = allFiles.flatMap((fullPath) => collectViolations(fullPath))\n\nif (violations.length > 0) {\n  fail('Found potential sync fallback or dual-track task branch in production API routes', violations)\n}\n\nconsole.log('[no-internal-task-sync-fallback] OK')\n"
  },
  {
    "path": "scripts/guards/no-media-provider-bypass.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\nconst allowFactoryImportIn = new Set([\n  'src/lib/generator-api.ts',\n  'src/lib/generators/factory.ts',\n])\n\nfunction fail(title, details = []) {\n  console.error(`\\n[no-media-provider-bypass] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (sourceExtensions.has(path.extname(entry.name))) {\n      out.push(fullPath)\n    }\n  }\n  return out\n}\n\nconst generatorApiPath = path.join(root, 'src/lib/generator-api.ts')\nif (!fs.existsSync(generatorApiPath)) {\n  fail('Missing src/lib/generator-api.ts')\n}\n\nconst generatorApiContent = fs.readFileSync(generatorApiPath, 'utf8')\nconst resolveModelSelectionHits = (generatorApiContent.match(/resolveModelSelection\\s*\\(/g) || []).length\nif (resolveModelSelectionHits < 2) {\n  fail('generator-api must route both image and video generation through resolveModelSelection', [\n    'expected >= 2 resolveModelSelection(...) calls in src/lib/generator-api.ts',\n  ])\n}\n\nconst allFiles = walk(path.join(root, 'src'))\nconst violations = []\n\nfor (const fullPath of allFiles) {\n  const relPath = toRel(fullPath)\n  const content = fs.readFileSync(fullPath, 'utf8')\n  const lines = content.split('\\n')\n\n  for (let i = 0; i < lines.length; i += 1) {\n    const line = lines[i]\n\n    if (\n      relPath !== 'src/lib/generators/factory.ts' &&\n      (/\\bcreateImageGeneratorByModel\\s*\\(/.test(line) || /\\bcreateVideoGeneratorByModel\\s*\\(/.test(line))\n    ) {\n      violations.push(`${relPath}:${i + 1} forbidden provider-bypass factory call create*GeneratorByModel(...)`)\n    }\n\n    if ((/\\bgetImageApiKey\\s*\\(/.test(line) || /\\bgetVideoApiKey\\s*\\(/.test(line)) && relPath !== 'src/lib/api-config.ts') {\n      violations.push(`${relPath}:${i + 1} forbidden direct getImageApiKey/getVideoApiKey usage outside api-config`)\n    }\n\n    if (/from\\s+['\"]@\\/lib\\/generators\\/factory['\"]/.test(line) && !allowFactoryImportIn.has(relPath)) {\n      violations.push(`${relPath}:${i + 1} forbidden direct import from '@/lib/generators/factory' (must go through generator-api)`)\n    }\n  }\n}\n\nif (violations.length > 0) {\n  fail('Found media provider routing bypass', violations)\n}\n\nconsole.log('[no-media-provider-bypass] OK')\n"
  },
  {
    "path": "scripts/guards/no-model-key-downgrade.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\nconst scanRoots = ['src/app', 'src/lib']\nconst modelFields = [\n  'analysisModel',\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n  'videoModel',\n]\n\nfunction fail(title, details = []) {\n  console.error(`\\n[no-model-key-downgrade] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (sourceExtensions.has(path.extname(entry.name))) {\n      out.push(fullPath)\n    }\n  }\n  return out\n}\n\nfunction collectViolations(filePath) {\n  const relPath = toRel(filePath)\n  const lines = fs.readFileSync(filePath, 'utf8').split('\\n')\n  const violations = []\n\n  const modelFieldPattern = new RegExp(`\\\\b(${modelFields.join('|')})\\\\s*:\\\\s*[^,\\\\n]*\\\\bmodelId\\\\b`)\n  const optionModelIdPattern = /value=\\{model\\.modelId\\}/\n\n  for (let index = 0; index < lines.length; index += 1) {\n    const line = lines[index]\n    if (modelFieldPattern.test(line)) {\n      violations.push(`${relPath}:${index + 1} default model field must persist model_key, not modelId`)\n    }\n    if (optionModelIdPattern.test(line)) {\n      violations.push(`${relPath}:${index + 1} UI option value must use modelKey, not model.modelId`)\n    }\n  }\n\n  return violations\n}\n\nfunction assertFileContains(relativePath, requiredSnippets) {\n  const fullPath = path.join(root, relativePath)\n  if (!fs.existsSync(fullPath)) {\n    fail('Missing required contract file', [relativePath])\n  }\n  const content = fs.readFileSync(fullPath, 'utf8')\n  const missing = requiredSnippets.filter((snippet) => !content.includes(snippet))\n  if (missing.length > 0) {\n    fail('Model key contract anchor missing', missing.map((snippet) => `${relativePath} missing: ${snippet}`))\n  }\n}\n\nconst files = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))\nconst violations = files.flatMap((filePath) => collectViolations(filePath))\n\nassertFileContains('src/lib/model-config-contract.ts', ['parseModelKeyStrict', 'markerIndex === -1) return null'])\nassertFileContains('src/lib/config-service.ts', ['parseModelKeyStrict'])\nassertFileContains('src/app/api/user/api-config/route.ts', ['validateDefaultModelKey', 'must be provider::modelId'])\nassertFileContains('src/app/api/novel-promotion/[projectId]/route.ts', ['must be provider::modelId'])\n\nif (violations.length > 0) {\n  fail('Found model key downgrade pattern', violations)\n}\n\nconsole.log('[no-model-key-downgrade] OK')\n"
  },
  {
    "path": "scripts/guards/no-multiple-sources-of-truth.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\n\nconst lineScanRoots = [\n  'src/app/[locale]/workspace/[projectId]/modes/novel-promotion',\n  'src/lib/query/hooks',\n]\n\nconst fileScanRoots = [\n  'src/app/api/novel-promotion',\n  'src/lib/workers/handlers',\n]\n\nconst lineRules = [\n  {\n    name: 'shadow state localStoryboards',\n    test: (line) => /const\\s*\\[\\s*localStoryboards\\s*,\\s*setLocalStoryboards\\s*\\]\\s*=\\s*useState/.test(line),\n  },\n  {\n    name: 'shadow state localVoiceLines',\n    test: (line) => /const\\s*\\[\\s*localVoiceLines\\s*,\\s*setLocalVoiceLines\\s*\\]\\s*=\\s*useState/.test(line),\n  },\n  {\n    name: 'hardcoded queryKey array',\n    test: (line) => /queryKey\\s*:\\s*\\[/.test(line),\n  },\n]\n\nfunction fail(title, details = []) {\n  console.error(`\\n[no-multiple-sources-of-truth] ${title}`)\n  for (const detail of details) {\n    console.error(`  - ${detail}`)\n  }\n  process.exit(1)\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (sourceExtensions.has(path.extname(entry.name))) out.push(fullPath)\n  }\n  return out\n}\n\nfunction collectLineViolations(fullPath) {\n  const relPath = toRel(fullPath)\n  const content = fs.readFileSync(fullPath, 'utf8')\n  const lines = content.split('\\n')\n  const violations = []\n\n  for (let i = 0; i < lines.length; i += 1) {\n    const line = lines[i]\n    for (const rule of lineRules) {\n      if (rule.test(line)) {\n        violations.push(`${relPath}:${i + 1} forbidden: ${rule.name}`)\n      }\n    }\n  }\n\n  return violations\n}\n\nfunction collectFileViolations(fullPath) {\n  const relPath = toRel(fullPath)\n  const content = fs.readFileSync(fullPath, 'utf8')\n  const violations = []\n\n  const updateCallRegex = /novelPromotionProject\\.update\\(\\{[\\s\\S]*?\\n\\s*\\}\\)/g\n  for (const match of content.matchAll(updateCallRegex)) {\n    const block = match[0]\n    const hasStageWrite = /\\bdata\\s*:\\s*\\{[\\s\\S]*?\\bstage\\s*:/.test(block)\n    if (!hasStageWrite) continue\n    const before = content.slice(0, match.index ?? 0)\n    const lineNumber = before.split('\\n').length\n    violations.push(`${relPath}:${lineNumber} forbidden: DB stage write in novelPromotionProject.update`)\n  }\n\n  return violations\n}\n\nconst lineFiles = lineScanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))\nconst fileFiles = fileScanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))\n\nconst lineViolations = lineFiles.flatMap((fullPath) => collectLineViolations(fullPath))\nconst fileViolations = fileFiles.flatMap((fullPath) => collectFileViolations(fullPath))\nconst allViolations = [...lineViolations, ...fileViolations]\n\nif (allViolations.length > 0) {\n  fail('Found multiple-sources-of-truth regressions', allViolations)\n}\n\nconsole.log('[no-multiple-sources-of-truth] OK')\n"
  },
  {
    "path": "scripts/guards/no-provider-guessing.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\nconst scanRoots = ['src/lib', 'src/app/api']\nconst allowModelRegistryUsage = new Set()\n\nfunction fail(title, details = []) {\n  console.error(`\\n[no-provider-guessing] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (sourceExtensions.has(path.extname(entry.name))) {\n      out.push(fullPath)\n    }\n  }\n  return out\n}\n\nconst apiConfigPath = path.join(root, 'src/lib/api-config.ts')\nif (!fs.existsSync(apiConfigPath)) {\n  fail('Missing src/lib/api-config.ts')\n}\nconst legacyRegistryPath = path.join(root, 'src/lib/model-registry.ts')\nif (fs.existsSync(legacyRegistryPath)) {\n  fail('Legacy runtime registry must be removed', ['src/lib/model-registry.ts'])\n}\nconst apiConfigText = fs.readFileSync(apiConfigPath, 'utf8')\n\nconst forbiddenApiConfigTokens = [\n  'includeAnyType',\n  'crossTypeCandidates',\n  'matches multiple providers across media types',\n]\nconst apiViolations = forbiddenApiConfigTokens\n  .filter((token) => apiConfigText.includes(token))\n  .map((token) => `src/lib/api-config.ts contains forbidden provider-guessing token: ${token}`)\n\n// 验证 api-config.ts 使用严格 provider.id 精确匹配（不按 type 过滤，不做 providerKey 模糊匹配）\nif (!apiConfigText.includes('pickProviderStrict(')) {\n  apiViolations.push('src/lib/api-config.ts missing strict provider resolution function (pickProviderStrict)')\n}\n\nconst files = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))\nconst violations = [...apiViolations]\n\nfor (const fullPath of files) {\n  const relPath = toRel(fullPath)\n  const content = fs.readFileSync(fullPath, 'utf8')\n  const lines = content.split('\\n')\n\n  for (let index = 0; index < lines.length; index += 1) {\n    const line = lines[index]\n    if (\n      /from\\s+['\"]@\\/lib\\/model-registry['\"]/.test(line)\n      && !allowModelRegistryUsage.has(relPath)\n    ) {\n      violations.push(`${relPath}:${index + 1} forbidden model-registry import outside allowed boundary`)\n    }\n\n    if (\n      (/\\bgetModelRegistryEntry\\s*\\(/.test(line) || /\\blistRegisteredModels\\s*\\(/.test(line))\n      && !allowModelRegistryUsage.has(relPath)\n    ) {\n      violations.push(`${relPath}:${index + 1} forbidden model-registry runtime mapping usage`)\n    }\n  }\n}\n\nif (violations.length > 0) {\n  fail('Found provider guessing / registry mapping violation', violations)\n}\n\nconsole.log('[no-provider-guessing] OK')\n"
  },
  {
    "path": "scripts/guards/no-server-mirror-state.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst scanRoots = [\n  'src/app/[locale]/workspace/[projectId]/modes/novel-promotion',\n]\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\n\nconst forbiddenRules = [\n  {\n    name: 'localProject/localEpisode mirror state',\n    test: (line) => /\\blocalProject\\b|\\blocalEpisode\\b/.test(line),\n  },\n  {\n    name: 'server mirror useState(projectData.*)',\n    test: (line) => /useState\\s*\\(\\s*projectData\\./.test(line),\n  },\n  {\n    name: 'server mirror useState(episode?.*)',\n    test: (line) => /useState\\s*\\(\\s*episode\\?\\./.test(line),\n  },\n]\n\nfunction fail(title, details = []) {\n  console.error(`\\n[no-server-mirror-state] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    const ext = path.extname(entry.name)\n    if (sourceExtensions.has(ext)) out.push(fullPath)\n  }\n  return out\n}\n\nfunction collectViolations(fullPath) {\n  const relPath = toRel(fullPath)\n  const content = fs.readFileSync(fullPath, 'utf8')\n  const lines = content.split('\\n')\n  const violations = []\n\n  for (let i = 0; i < lines.length; i += 1) {\n    const line = lines[i]\n    for (const rule of forbiddenRules) {\n      if (rule.test(line)) {\n        violations.push(`${relPath}:${i + 1} forbidden: ${rule.name}`)\n      }\n    }\n  }\n\n  return violations\n}\n\nconst allFiles = scanRoots.flatMap((scanRoot) => walk(path.join(root, scanRoot)))\nconst violations = allFiles.flatMap((fullPath) => collectViolations(fullPath))\n\nif (violations.length > 0) {\n  fail('Found forbidden server mirror state patterns', violations)\n}\n\nconsole.log('[no-server-mirror-state] OK')\n"
  },
  {
    "path": "scripts/guards/prompt-ab-regression.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst catalogPath = path.join(root, 'src', 'lib', 'prompt-i18n', 'catalog.ts')\nconst singlePlaceholderPattern = /\\{([A-Za-z0-9_]+)\\}/g\nconst doublePlaceholderPattern = /\\{\\{([A-Za-z0-9_]+)\\}\\}/g\nconst unresolvedPlaceholderPattern = /\\{\\{?[A-Za-z0-9_]+\\}?\\}/g\n\nfunction fail(title, details = []) {\n  console.error(`\\n[prompt-ab-regression] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction parseCatalog(text) {\n  const entries = []\n  const entryPattern = /pathStem:\\s*'([^']+)'\\s*,[\\s\\S]*?variableKeys:\\s*\\[([\\s\\S]*?)\\]\\s*,/g\n  for (const match of text.matchAll(entryPattern)) {\n    const pathStem = match[1]\n    const rawKeys = match[2] || ''\n    const keys = Array.from(rawKeys.matchAll(/'([^']+)'/g)).map((item) => item[1])\n    entries.push({ pathStem, variableKeys: keys })\n  }\n  return entries\n}\n\nfunction extractPlaceholders(template) {\n  const keys = new Set()\n  for (const match of template.matchAll(singlePlaceholderPattern)) {\n    if (match[1]) keys.add(match[1])\n  }\n  for (const match of template.matchAll(doublePlaceholderPattern)) {\n    if (match[1]) keys.add(match[1])\n  }\n  return Array.from(keys)\n}\n\nfunction replaceAll(template, variables) {\n  let rendered = template\n  for (const [key, value] of Object.entries(variables)) {\n    const pattern = new RegExp(`\\\\{\\\\{${key}\\\\}\\\\}|\\\\{${key}\\\\}`, 'g')\n    rendered = rendered.replace(pattern, value)\n  }\n  return rendered\n}\n\nfunction setDiff(left, right) {\n  const rightSet = new Set(right)\n  return left.filter((item) => !rightSet.has(item))\n}\n\nif (!fs.existsSync(catalogPath)) {\n  fail('catalog.ts not found', ['src/lib/prompt-i18n/catalog.ts'])\n}\n\nconst catalogText = fs.readFileSync(catalogPath, 'utf8')\nconst entries = parseCatalog(catalogText)\nif (entries.length === 0) {\n  fail('failed to parse prompt catalog entries')\n}\n\nconst violations = []\n\nfor (const entry of entries) {\n  const zhPath = path.join(root, 'lib', 'prompts', `${entry.pathStem}.zh.txt`)\n  const enPath = path.join(root, 'lib', 'prompts', `${entry.pathStem}.en.txt`)\n  if (!fs.existsSync(zhPath)) {\n    violations.push(`missing zh template: lib/prompts/${entry.pathStem}.zh.txt`)\n    continue\n  }\n  if (!fs.existsSync(enPath)) {\n    violations.push(`missing en template: lib/prompts/${entry.pathStem}.en.txt`)\n    continue\n  }\n\n  const zhTemplate = fs.readFileSync(zhPath, 'utf8')\n  const enTemplate = fs.readFileSync(enPath, 'utf8')\n  const declared = entry.variableKeys\n  const zhPlaceholders = extractPlaceholders(zhTemplate)\n  const enPlaceholders = extractPlaceholders(enTemplate)\n\n  const missingInZh = setDiff(declared, zhPlaceholders)\n  const missingInEn = setDiff(declared, enPlaceholders)\n  const extraInZh = setDiff(zhPlaceholders, declared)\n  const extraInEn = setDiff(enPlaceholders, declared)\n  const zhOnly = setDiff(zhPlaceholders, enPlaceholders)\n  const enOnly = setDiff(enPlaceholders, zhPlaceholders)\n\n  for (const key of missingInZh) {\n    violations.push(`missing {${key}} in zh template: lib/prompts/${entry.pathStem}.zh.txt`)\n  }\n  for (const key of missingInEn) {\n    violations.push(`missing {${key}} in en template: lib/prompts/${entry.pathStem}.en.txt`)\n  }\n  for (const key of extraInZh) {\n    violations.push(`unexpected {${key}} in zh template: lib/prompts/${entry.pathStem}.zh.txt`)\n  }\n  for (const key of extraInEn) {\n    violations.push(`unexpected {${key}} in en template: lib/prompts/${entry.pathStem}.en.txt`)\n  }\n  for (const key of zhOnly) {\n    violations.push(`placeholder {${key}} exists only in zh template: ${entry.pathStem}`)\n  }\n  for (const key of enOnly) {\n    violations.push(`placeholder {${key}} exists only in en template: ${entry.pathStem}`)\n  }\n\n  const variables = Object.fromEntries(\n    declared.map((key) => [key, `__AB_SAMPLE_${key.toUpperCase()}__`]),\n  )\n  const renderedZh = replaceAll(zhTemplate, variables)\n  const renderedEn = replaceAll(enTemplate, variables)\n\n  const unresolvedZh = renderedZh.match(unresolvedPlaceholderPattern) || []\n  const unresolvedEn = renderedEn.match(unresolvedPlaceholderPattern) || []\n  if (unresolvedZh.length > 0) {\n    violations.push(`unresolved placeholders in zh template: ${entry.pathStem} -> ${unresolvedZh.join(', ')}`)\n  }\n  if (unresolvedEn.length > 0) {\n    violations.push(`unresolved placeholders in en template: ${entry.pathStem} -> ${unresolvedEn.join(', ')}`)\n  }\n\n  for (const [key, sample] of Object.entries(variables)) {\n    if (!renderedZh.includes(sample)) {\n      violations.push(`zh template variable not used after render: ${entry.pathStem}.{${key}}`)\n    }\n    if (!renderedEn.includes(sample)) {\n      violations.push(`en template variable not used after render: ${entry.pathStem}.{${key}}`)\n    }\n  }\n}\n\nif (violations.length > 0) {\n  fail('A/B regression check failed', violations)\n}\n\nconsole.log(`[prompt-ab-regression] OK (${entries.length} templates checked)`)\n"
  },
  {
    "path": "scripts/guards/prompt-i18n-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])\nconst scanRoots = ['src', 'scripts']\nconst allowedPromptTemplateReaders = new Set([\n  'src/lib/prompt-i18n/template-store.ts',\n  'scripts/guards/prompt-i18n-guard.mjs',\n  'scripts/guards/prompt-semantic-regression.mjs',\n  'scripts/guards/prompt-ab-regression.mjs',\n  'scripts/guards/prompt-json-canary-guard.mjs',\n])\nconst languageDirectiveAllowList = new Set([\n  'scripts/guards/prompt-i18n-guard.mjs',\n])\nconst languageDirectivePattern = /请用中文|中文输出|use Chinese|output in Chinese/i\n\nfunction fail(title, details = []) {\n  console.error(`\\n[prompt-i18n-guard] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    out.push(fullPath)\n  }\n  return out\n}\n\nfunction listSourceFiles() {\n  return scanRoots\n    .flatMap((scanRoot) => walk(path.join(root, scanRoot)))\n    .filter((fullPath) => sourceExtensions.has(path.extname(fullPath)))\n}\n\nfunction collectDirectPromptReadViolations() {\n  const violations = []\n  const files = listSourceFiles()\n  for (const filePath of files) {\n    const relPath = toRel(filePath)\n    if (allowedPromptTemplateReaders.has(relPath)) continue\n    const content = fs.readFileSync(filePath, 'utf8')\n    const hasReadFileSync = /\\breadFileSync\\s*\\(/.test(content)\n    if (!hasReadFileSync) continue\n    const hasPromptPathToken =\n      content.includes('lib/prompts')\n      || (\n        /['\"]lib['\"]/.test(content)\n        && /['\"]prompts['\"]/.test(content)\n      )\n    if (hasPromptPathToken) {\n      violations.push(`${relPath} direct prompt file read is forbidden; use buildPrompt/getPromptTemplate`)\n    }\n  }\n  return violations\n}\n\nfunction collectLanguageDirectiveViolations() {\n  const violations = []\n\n  for (const filePath of listSourceFiles()) {\n    const relPath = toRel(filePath)\n    if (languageDirectiveAllowList.has(relPath)) continue\n    const lines = fs.readFileSync(filePath, 'utf8').split('\\n')\n    for (let index = 0; index < lines.length; index += 1) {\n      const line = lines[index]\n      if (languageDirectivePattern.test(line)) {\n        violations.push(`${relPath}:${index + 1} hardcoded language directive is forbidden`)\n      }\n    }\n  }\n\n  const promptFiles = walk(path.join(root, 'lib', 'prompts'))\n    .filter((fullPath) => fullPath.endsWith('.en.txt'))\n  for (const filePath of promptFiles) {\n    const relPath = toRel(filePath)\n    const lines = fs.readFileSync(filePath, 'utf8').split('\\n')\n    for (let index = 0; index < lines.length; index += 1) {\n      const line = lines[index]\n      if (languageDirectivePattern.test(line)) {\n        violations.push(`${relPath}:${index + 1} English template cannot require Chinese output`)\n      }\n    }\n  }\n\n  return violations\n}\n\nfunction collectLegacyPromptFiles() {\n  return walk(path.join(root, 'lib', 'prompts'))\n    .map((fullPath) => toRel(fullPath))\n    .filter((relPath) => relPath.endsWith('.txt') && !relPath.endsWith('.zh.txt') && !relPath.endsWith('.en.txt'))\n}\n\nfunction verifyPromptCatalogCoverage() {\n  const catalogPath = path.join(root, 'src', 'lib', 'prompt-i18n', 'catalog.ts')\n  if (!fs.existsSync(catalogPath)) {\n    fail('Missing prompt catalog file', ['src/lib/prompt-i18n/catalog.ts'])\n  }\n\n  const catalogText = fs.readFileSync(catalogPath, 'utf8')\n  const stems = Array.from(catalogText.matchAll(/pathStem:\\s*'([^']+)'/g)).map((match) => match[1])\n  if (stems.length === 0) {\n    fail('No prompt pathStem found in catalog.ts')\n  }\n\n  const missing = []\n  for (const stem of stems) {\n    const zhPath = path.join(root, 'lib', 'prompts', `${stem}.zh.txt`)\n    const enPath = path.join(root, 'lib', 'prompts', `${stem}.en.txt`)\n    if (!fs.existsSync(zhPath)) {\n      missing.push(`missing zh template: lib/prompts/${stem}.zh.txt`)\n    }\n    if (!fs.existsSync(enPath)) {\n      missing.push(`missing en template: lib/prompts/${stem}.en.txt`)\n    }\n  }\n\n  if (missing.length > 0) {\n    fail('Prompt template coverage check failed', missing)\n  }\n}\n\nconst legacyPromptFiles = collectLegacyPromptFiles()\nif (legacyPromptFiles.length > 0) {\n  fail('Legacy prompt files found (.txt without locale suffix)', legacyPromptFiles)\n}\n\nverifyPromptCatalogCoverage()\n\nconst promptReadViolations = collectDirectPromptReadViolations()\nif (promptReadViolations.length > 0) {\n  fail('Found direct prompt template reads', promptReadViolations)\n}\n\nconst languageViolations = collectLanguageDirectiveViolations()\nif (languageViolations.length > 0) {\n  fail('Found hardcoded language directives', languageViolations)\n}\n\nconsole.log('[prompt-i18n-guard] OK')\n"
  },
  {
    "path": "scripts/guards/prompt-json-canary-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\n\nconst CANARY_FILES = {\n  clips: 'standards/prompt-canary/story_to_script_clips.canary.json',\n  screenplay: 'standards/prompt-canary/screenplay_conversion.canary.json',\n  storyboardPanels: 'standards/prompt-canary/storyboard_panels.canary.json',\n  voiceAnalysis: 'standards/prompt-canary/voice_analysis.canary.json',\n}\n\nconst TEMPLATE_TOKEN_REQUIREMENTS = {\n  'novel-promotion/agent_clip': ['start', 'end', 'summary', 'location', 'characters'],\n  'novel-promotion/screenplay_conversion': [\n    'clip_id',\n    'original_text',\n    'scenes',\n    'heading',\n    'content',\n    'type',\n    'action',\n    'dialogue',\n    'voiceover',\n  ],\n  'novel-promotion/agent_storyboard_plan': [\n    'panel_number',\n    'description',\n    'characters',\n    'location',\n    'scene_type',\n    'source_text',\n  ],\n  'novel-promotion/agent_storyboard_detail': [\n    'panel_number',\n    'description',\n    'characters',\n    'location',\n    'scene_type',\n    'source_text',\n    'shot_type',\n    'camera_move',\n    'video_prompt',\n  ],\n  'novel-promotion/agent_storyboard_insert': [\n    'panel_number',\n    'description',\n    'characters',\n    'location',\n    'scene_type',\n    'source_text',\n    'shot_type',\n    'camera_move',\n    'video_prompt',\n  ],\n  'novel-promotion/voice_analysis': [\n    'lineIndex',\n    'speaker',\n    'content',\n    'emotionStrength',\n    'matchedPanel',\n    'storyboardId',\n    'panelIndex',\n  ],\n}\n\nfunction fail(title, details = []) {\n  console.error(`\\n[prompt-json-canary-guard] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction isRecord(value) {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isString(value) {\n  return typeof value === 'string'\n}\n\nfunction isNumber(value) {\n  return typeof value === 'number' && Number.isFinite(value)\n}\n\nfunction readJson(relativePath) {\n  const fullPath = path.join(root, relativePath)\n  if (!fs.existsSync(fullPath)) {\n    fail('Missing canary fixture', [relativePath])\n  }\n  try {\n    return JSON.parse(fs.readFileSync(fullPath, 'utf8'))\n  } catch (error) {\n    fail('Invalid canary fixture JSON', [`${relativePath}: ${error instanceof Error ? error.message : String(error)}`])\n  }\n}\n\nfunction validateClipCanary(value) {\n  if (!Array.isArray(value) || value.length === 0) return 'clips fixture must be a non-empty array'\n  for (let i = 0; i < value.length; i += 1) {\n    const row = value[i]\n    if (!isRecord(row)) return `clips[${i}] must be an object`\n    if (!isString(row.start) || row.start.length < 5) return `clips[${i}].start must be string length >= 5`\n    if (!isString(row.end) || row.end.length < 5) return `clips[${i}].end must be string length >= 5`\n    if (!isString(row.summary) || row.summary.length === 0) return `clips[${i}].summary must be non-empty string`\n    if (!(row.location === null || isString(row.location))) return `clips[${i}].location must be string or null`\n    if (!Array.isArray(row.characters) || !row.characters.every((item) => isString(item))) {\n      return `clips[${i}].characters must be string array`\n    }\n  }\n  return null\n}\n\nfunction validateScreenplayCanary(value) {\n  if (!isRecord(value)) return 'screenplay fixture must be an object'\n  if (!isString(value.clip_id) || !value.clip_id) return 'screenplay.clip_id must be non-empty string'\n  if (!isString(value.original_text)) return 'screenplay.original_text must be string'\n  if (!Array.isArray(value.scenes) || value.scenes.length === 0) return 'screenplay.scenes must be non-empty array'\n\n  for (let i = 0; i < value.scenes.length; i += 1) {\n    const scene = value.scenes[i]\n    if (!isRecord(scene)) return `screenplay.scenes[${i}] must be object`\n    if (!isNumber(scene.scene_number)) return `screenplay.scenes[${i}].scene_number must be number`\n    if (!isRecord(scene.heading)) return `screenplay.scenes[${i}].heading must be object`\n    if (!isString(scene.heading.int_ext)) return `screenplay.scenes[${i}].heading.int_ext must be string`\n    if (!isString(scene.heading.location)) return `screenplay.scenes[${i}].heading.location must be string`\n    if (!isString(scene.heading.time)) return `screenplay.scenes[${i}].heading.time must be string`\n    if (!isString(scene.description)) return `screenplay.scenes[${i}].description must be string`\n    if (!Array.isArray(scene.characters) || !scene.characters.every((item) => isString(item))) {\n      return `screenplay.scenes[${i}].characters must be string array`\n    }\n    if (!Array.isArray(scene.content) || scene.content.length === 0) return `screenplay.scenes[${i}].content must be non-empty array`\n\n    for (let j = 0; j < scene.content.length; j += 1) {\n      const segment = scene.content[j]\n      if (!isRecord(segment)) return `screenplay.scenes[${i}].content[${j}] must be object`\n      if (!isString(segment.type)) return `screenplay.scenes[${i}].content[${j}].type must be string`\n      if (segment.type === 'action') {\n        if (!isString(segment.text)) return `screenplay action[${i}:${j}].text must be string`\n      } else if (segment.type === 'dialogue') {\n        if (!isString(segment.character)) return `screenplay dialogue[${i}:${j}].character must be string`\n        if (!isString(segment.lines)) return `screenplay dialogue[${i}:${j}].lines must be string`\n        if (segment.parenthetical !== undefined && !isString(segment.parenthetical)) {\n          return `screenplay dialogue[${i}:${j}].parenthetical must be string when present`\n        }\n      } else if (segment.type === 'voiceover') {\n        if (!isString(segment.text)) return `screenplay voiceover[${i}:${j}].text must be string`\n        if (segment.character !== undefined && !isString(segment.character)) {\n          return `screenplay voiceover[${i}:${j}].character must be string when present`\n        }\n      } else {\n        return `screenplay.scenes[${i}].content[${j}].type must be action/dialogue/voiceover`\n      }\n    }\n  }\n\n  return null\n}\n\nfunction validateStoryboardPanelsCanary(value) {\n  if (!Array.isArray(value) || value.length === 0) return 'storyboard panels fixture must be non-empty array'\n  for (let i = 0; i < value.length; i += 1) {\n    const panel = value[i]\n    if (!isRecord(panel)) return `storyboardPanels[${i}] must be object`\n    if (!isNumber(panel.panel_number)) return `storyboardPanels[${i}].panel_number must be number`\n    if (!isString(panel.description)) return `storyboardPanels[${i}].description must be string`\n    if (!isString(panel.location)) return `storyboardPanels[${i}].location must be string`\n    if (!isString(panel.scene_type)) return `storyboardPanels[${i}].scene_type must be string`\n    if (!isString(panel.source_text)) return `storyboardPanels[${i}].source_text must be string`\n    if (!isString(panel.shot_type)) return `storyboardPanels[${i}].shot_type must be string`\n    if (!isString(panel.camera_move)) return `storyboardPanels[${i}].camera_move must be string`\n    if (!isString(panel.video_prompt)) return `storyboardPanels[${i}].video_prompt must be string`\n    if (panel.duration !== undefined && !isNumber(panel.duration)) return `storyboardPanels[${i}].duration must be number when present`\n    if (!Array.isArray(panel.characters)) return `storyboardPanels[${i}].characters must be array`\n    for (let j = 0; j < panel.characters.length; j += 1) {\n      const character = panel.characters[j]\n      if (!isRecord(character)) return `storyboardPanels[${i}].characters[${j}] must be object`\n      if (!isString(character.name)) return `storyboardPanels[${i}].characters[${j}].name must be string`\n      if (character.appearance !== undefined && !isString(character.appearance)) {\n        return `storyboardPanels[${i}].characters[${j}].appearance must be string when present`\n      }\n    }\n  }\n  return null\n}\n\nfunction validateVoiceAnalysisCanary(value) {\n  if (!Array.isArray(value) || value.length === 0) return 'voice analysis fixture must be non-empty array'\n  for (let i = 0; i < value.length; i += 1) {\n    const row = value[i]\n    if (!isRecord(row)) return `voiceAnalysis[${i}] must be object`\n    if (!isNumber(row.lineIndex)) return `voiceAnalysis[${i}].lineIndex must be number`\n    if (!isString(row.speaker)) return `voiceAnalysis[${i}].speaker must be string`\n    if (!isString(row.content)) return `voiceAnalysis[${i}].content must be string`\n    if (!isNumber(row.emotionStrength)) return `voiceAnalysis[${i}].emotionStrength must be number`\n    if (row.matchedPanel !== null) {\n      if (!isRecord(row.matchedPanel)) return `voiceAnalysis[${i}].matchedPanel must be object or null`\n      if (!isString(row.matchedPanel.storyboardId)) return `voiceAnalysis[${i}].matchedPanel.storyboardId must be string`\n      if (!isNumber(row.matchedPanel.panelIndex)) return `voiceAnalysis[${i}].matchedPanel.panelIndex must be number`\n    }\n  }\n  return null\n}\n\nfunction checkTemplateTokens(pathStem, requiredTokens) {\n  const violations = []\n  for (const locale of ['zh', 'en']) {\n    const relPath = `lib/prompts/${pathStem}.${locale}.txt`\n    const fullPath = path.join(root, relPath)\n    if (!fs.existsSync(fullPath)) {\n      violations.push(`missing template: ${relPath}`)\n      continue\n    }\n    const content = fs.readFileSync(fullPath, 'utf8')\n    for (const token of requiredTokens) {\n      if (!content.includes(token)) {\n        violations.push(`missing token ${token} in ${relPath}`)\n      }\n    }\n  }\n  return violations\n}\n\nconst violations = []\n\nconst clipsErr = validateClipCanary(readJson(CANARY_FILES.clips))\nif (clipsErr) violations.push(clipsErr)\n\nconst screenplayErr = validateScreenplayCanary(readJson(CANARY_FILES.screenplay))\nif (screenplayErr) violations.push(screenplayErr)\n\nconst panelsErr = validateStoryboardPanelsCanary(readJson(CANARY_FILES.storyboardPanels))\nif (panelsErr) violations.push(panelsErr)\n\nconst voiceErr = validateVoiceAnalysisCanary(readJson(CANARY_FILES.voiceAnalysis))\nif (voiceErr) violations.push(voiceErr)\n\nfor (const [pathStem, requiredTokens] of Object.entries(TEMPLATE_TOKEN_REQUIREMENTS)) {\n  violations.push(...checkTemplateTokens(pathStem, requiredTokens))\n}\n\nif (violations.length > 0) {\n  fail('JSON schema canary check failed', violations)\n}\n\nconsole.log('[prompt-json-canary-guard] OK')\n"
  },
  {
    "path": "scripts/guards/prompt-semantic-regression.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\nconst catalogPath = path.join(root, 'src', 'lib', 'prompt-i18n', 'catalog.ts')\nconst chineseCharPattern = /[\\p{Script=Han}]/u\nconst singlePlaceholderPattern = /\\{([A-Za-z0-9_]+)\\}/g\nconst doublePlaceholderPattern = /\\{\\{([A-Za-z0-9_]+)\\}\\}/g\n\nconst criticalTemplateTokens = new Map([\n  ['novel-promotion/voice_analysis', ['\"lineIndex\"', '\"speaker\"', '\"content\"', '\"emotionStrength\"', '\"matchedPanel\"']],\n  ['novel-promotion/agent_storyboard_plan', ['\"panel_number\"', '\"description\"', '\"characters\"', '\"location\"', '\"scene_type\"', '\"source_text\"', '\"shot_type\"', '\"camera_move\"', '\"video_prompt\"']],\n  ['novel-promotion/agent_storyboard_detail', ['\"panel_number\"', '\"description\"', '\"characters\"', '\"location\"', '\"scene_type\"', '\"source_text\"', '\"shot_type\"', '\"camera_move\"', '\"video_prompt\"']],\n  ['novel-promotion/agent_storyboard_insert', ['\"panel_number\"', '\"description\"', '\"characters\"', '\"location\"', '\"scene_type\"', '\"source_text\"', '\"shot_type\"', '\"camera_move\"', '\"video_prompt\"']],\n  ['novel-promotion/screenplay_conversion', ['\"clip_id\"', '\"scenes\"', '\"heading\"', '\"content\"', '\"dialogue\"', '\"voiceover\"']],\n  ['novel-promotion/select_location', ['\"locations\"', '\"name\"', '\"summary\"', '\"descriptions\"']],\n  ['novel-promotion/episode_split', ['\"analysis\"', '\"episodes\"', '\"startMarker\"', '\"endMarker\"', '\"validation\"']],\n  ['novel-promotion/image_prompt_modify', ['\"image_prompt\"', '\"video_prompt\"']],\n  ['novel-promotion/character_create', ['\"prompt\"']],\n  ['novel-promotion/location_create', ['\"prompt\"']],\n])\n\nfunction fail(title, details = []) {\n  console.error(`\\n[prompt-semantic-regression] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction parseCatalog(text) {\n  const entries = []\n  const entryPattern = /pathStem:\\s*'([^']+)'\\s*,[\\s\\S]*?variableKeys:\\s*\\[([\\s\\S]*?)\\]\\s*,/g\n  for (const match of text.matchAll(entryPattern)) {\n    const pathStem = match[1]\n    const rawKeys = match[2] || ''\n    const keys = Array.from(rawKeys.matchAll(/'([^']+)'/g)).map((item) => item[1])\n    entries.push({ pathStem, variableKeys: keys })\n  }\n  return entries\n}\n\nfunction extractPlaceholders(template) {\n  const keys = new Set()\n  for (const match of template.matchAll(singlePlaceholderPattern)) {\n    if (match[1]) keys.add(match[1])\n  }\n  for (const match of template.matchAll(doublePlaceholderPattern)) {\n    if (match[1]) keys.add(match[1])\n  }\n  return Array.from(keys)\n}\n\nif (!fs.existsSync(catalogPath)) {\n  fail('catalog.ts not found', ['src/lib/prompt-i18n/catalog.ts'])\n}\n\nconst catalogText = fs.readFileSync(catalogPath, 'utf8')\nconst entries = parseCatalog(catalogText)\nif (entries.length === 0) {\n  fail('failed to parse prompt catalog entries')\n}\n\nconst violations = []\nfor (const entry of entries) {\n  const templatePath = path.join(root, 'lib', 'prompts', `${entry.pathStem}.en.txt`)\n  if (!fs.existsSync(templatePath)) {\n    violations.push(`missing template: lib/prompts/${entry.pathStem}.en.txt`)\n    continue\n  }\n\n  const template = fs.readFileSync(templatePath, 'utf8')\n  if (chineseCharPattern.test(template)) {\n    violations.push(`unexpected Chinese content in English template: lib/prompts/${entry.pathStem}.en.txt`)\n  }\n\n  const placeholders = extractPlaceholders(template)\n  const placeholderSet = new Set(placeholders)\n  const variableKeySet = new Set(entry.variableKeys)\n\n  for (const key of entry.variableKeys) {\n    if (!placeholderSet.has(key)) {\n      violations.push(`missing placeholder {${key}} in lib/prompts/${entry.pathStem}.en.txt`)\n    }\n  }\n\n  for (const key of placeholders) {\n    if (!variableKeySet.has(key)) {\n      violations.push(`unexpected placeholder {${key}} in lib/prompts/${entry.pathStem}.en.txt`)\n    }\n  }\n\n  const requiredTokens = criticalTemplateTokens.get(entry.pathStem) || []\n  for (const token of requiredTokens) {\n    if (!template.includes(token)) {\n      violations.push(`missing semantic token ${token} in lib/prompts/${entry.pathStem}.en.txt`)\n    }\n  }\n}\n\nif (violations.length > 0) {\n  fail('semantic regression check failed', violations)\n}\n\nconsole.log(`[prompt-semantic-regression] OK (${entries.length} templates checked)`)\n"
  },
  {
    "path": "scripts/guards/task-loading-baseline.json",
    "content": "{\n  \"allowedDirectTaskStateUsageFiles\": [\n    \"src/lib/query/hooks/useTaskTargetStates.ts\",\n    \"src/lib/query/hooks/useTaskPresentation.ts\",\n    \"src/lib/query/hooks/useProjectAssets.ts\",\n    \"src/lib/query/hooks/useGlobalAssets.ts\"\n  ],\n  \"allowedLegacyGeneratingUsageFiles\": []\n}\n"
  },
  {
    "path": "scripts/guards/task-loading-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst workspaceRoot = process.cwd()\nconst baselinePath = path.join(workspaceRoot, 'scripts/guards/task-loading-baseline.json')\n\nfunction walkFiles(dir, out = []) {\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.next') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walkFiles(fullPath, out)\n    } else {\n      out.push(fullPath)\n    }\n  }\n  return out\n}\n\nfunction toPosixRelative(filePath) {\n  return path.relative(workspaceRoot, filePath).split(path.sep).join('/')\n}\n\nfunction collectMatches(files, pattern) {\n  const matches = []\n  for (const fullPath of files) {\n    if (!fullPath.endsWith('.ts') && !fullPath.endsWith('.tsx')) continue\n    const relPath = toPosixRelative(fullPath)\n    const content = fs.readFileSync(fullPath, 'utf8')\n    const lines = content.split('\\n')\n    for (let i = 0; i < lines.length; i += 1) {\n      if (lines[i].includes(pattern)) {\n        matches.push(`${relPath}:${i + 1}`)\n      }\n    }\n  }\n  return matches\n}\n\nfunction fail(title, lines) {\n  console.error(`\\n[task-loading-guard] ${title}`)\n  for (const line of lines) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nif (!fs.existsSync(baselinePath)) {\n  fail('Missing baseline file', [toPosixRelative(baselinePath)])\n}\n\nconst baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'))\nconst allowedFiles = new Set(baseline.allowedDirectTaskStateUsageFiles || [])\nconst allowedLegacyGeneratingFiles = new Set(baseline.allowedLegacyGeneratingUsageFiles || [])\nconst allFiles = walkFiles(path.join(workspaceRoot, 'src'))\n\nconst directTaskStateUsage = collectMatches(allFiles, 'useTaskTargetStates(')\nconst directUsageOutOfAllowlist = directTaskStateUsage\n  .map((entry) => entry.split(':')[0])\n  .filter((file) => !allowedFiles.has(file))\n\nif (directUsageOutOfAllowlist.length > 0) {\n  fail(\n    'Found component-level direct useTaskTargetStates outside baseline allowlist',\n    Array.from(new Set(directUsageOutOfAllowlist)),\n  )\n}\n\nconst crossDomainLabels = collectMatches(allFiles, 'video.panelCard.generating')\nif (crossDomainLabels.length > 0) {\n  fail('Found cross-domain loading label reuse (video.panelCard.generating)', crossDomainLabels)\n}\n\nconst uiFiles = allFiles.filter((file) => {\n  const relPath = toPosixRelative(file)\n  return relPath.startsWith('src/app/') || relPath.startsWith('src/components/')\n})\nconst legacyGeneratingPatterns = [\n  'appearance.generating',\n  'panel.generatingImage',\n  'shot.generatingImage',\n  'line.generating',\n]\nconst legacyGeneratingMatches = legacyGeneratingPatterns.flatMap((pattern) =>\n  collectMatches(uiFiles, pattern),\n)\nconst legacyGeneratingOutOfAllowlist = legacyGeneratingMatches\n  .map((entry) => entry.split(':')[0])\n  .filter((file) => !allowedLegacyGeneratingFiles.has(file))\nif (legacyGeneratingOutOfAllowlist.length > 0) {\n  fail(\n    'Found legacy generating truth usage in UI components',\n    Array.from(new Set(legacyGeneratingOutOfAllowlist)),\n  )\n}\n\nconst hooksIndexPath = path.join(workspaceRoot, 'src/lib/query/hooks/index.ts')\nif (fs.existsSync(hooksIndexPath)) {\n  const hooksIndex = fs.readFileSync(hooksIndexPath, 'utf8')\n  const bannedReexports = [\n    {\n      pattern: /export\\s*\\{[^}]*useGenerateCharacterImage[^}]*\\}\\s*from\\s*['\"]\\.\\/useGlobalAssets['\"]/m,\n      message: 'hooks/index.ts must not export useGenerateCharacterImage from useGlobalAssets',\n    },\n    {\n      pattern: /export\\s*\\{[^}]*useGenerateLocationImage[^}]*\\}\\s*from\\s*['\"]\\.\\/useGlobalAssets['\"]/m,\n      message: 'hooks/index.ts must not export useGenerateLocationImage from useGlobalAssets',\n    },\n    {\n      pattern: /export\\s*\\{[^}]*useGenerateProjectCharacterImage[^}]*\\}\\s*from\\s*['\"]\\.\\/useProjectAssets['\"]/m,\n      message: 'hooks/index.ts must not export useGenerateProjectCharacterImage from useProjectAssets',\n    },\n    {\n      pattern: /export\\s*\\{[^}]*useGenerateProjectLocationImage[^}]*\\}\\s*from\\s*['\"]\\.\\/useProjectAssets['\"]/m,\n      message: 'hooks/index.ts must not export useGenerateProjectLocationImage from useProjectAssets',\n    },\n  ]\n\n  const violations = bannedReexports\n    .filter((item) => item.pattern.test(hooksIndex))\n    .map((item) => item.message)\n\n  if (violations.length > 0) {\n    fail('Found non-canonical mutation re-exports', violations)\n  }\n}\n\nconsole.log('[task-loading-guard] OK')\n"
  },
  {
    "path": "scripts/guards/task-state-unification-guard.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nfailed=0\n\ncheck_absent() {\n  local label=\"$1\"\n  local pattern=\"$2\"\n  shift 2\n  local output\n  output=\"$(git grep --untracked -nE \"$pattern\" -- \"$@\" || true)\"\n  if [[ -n \"$output\" ]]; then\n    echo \"$output\"\n    echo \"::error title=${label}::${label}\"\n    failed=1\n  fi\n}\n\ncheck_absent \\\n  \"Do not branch UI status on cancelled\" \\\n  \"status[[:space:]]*===[[:space:]]*['\\\\\\\"]cancelled['\\\\\\\"]|status[[:space:]]*==[[:space:]]*['\\\\\\\"]cancelled['\\\\\\\"]\" \\\n  src/app \\\n  src/components \\\n  src/features \\\n  src/lib/query\n\ncheck_absent \\\n  \"useTaskHandoff is forbidden\" \\\n  \"useTaskHandoff\" \\\n  src\n\ncheck_absent \\\n  \"Do not use legacy task hooks in app layer\" \\\n  \"useActiveTasks\\\\(|useTaskStatus\\\\(\" \\\n  src/app \\\n  src/features\n\nif [[ \"$failed\" -ne 0 ]]; then\n  exit 1\nfi\n\necho \"task-state-unification guard passed\"\n"
  },
  {
    "path": "scripts/guards/task-status-cutover-audit.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nROOT_DIR=\"$(git rev-parse --show-toplevel)\"\ncd \"$ROOT_DIR\"\n\nFAILED=0\n\nprint_header() {\n  echo\n  echo \"============================================================\"\n  echo \"$1\"\n  echo \"============================================================\"\n}\n\nprint_ok() {\n  echo \"[PASS] $1\"\n}\n\nprint_fail() {\n  echo \"[FAIL] $1\"\n}\n\nrun_zero_match_check() {\n  local title=\"$1\"\n  local pattern=\"$2\"\n  shift 2\n  local paths=(\"$@\")\n  local output\n  output=\"$(git grep -n -E \"$pattern\" -- \"${paths[@]}\" || true)\"\n  if [[ -z \"$output\" ]]; then\n    print_ok \"$title\"\n  else\n    print_fail \"$title\"\n    echo \"$output\"\n    FAILED=1\n  fi\n}\n\nrun_usetasktargetstates_check() {\n  local title=\"useTaskTargetStates 仅允许在 useProjectAssets/useGlobalAssets 中使用\"\n  local output\n  output=\"$(git grep -n \"useTaskTargetStates\" -- src || true)\"\n\n  if [[ -z \"$output\" ]]; then\n    print_ok \"$title (当前 0 命中)\"\n    return\n  fi\n\n  local filtered\n  filtered=\"$(echo \"$output\" | grep -v \"src/lib/query/hooks/useProjectAssets.ts\" | grep -v \"src/lib/query/hooks/useGlobalAssets.ts\" || true)\"\n\n  if [[ -z \"$filtered\" ]]; then\n    print_ok \"$title\"\n  else\n    print_fail \"$title\"\n    echo \"$filtered\"\n    FAILED=1\n  fi\n}\n\nprint_header \"Task Status Cutover Audit\"\n\nrun_zero_match_check \\\n  \"禁止 useTaskHandoff\" \\\n  \"useTaskHandoff\" \\\n  src\n\nrun_zero_match_check \\\n  \"禁止 manualRegeneratingItems/setRegeneratingItems/clearRegeneratingItem\" \\\n  \"manualRegeneratingItems|setRegeneratingItems|clearRegeneratingItem\" \\\n  src\n\nrun_zero_match_check \\\n  \"禁止业务层直接判断 status ===/!== cancelled\" \\\n  \"status\\\\s*===\\\\s*['\\\\\\\"]cancelled['\\\\\\\"]|status\\\\s*!==\\\\s*['\\\\\\\"]cancelled['\\\\\\\"]\" \\\n  src\n\nrun_zero_match_check \\\n  \"禁止 generatingImage/generatingVideo/generatingLipSync 字段\" \\\n  \"\\\\bgeneratingImage\\\\b|\\\\bgeneratingVideo\\\\b|\\\\bgeneratingLipSync\\\\b\" \\\n  src\n\nrun_usetasktargetstates_check\n\nrun_zero_match_check \\\n  \"禁止 novel-promotion/asset-hub/shared-assets 中 useState(false) 作为生成态命名\" \\\n  \"const \\\\[[^\\\\]]*(Generating|Regenerating|WaitingForGeneration|AnalyzingAssets|GeneratingAll|CopyingFromGlobal)[^\\\\]]*\\\\]\\\\s*=\\\\s*useState\\\\(false\\\\)\" \\\n  \"src/app/[locale]/workspace/[projectId]/modes/novel-promotion\" \\\n  \"src/app/[locale]/workspace/asset-hub\" \\\n  \"src/components/shared/assets\"\n\nprint_header \"Audit Result\"\nif [[ \"$FAILED\" -eq 0 ]]; then\n  echo \"All checks passed.\"\n  exit 0\nfi\n\necho \"Audit failed. Please fix findings above.\"\nexit 1\n"
  },
  {
    "path": "scripts/guards/task-submit-compensation-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\nimport { pathToFileURL } from 'url'\n\nconst root = process.cwd()\nconst apiDir = path.join(root, 'src', 'app', 'api')\nconst CREATE_PATTERN = /\\.\\s*create\\s*\\(/\nconst SUBMIT_TASK_PATTERN = /\\bsubmitTask\\s*\\(/\nconst ROLLBACK_PATTERN = /rollback|compensat/i\n\nfunction fail(title, details = []) {\n  process.stderr.write(`\\n[task-submit-compensation-guard] ${title}\\n`)\n  for (const detail of details) {\n    process.stderr.write(`  - ${detail}\\n`)\n  }\n  process.exit(1)\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (entry.name === 'route.ts') out.push(fullPath)\n  }\n  return out\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nexport function inspectTaskSubmitCompensation(relPath, content) {\n  if (!CREATE_PATTERN.test(content)) return []\n  if (!SUBMIT_TASK_PATTERN.test(content)) return []\n  if (ROLLBACK_PATTERN.test(content)) return []\n  return [\n    `${relPath} creates data before submitTask without explicit rollback/compensation marker`,\n  ]\n}\n\nexport function findTaskSubmitCompensationViolations(scanRoot = root) {\n  const routesRoot = path.join(scanRoot, 'src', 'app', 'api')\n  return walk(routesRoot)\n    .map((fullPath) => {\n      const relPath = path.relative(scanRoot, fullPath).split(path.sep).join('/')\n      const content = fs.readFileSync(fullPath, 'utf8')\n      return inspectTaskSubmitCompensation(relPath, content)\n    })\n    .flat()\n}\n\nexport function main() {\n  if (!fs.existsSync(apiDir)) {\n    fail('Missing src/app/api directory')\n  }\n\n  const routeFiles = walk(apiDir)\n  const violations = routeFiles\n    .map((fullPath) => {\n      const relPath = toRel(fullPath)\n      const content = fs.readFileSync(fullPath, 'utf8')\n      return inspectTaskSubmitCompensation(relPath, content)\n    })\n    .flat()\n\n  if (violations.length > 0) {\n    fail('Found create+submitTask routes without compensation marker', violations)\n  }\n\n  process.stdout.write(`[task-submit-compensation-guard] OK routes=${routeFiles.length}\\n`)\n}\n\nif (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {\n  main()\n}\n"
  },
  {
    "path": "scripts/guards/task-target-states-no-polling-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\n\nconst root = process.cwd()\n\nfunction fail(title, details = []) {\n  console.error(`\\n[task-target-states-no-polling-guard] ${title}`)\n  for (const line of details) {\n    console.error(`  - ${line}`)\n  }\n  process.exit(1)\n}\n\nfunction readFile(relativePath) {\n  const fullPath = path.join(root, relativePath)\n  if (!fs.existsSync(fullPath)) {\n    fail('Missing required file', [relativePath])\n  }\n  return fs.readFileSync(fullPath, 'utf8')\n}\n\nfunction walk(dir, out = []) {\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const full = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(full, out)\n    } else {\n      out.push(full)\n    }\n  }\n  return out\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nfunction collectPattern(pattern) {\n  const files = walk(path.join(root, 'src'))\n  const hits = []\n  for (const fullPath of files) {\n    if (!fullPath.endsWith('.ts') && !fullPath.endsWith('.tsx')) continue\n    const text = fs.readFileSync(fullPath, 'utf8')\n    const lines = text.split('\\n')\n    for (let i = 0; i < lines.length; i += 1) {\n      if (pattern.test(lines[i])) {\n        hits.push(`${toRel(fullPath)}:${i + 1}`)\n      }\n    }\n  }\n  return hits\n}\n\nconst refetchIntervalMsHits = collectPattern(/\\brefetchIntervalMs\\b/)\nif (refetchIntervalMsHits.length > 0) {\n  fail('Found forbidden refetchIntervalMs usage', refetchIntervalMsHits)\n}\n\nconst voiceStagePath =\n  'src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/VoiceStage.tsx'\nconst voiceStageText = readFile(voiceStagePath)\nif (voiceStageText.includes('setInterval(')) {\n  fail('VoiceStage must not use timer polling', [voiceStagePath])\n}\n\nconst targetStateMapPath = 'src/lib/query/hooks/useTaskTargetStateMap.ts'\nconst targetStateMapText = readFile(targetStateMapPath)\nif (!/refetchInterval:\\s*false/.test(targetStateMapText)) {\n  fail('useTaskTargetStateMap must keep refetchInterval disabled', [targetStateMapPath])\n}\n\nconst ssePath = 'src/lib/query/hooks/useSSE.ts'\nconst sseText = readFile(ssePath)\nconst targetStatesInvalidateExprMatch = sseText.match(\n  /const shouldInvalidateTargetStates\\s*=\\s*([\\s\\S]*?)\\n\\s*\\n/,\n)\nif (!targetStatesInvalidateExprMatch) {\n  fail('Unable to locate shouldInvalidateTargetStates expression', [ssePath])\n}\nconst targetStatesInvalidateExpr = targetStatesInvalidateExprMatch[1]\nif (!/TASK_EVENT_TYPE\\.COMPLETED/.test(targetStatesInvalidateExpr) || !/TASK_EVENT_TYPE\\.FAILED/.test(targetStatesInvalidateExpr)) {\n  fail('useSSE must invalidate target states only for terminal events', [ssePath])\n}\nif (/TASK_EVENT_TYPE\\.CREATED/.test(targetStatesInvalidateExpr)) {\n  fail('useSSE target-state invalidation must not include CREATED', [ssePath])\n}\nif (/TASK_EVENT_TYPE\\.PROCESSING/.test(targetStatesInvalidateExpr)) {\n  fail('useSSE target-state invalidation must not include PROCESSING', [ssePath])\n}\n\nconsole.log('[task-target-states-no-polling-guard] OK')\n"
  },
  {
    "path": "scripts/guards/test-behavior-quality-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\n\nconst root = process.cwd()\nconst targetDirs = [\n  path.join(root, 'tests', 'integration', 'api', 'contract'),\n  path.join(root, 'tests', 'integration', 'provider'),\n  path.join(root, 'tests', 'integration', 'chain'),\n  path.join(root, 'tests', 'system'),\n  path.join(root, 'tests', 'regression'),\n]\n\nfunction fail(title, details = []) {\n  console.error(`\\n[test-behavior-quality-guard] ${title}`)\n  for (const detail of details) {\n    console.error(`  - ${detail}`)\n  }\n  process.exit(1)\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === 'node_modules') continue\n    const full = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(full, out)\n      continue\n    }\n    if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(full)\n  }\n  return out\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nconst files = targetDirs.flatMap((dir) => walk(dir))\nif (files.length === 0) {\n  fail('No target test files found', targetDirs.map((dir) => toRel(dir)))\n}\n\nconst violations = []\n\nfor (const file of files) {\n  const rel = toRel(file)\n  const text = fs.readFileSync(file, 'utf8')\n\n  const hasSourceRead = /(readFileSync|fs\\.readFileSync)\\s*\\([\\s\\S]{0,240}src\\/(app|lib)\\//m.test(text)\n  if (hasSourceRead) {\n    violations.push(`${rel}: reading source code text is forbidden in behavior contract/chain tests`)\n  }\n\n  const forbiddenStringContracts = [\n    /toContain\\(\\s*['\"]apiHandler['\"]\\s*\\)/,\n    /toContain\\(\\s*['\"]submitTask['\"]\\s*\\)/,\n    /toContain\\(\\s*['\"]maybeSubmitLLMTask['\"]\\s*\\)/,\n    /includes\\(\\s*['\"]apiHandler['\"]\\s*\\)/,\n    /includes\\(\\s*['\"]submitTask['\"]\\s*\\)/,\n    /includes\\(\\s*['\"]maybeSubmitLLMTask['\"]\\s*\\)/,\n  ]\n\n  for (const pattern of forbiddenStringContracts) {\n    if (pattern.test(text)) {\n      violations.push(`${rel}: forbidden structural string assertion matched ${pattern}`)\n      break\n    }\n  }\n\n  const hasWeakCallAssertion = /toHaveBeenCalled\\(\\s*\\)/.test(text)\n  const hasStrongCallAssertion = /toHaveBeenCalledWith\\(/.test(text)\n  if (hasWeakCallAssertion && !hasStrongCallAssertion) {\n    violations.push(`${rel}: has toHaveBeenCalled() without any toHaveBeenCalledWith() result assertions`)\n  }\n}\n\nif (violations.length > 0) {\n  fail('Behavior quality violations found', violations)\n}\n\nconsole.log(`[test-behavior-quality-guard] OK files=${files.length}`)\n"
  },
  {
    "path": "scripts/guards/test-behavior-route-coverage-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\n\nconst root = process.cwd()\nconst catalogPath = path.join(root, 'tests', 'contracts', 'route-catalog.ts')\nconst matrixPath = path.join(root, 'tests', 'contracts', 'route-behavior-matrix.ts')\n\nfunction fail(title, details = []) {\n  console.error(`\\n[test-behavior-route-coverage-guard] ${title}`)\n  for (const detail of details) {\n    console.error(`  - ${detail}`)\n  }\n  process.exit(1)\n}\n\nif (!fs.existsSync(catalogPath)) {\n  fail('route catalog is missing', ['tests/contracts/route-catalog.ts'])\n}\nif (!fs.existsSync(matrixPath)) {\n  fail('route behavior matrix is missing', ['tests/contracts/route-behavior-matrix.ts'])\n}\n\nconst catalogText = fs.readFileSync(catalogPath, 'utf8')\nconst matrixText = fs.readFileSync(matrixPath, 'utf8')\n\nif (!matrixText.includes('ROUTE_CATALOG.map')) {\n  fail('route behavior matrix must derive entries from ROUTE_CATALOG.map')\n}\n\nconst routeFilesBlockMatch = catalogText.match(/const ROUTE_FILES = \\[([\\s\\S]*?)\\] as const/)\nif (!routeFilesBlockMatch) {\n  fail('unable to parse ROUTE_FILES block from route catalog')\n}\nconst routeFilesBlock = routeFilesBlockMatch ? routeFilesBlockMatch[1] : ''\nconst routeCount = Array.from(routeFilesBlock.matchAll(/'src\\/app\\/api\\/[^']+\\/route\\.ts'/g)).length\nif (routeCount === 0) {\n  fail('no routes detected in route catalog')\n}\n\nconst testFiles = Array.from(matrixText.matchAll(/'tests\\/[a-zA-Z0-9_\\-/.]+\\.test\\.ts'/g))\n  .map((match) => match[0].slice(1, -1))\n\nif (testFiles.length === 0) {\n  fail('route behavior matrix does not declare any behavior test files')\n}\n\nconst missingTests = Array.from(new Set(testFiles)).filter((file) => !fs.existsSync(path.join(root, file)))\nif (missingTests.length > 0) {\n  fail('route behavior matrix references missing test files', missingTests)\n}\n\nconsole.log(`[test-behavior-route-coverage-guard] OK routes=${routeCount} tests=${new Set(testFiles).size}`)\n"
  },
  {
    "path": "scripts/guards/test-behavior-tasktype-coverage-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\n\nconst root = process.cwd()\nconst catalogPath = path.join(root, 'tests', 'contracts', 'task-type-catalog.ts')\nconst matrixPath = path.join(root, 'tests', 'contracts', 'tasktype-behavior-matrix.ts')\n\nfunction fail(title, details = []) {\n  console.error(`\\n[test-behavior-tasktype-coverage-guard] ${title}`)\n  for (const detail of details) {\n    console.error(`  - ${detail}`)\n  }\n  process.exit(1)\n}\n\nif (!fs.existsSync(catalogPath)) {\n  fail('task type catalog is missing', ['tests/contracts/task-type-catalog.ts'])\n}\nif (!fs.existsSync(matrixPath)) {\n  fail('tasktype behavior matrix is missing', ['tests/contracts/tasktype-behavior-matrix.ts'])\n}\n\nconst catalogText = fs.readFileSync(catalogPath, 'utf8')\nconst matrixText = fs.readFileSync(matrixPath, 'utf8')\n\nif (!matrixText.includes('TASK_TYPE_CATALOG.map')) {\n  fail('tasktype behavior matrix must derive entries from TASK_TYPE_CATALOG.map')\n}\n\nconst taskTypeCount = Array.from(catalogText.matchAll(/\\[TASK_TYPE\\.([A-Z_]+)\\]/g)).length\nif (taskTypeCount === 0) {\n  fail('no task types detected in task type catalog')\n}\n\nconst testFiles = Array.from(matrixText.matchAll(/'tests\\/[a-zA-Z0-9_\\-/.]+\\.test\\.ts'/g))\n  .map((match) => match[0].slice(1, -1))\n\nif (testFiles.length === 0) {\n  fail('tasktype behavior matrix does not declare any behavior test files')\n}\n\nconst missingTests = Array.from(new Set(testFiles)).filter((file) => !fs.existsSync(path.join(root, file)))\nif (missingTests.length > 0) {\n  fail('tasktype behavior matrix references missing test files', missingTests)\n}\n\nconsole.log(`[test-behavior-tasktype-coverage-guard] OK taskTypes=${taskTypeCount} tests=${new Set(testFiles).size}`)\n"
  },
  {
    "path": "scripts/guards/test-route-coverage-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\n\nconst root = process.cwd()\nconst apiDir = path.join(root, 'src', 'app', 'api')\nconst catalogPath = path.join(root, 'tests', 'contracts', 'route-catalog.ts')\n\nfunction fail(title, details = []) {\n  console.error(`\\n[test-route-coverage-guard] ${title}`)\n  for (const detail of details) {\n    console.error(`  - ${detail}`)\n  }\n  process.exit(1)\n}\n\nfunction walk(dir, out = []) {\n  if (!fs.existsSync(dir)) return out\n  const entries = fs.readdirSync(dir, { withFileTypes: true })\n  for (const entry of entries) {\n    if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue\n    const fullPath = path.join(dir, entry.name)\n    if (entry.isDirectory()) {\n      walk(fullPath, out)\n      continue\n    }\n    if (entry.name === 'route.ts') out.push(fullPath)\n  }\n  return out\n}\n\nfunction toRel(fullPath) {\n  return path.relative(root, fullPath).split(path.sep).join('/')\n}\n\nif (!fs.existsSync(catalogPath)) {\n  fail('route-catalog.ts is missing', ['tests/contracts/route-catalog.ts'])\n}\n\nconst actualRoutes = walk(apiDir).map(toRel).sort()\nconst catalogText = fs.readFileSync(catalogPath, 'utf8')\nconst catalogRoutes = Array.from(catalogText.matchAll(/'src\\/app\\/api\\/[^']+\\/route\\.ts'/g))\n  .map((match) => match[0].slice(1, -1))\n  .sort()\n\nconst missingInCatalog = actualRoutes.filter((routeFile) => !catalogRoutes.includes(routeFile))\nconst staleInCatalog = catalogRoutes.filter((routeFile) => !actualRoutes.includes(routeFile))\n\nif (missingInCatalog.length > 0) {\n  fail('Missing routes in tests/contracts/route-catalog.ts', missingInCatalog)\n}\nif (staleInCatalog.length > 0) {\n  fail('Stale route entries found in tests/contracts/route-catalog.ts', staleInCatalog)\n}\n\nconsole.log(`[test-route-coverage-guard] OK routes=${actualRoutes.length}`)\n"
  },
  {
    "path": "scripts/guards/test-tasktype-coverage-guard.mjs",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs'\nimport path from 'path'\n\nconst root = process.cwd()\nconst taskTypesPath = path.join(root, 'src', 'lib', 'task', 'types.ts')\nconst catalogPath = path.join(root, 'tests', 'contracts', 'task-type-catalog.ts')\n\nfunction fail(title, details = []) {\n  console.error(`\\n[test-tasktype-coverage-guard] ${title}`)\n  for (const detail of details) {\n    console.error(`  - ${detail}`)\n  }\n  process.exit(1)\n}\n\nif (!fs.existsSync(taskTypesPath)) {\n  fail('Task type source file is missing', ['src/lib/task/types.ts'])\n}\nif (!fs.existsSync(catalogPath)) {\n  fail('Task type catalog file is missing', ['tests/contracts/task-type-catalog.ts'])\n}\n\nconst taskTypesText = fs.readFileSync(taskTypesPath, 'utf8')\nconst catalogText = fs.readFileSync(catalogPath, 'utf8')\n\nconst taskTypeBlockMatch = taskTypesText.match(/export const TASK_TYPE = \\{([\\s\\S]*?)\\n\\} as const/)\nif (!taskTypeBlockMatch) {\n  fail('Unable to parse TASK_TYPE block from src/lib/task/types.ts')\n}\nconst taskTypeBlock = taskTypeBlockMatch ? taskTypeBlockMatch[1] : ''\nconst taskTypeKeys = Array.from(taskTypeBlock.matchAll(/^\\s+([A-Z_]+):\\s'[^']+',?$/gm)).map((match) => match[1])\nconst catalogKeys = Array.from(catalogText.matchAll(/\\[TASK_TYPE\\.([A-Z_]+)\\]/g)).map((match) => match[1])\n\nconst missingKeys = taskTypeKeys.filter((key) => !catalogKeys.includes(key))\nconst staleKeys = catalogKeys.filter((key) => !taskTypeKeys.includes(key))\n\nif (missingKeys.length > 0) {\n  fail('Missing TASK_TYPE owners in tests/contracts/task-type-catalog.ts', missingKeys)\n}\nif (staleKeys.length > 0) {\n  fail('Stale TASK_TYPE keys in tests/contracts/task-type-catalog.ts', staleKeys)\n}\n\nconsole.log(`[test-tasktype-coverage-guard] OK taskTypes=${taskTypeKeys.length}`)\n"
  },
  {
    "path": "scripts/media-archive-legacy-refs.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { createHash } from 'node:crypto'\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport { prisma } from '@/lib/prisma'\nimport { MEDIA_MODEL_MAPPINGS } from './media-mapping'\n\nconst BACKUP_ROOT = path.join(process.cwd(), 'data', 'migration-backups')\nconst BATCH_SIZE = 500\ntype DynamicModel = {\n  findMany: (args: unknown) => Promise<Array<Record<string, unknown>>>\n  createMany?: (args: unknown) => Promise<unknown>\n}\nconst prismaDynamic = prisma as unknown as Record<string, DynamicModel>\n\nfunction nowStamp() {\n  return new Date().toISOString().replace(/[:.]/g, '-')\n}\n\nfunction checksum(value: string) {\n  return createHash('sha256').update(value).digest('hex')\n}\n\nfunction toSelect(fields: string[]) {\n  const select: Record<string, true> = { id: true }\n  for (const field of fields) select[field] = true\n  return select\n}\n\nasync function main() {\n  const runId = nowStamp()\n  const backupDir = path.join(BACKUP_ROOT, runId)\n  await fs.mkdir(backupDir, { recursive: true })\n\n  const allRows: Array<{\n    runId: string\n    tableName: string\n    rowId: string\n    fieldName: string\n    legacyValue: string\n    checksum: string\n  }> = []\n\n  for (const mapping of MEDIA_MODEL_MAPPINGS) {\n    const model = prismaDynamic[mapping.model]\n    if (!model) continue\n\n    const select = toSelect(mapping.fields.map((f) => f.legacyField))\n    let cursor: string | null = null\n\n    while (true) {\n      const page = await model.findMany({\n        select,\n        ...(cursor\n          ? {\n            cursor: { id: cursor },\n            skip: 1,\n          }\n          : {}),\n        orderBy: { id: 'asc' },\n        take: BATCH_SIZE,\n      })\n      if (!page.length) break\n\n      for (const row of page) {\n        for (const field of mapping.fields) {\n          const value = row[field.legacyField]\n          if (typeof value !== 'string' || !value.trim()) continue\n          allRows.push({\n            runId,\n            tableName: mapping.tableName,\n            rowId: String(row.id),\n            fieldName: field.legacyField,\n            legacyValue: value,\n            checksum: checksum(value),\n          })\n        }\n      }\n\n      cursor = String(page[page.length - 1].id)\n    }\n  }\n\n  if (allRows.length > 0) {\n    try {\n      const backupModel = prismaDynamic.legacyMediaRefBackup\n      if (!backupModel?.createMany) {\n        throw new Error('Prisma model not found: legacyMediaRefBackup')\n      }\n      for (let i = 0; i < allRows.length; i += 1000) {\n        const chunk = allRows.slice(i, i + 1000)\n        await backupModel.createMany({ data: chunk })\n      }\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error)\n      _ulogError('[media-archive-legacy-refs] db backup table unavailable, fallback to file snapshot only', message)\n    }\n  }\n\n  const snapshotPath = path.join(backupDir, 'legacy-media-refs.json')\n  await fs.writeFile(snapshotPath, JSON.stringify(allRows, null, 2), 'utf8')\n  const snapshotHash = checksum(await fs.readFile(snapshotPath, 'utf8'))\n\n  const summary = {\n    runId,\n    createdAt: new Date().toISOString(),\n    backupDir,\n    archivedCount: allRows.length,\n    snapshotFile: path.basename(snapshotPath),\n    snapshotSha256: snapshotHash,\n  }\n\n  await fs.writeFile(path.join(backupDir, 'legacy-media-refs-summary.json'), JSON.stringify(summary, null, 2), 'utf8')\n\n  _ulogInfo(`[media-archive-legacy-refs] runId=${runId}`)\n  _ulogInfo(`[media-archive-legacy-refs] archived=${allRows.length}`)\n  _ulogInfo(`[media-archive-legacy-refs] snapshot=${snapshotPath}`)\n}\n\nmain()\n  .catch((error) => {\n    _ulogError('[media-archive-legacy-refs] failed:', error)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/media-backfill-refs.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { prisma } from '@/lib/prisma'\nimport { resolveMediaRefFromLegacyValue } from '@/lib/media/service'\nimport { MEDIA_MODEL_MAPPINGS } from './media-mapping'\n\nconst BATCH_SIZE = 200\ntype DynamicModel = {\n  findMany: (args: unknown) => Promise<Array<Record<string, unknown>>>\n  update: (args: unknown) => Promise<unknown>\n}\nconst prismaDynamic = prisma as unknown as Record<string, DynamicModel>\n\nfunction toSelect(fields: string[]) {\n  const select: Record<string, true> = { id: true }\n  for (const field of fields) select[field] = true\n  return select\n}\n\nasync function backfillModel(mapping: (typeof MEDIA_MODEL_MAPPINGS)[number]) {\n  const model = prismaDynamic[mapping.model]\n  if (!model) {\n    throw new Error(`Prisma model not found: ${mapping.model}`)\n  }\n\n  const selectFields = mapping.fields.flatMap((f) => [f.legacyField, f.mediaIdField])\n  const select = toSelect(selectFields)\n\n  let cursor: string | null = null\n  let scanned = 0\n  let updated = 0\n\n  try {\n    while (true) {\n      const rows = await model.findMany({\n      select,\n      ...(cursor\n        ? {\n          cursor: { id: cursor },\n          skip: 1,\n        }\n        : {}),\n      orderBy: { id: 'asc' },\n      take: BATCH_SIZE,\n    })\n\n      if (!rows.length) break\n\n      for (const row of rows) {\n        scanned += 1\n        const patch: Record<string, string> = {}\n\n        for (const field of mapping.fields) {\n          const mediaId = row[field.mediaIdField]\n          const legacyValue = row[field.legacyField]\n          if (mediaId || typeof legacyValue !== 'string' || !legacyValue.trim()) {\n            continue\n          }\n\n          const media = await resolveMediaRefFromLegacyValue(legacyValue)\n          if (!media) continue\n          patch[field.mediaIdField] = media.id\n        }\n\n        if (Object.keys(patch).length > 0) {\n          await model.update({\n            where: { id: String(row.id) },\n            data: patch,\n          })\n          updated += 1\n        }\n      }\n\n      cursor = String(rows[rows.length - 1].id)\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    if (message.includes('does not exist') || message.includes('Unknown column')) {\n      _ulogError(\n        `[media-backfill-refs] skip ${mapping.tableName}: migration columns not available yet`,\n        message,\n      )\n      return { scanned: 0, updated: 0, skipped: true }\n    }\n    throw error\n  }\n\n  return { scanned, updated, skipped: false }\n}\n\nasync function main() {\n  const startedAt = new Date()\n  _ulogInfo(`[media-backfill-refs] started at ${startedAt.toISOString()}`)\n\n  let totalScanned = 0\n  let totalUpdated = 0\n\n  for (const mapping of MEDIA_MODEL_MAPPINGS) {\n    const result = await backfillModel(mapping)\n    totalScanned += result.scanned\n    totalUpdated += result.updated\n    if (result.skipped) {\n      _ulogInfo(`[media-backfill-refs] ${mapping.tableName}: skipped (run add-only DB migration first)`)\n    } else {\n      _ulogInfo(\n        `[media-backfill-refs] ${mapping.tableName}: scanned=${result.scanned} updatedRows=${result.updated}`,\n      )\n    }\n  }\n\n  _ulogInfo(\n    `[media-backfill-refs] done scanned=${totalScanned} updatedRows=${totalUpdated} durationMs=${Date.now() - startedAt.getTime()}`,\n  )\n}\n\nmain()\n  .catch((error) => {\n    _ulogError('[media-backfill-refs] failed:', error)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/media-build-unreferenced-index.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport COS from 'cos-nodejs-sdk-v5'\nimport { prisma } from '@/lib/prisma'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { MEDIA_MODEL_MAPPINGS } from './media-mapping'\n\ntype StorageEntry = {\n  key: string\n  sizeBytes: number\n  lastModified: string | null\n}\ntype CosBucketPage = {\n  Contents?: Array<{ Key: string; Size?: string | number; LastModified?: string }>\n  IsTruncated?: string | boolean\n  NextMarker?: string\n}\ntype DynamicModel = {\n  findMany: (args: unknown) => Promise<Array<Record<string, unknown>>>\n}\nconst prismaDynamic = prisma as unknown as Record<string, DynamicModel>\n\nconst BACKUP_ROOT = path.join(process.cwd(), 'data', 'migration-backups')\n\nfunction nowStamp() {\n  return new Date().toISOString().replace(/[:.]/g, '-')\n}\n\nasync function listLocalObjects(): Promise<StorageEntry[]> {\n  const uploadDir = process.env.UPLOAD_DIR || './data/uploads'\n  const rootDir = path.isAbsolute(uploadDir) ? uploadDir : path.join(process.cwd(), uploadDir)\n  const exists = await fs.stat(rootDir).then(() => true).catch(() => false)\n  if (!exists) return []\n\n  const rows: StorageEntry[] = []\n  const queue = ['']\n\n  while (queue.length > 0) {\n    const rel = queue.shift() as string\n    const full = path.join(rootDir, rel)\n    const entries = await fs.readdir(full, { withFileTypes: true })\n    for (const entry of entries) {\n      const childRel = path.join(rel, entry.name)\n      if (entry.isDirectory()) {\n        queue.push(childRel)\n        continue\n      }\n      if (!entry.isFile()) continue\n      const stat = await fs.stat(path.join(rootDir, childRel))\n      rows.push({\n        key: childRel.split(path.sep).join('/'),\n        sizeBytes: stat.size,\n        lastModified: stat.mtime.toISOString(),\n      })\n    }\n  }\n\n  return rows\n}\n\nasync function listCosObjects(): Promise<StorageEntry[]> {\n  const secretId = process.env.COS_SECRET_ID\n  const secretKey = process.env.COS_SECRET_KEY\n  const bucket = process.env.COS_BUCKET\n  const region = process.env.COS_REGION\n\n  if (!secretId || !secretKey || !bucket || !region) {\n    throw new Error('Missing COS env: COS_SECRET_ID/COS_SECRET_KEY/COS_BUCKET/COS_REGION')\n  }\n\n  const cos = new COS({ SecretId: secretId, SecretKey: secretKey, Timeout: 60_000 })\n  const rows: StorageEntry[] = []\n  let marker = ''\n\n  while (true) {\n    const page = await new Promise<CosBucketPage>((resolve, reject) => {\n      cos.getBucket(\n        {\n          Bucket: bucket,\n          Region: region,\n          Marker: marker,\n          MaxKeys: 1000,\n        },\n        (err, data) => (err ? reject(err) : resolve(data as unknown as CosBucketPage)),\n      )\n    })\n\n    const contents = page.Contents || []\n    for (const item of contents) {\n      rows.push({\n        key: item.Key,\n        sizeBytes: Number(item.Size || 0),\n        lastModified: item.LastModified || null,\n      })\n    }\n\n    const truncated = String(page.IsTruncated || 'false') === 'true'\n    if (!truncated) break\n    const nextMarker = typeof page.NextMarker === 'string' ? page.NextMarker : ''\n    marker = nextMarker || (contents.length ? contents[contents.length - 1].Key : '')\n    if (!marker) break\n  }\n\n  return rows\n}\n\nasync function listStorageObjects() {\n  const storageType = process.env.STORAGE_TYPE || 'cos'\n  if (storageType === 'local') {\n    return { storageType, rows: await listLocalObjects() }\n  }\n  return { storageType, rows: await listCosObjects() }\n}\n\nasync function buildReferencedKeySet() {\n  const refs = new Set<string>()\n\n  try {\n    const mediaRows = await prismaDynamic.mediaObject.findMany({\n      select: { storageKey: true },\n    })\n    for (const row of mediaRows) {\n      if (typeof row.storageKey === 'string' && row.storageKey.trim()) refs.add(row.storageKey)\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    _ulogError('[media-build-unreferenced-index] media_objects unavailable, fallback to legacy field scan', message)\n  }\n\n  for (const mapping of MEDIA_MODEL_MAPPINGS) {\n    const model = prismaDynamic[mapping.model]\n    if (!model) continue\n\n    const select: Record<string, true> = { id: true }\n    for (const field of mapping.fields) select[field.legacyField] = true\n\n    let cursor: string | null = null\n    while (true) {\n      const rows = await model.findMany({\n        select,\n        ...(cursor\n          ? {\n            cursor: { id: cursor },\n            skip: 1,\n          }\n          : {}),\n        orderBy: { id: 'asc' },\n        take: 500,\n      })\n      if (!rows.length) break\n\n      for (const row of rows) {\n        for (const field of mapping.fields) {\n          const value = row[field.legacyField]\n          if (typeof value !== 'string' || !value.trim()) continue\n          const key = await resolveStorageKeyFromMediaValue(value)\n          if (key) refs.add(key)\n        }\n      }\n\n      cursor = String(rows[rows.length - 1].id)\n    }\n  }\n\n  return refs\n}\n\nasync function main() {\n  const stamp = nowStamp()\n  const backupDir = path.join(BACKUP_ROOT, stamp)\n  await fs.mkdir(backupDir, { recursive: true })\n\n  const referenced = await buildReferencedKeySet()\n  const storage = await listStorageObjects()\n  const unreferenced = storage.rows.filter((row) => !referenced.has(row.key))\n\n  const output = {\n    createdAt: new Date().toISOString(),\n    storageType: storage.storageType,\n    totalStorageObjects: storage.rows.length,\n    referencedKeyCount: referenced.size,\n    unreferencedCount: unreferenced.length,\n    objects: unreferenced,\n  }\n\n  const filePath = path.join(backupDir, 'unreferenced-storage-objects-index.json')\n  await fs.writeFile(filePath, JSON.stringify(output, null, 2), 'utf8')\n\n  _ulogInfo(`[media-build-unreferenced-index] storageType=${storage.storageType}`)\n  _ulogInfo(`[media-build-unreferenced-index] total=${storage.rows.length} unreferenced=${unreferenced.length}`)\n  _ulogInfo(`[media-build-unreferenced-index] output=${filePath}`)\n}\n\nmain()\n  .catch((error) => {\n    _ulogError('[media-build-unreferenced-index] failed:', error)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/media-mapping.ts",
    "content": "export type MediaFieldMapping = {\n  legacyField: string\n  mediaIdField: string\n}\n\nexport type MediaModelMapping = {\n  model: string\n  tableName: string\n  fields: MediaFieldMapping[]\n}\n\nexport const MEDIA_MODEL_MAPPINGS: MediaModelMapping[] = [\n  {\n    model: 'characterAppearance',\n    tableName: 'character_appearances',\n    fields: [{ legacyField: 'imageUrl', mediaIdField: 'imageMediaId' }],\n  },\n  {\n    model: 'locationImage',\n    tableName: 'location_images',\n    fields: [{ legacyField: 'imageUrl', mediaIdField: 'imageMediaId' }],\n  },\n  {\n    model: 'novelPromotionCharacter',\n    tableName: 'novel_promotion_characters',\n    fields: [{ legacyField: 'customVoiceUrl', mediaIdField: 'customVoiceMediaId' }],\n  },\n  {\n    model: 'novelPromotionEpisode',\n    tableName: 'novel_promotion_episodes',\n    fields: [{ legacyField: 'audioUrl', mediaIdField: 'audioMediaId' }],\n  },\n  {\n    model: 'novelPromotionPanel',\n    tableName: 'novel_promotion_panels',\n    fields: [\n      { legacyField: 'imageUrl', mediaIdField: 'imageMediaId' },\n      { legacyField: 'videoUrl', mediaIdField: 'videoMediaId' },\n      { legacyField: 'lipSyncVideoUrl', mediaIdField: 'lipSyncVideoMediaId' },\n      { legacyField: 'sketchImageUrl', mediaIdField: 'sketchImageMediaId' },\n      { legacyField: 'previousImageUrl', mediaIdField: 'previousImageMediaId' },\n    ],\n  },\n  {\n    model: 'novelPromotionShot',\n    tableName: 'novel_promotion_shots',\n    fields: [{ legacyField: 'imageUrl', mediaIdField: 'imageMediaId' }],\n  },\n  {\n    model: 'supplementaryPanel',\n    tableName: 'supplementary_panels',\n    fields: [{ legacyField: 'imageUrl', mediaIdField: 'imageMediaId' }],\n  },\n  {\n    model: 'novelPromotionVoiceLine',\n    tableName: 'novel_promotion_voice_lines',\n    fields: [{ legacyField: 'audioUrl', mediaIdField: 'audioMediaId' }],\n  },\n  {\n    model: 'voicePreset',\n    tableName: 'voice_presets',\n    fields: [{ legacyField: 'audioUrl', mediaIdField: 'audioMediaId' }],\n  },\n  {\n    model: 'globalCharacter',\n    tableName: 'global_characters',\n    fields: [{ legacyField: 'customVoiceUrl', mediaIdField: 'customVoiceMediaId' }],\n  },\n  {\n    model: 'globalCharacterAppearance',\n    tableName: 'global_character_appearances',\n    fields: [\n      { legacyField: 'imageUrl', mediaIdField: 'imageMediaId' },\n      { legacyField: 'previousImageUrl', mediaIdField: 'previousImageMediaId' },\n    ],\n  },\n  {\n    model: 'globalLocationImage',\n    tableName: 'global_location_images',\n    fields: [\n      { legacyField: 'imageUrl', mediaIdField: 'imageMediaId' },\n      { legacyField: 'previousImageUrl', mediaIdField: 'previousImageMediaId' },\n    ],\n  },\n  {\n    model: 'globalVoice',\n    tableName: 'global_voices',\n    fields: [{ legacyField: 'customVoiceUrl', mediaIdField: 'customVoiceMediaId' }],\n  },\n]\n"
  },
  {
    "path": "scripts/media-restore-dry-run.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport { prisma } from '@/lib/prisma'\n\nconst BACKUP_ROOT = path.join(process.cwd(), 'data', 'migration-backups')\n\ntype CountMap = Record<string, number>\n\nasync function findLatestBackupDir() {\n  const exists = await fs.stat(BACKUP_ROOT).then(() => true).catch(() => false)\n  if (!exists) {\n    throw new Error(`Backup root not found: ${BACKUP_ROOT}`)\n  }\n  const dirs = (await fs.readdir(BACKUP_ROOT, { withFileTypes: true }))\n    .filter((d) => d.isDirectory())\n    .map((d) => d.name)\n    .sort()\n  const validDirs: string[] = []\n  for (const dir of dirs) {\n    const metadataPath = path.join(BACKUP_ROOT, dir, 'metadata.json')\n    const exists = await fs.stat(metadataPath).then(() => true).catch(() => false)\n    if (exists) validDirs.push(dir)\n  }\n\n  if (!validDirs.length) {\n    throw new Error(`No backup directories found in ${BACKUP_ROOT}`)\n  }\n  return path.join(BACKUP_ROOT, validDirs[validDirs.length - 1])\n}\n\nasync function readExpectedCounts(backupDir: string): Promise<CountMap> {\n  const metadataPath = path.join(backupDir, 'metadata.json')\n  const raw = await fs.readFile(metadataPath, 'utf8')\n  const parsed = JSON.parse(raw)\n  return (parsed.tableCounts || {}) as CountMap\n}\n\nasync function currentCounts(): Promise<CountMap> {\n  const entries: Array<[string, string]> = [\n    ['projects', 'projects'],\n    ['novel_promotion_projects', 'novel_promotion_projects'],\n    ['novel_promotion_episodes', 'novel_promotion_episodes'],\n    ['novel_promotion_panels', 'novel_promotion_panels'],\n    ['novel_promotion_voice_lines', 'novel_promotion_voice_lines'],\n    ['global_characters', 'global_characters'],\n    ['global_character_appearances', 'global_character_appearances'],\n    ['global_locations', 'global_locations'],\n    ['global_location_images', 'global_location_images'],\n    ['global_voices', 'global_voices'],\n    ['tasks', 'tasks'],\n    ['task_events', 'task_events'],\n  ]\n\n  const resolved = await Promise.all(entries.map(async ([name, tableName]) => {\n    const rows = (await prisma.$queryRawUnsafe(\n      `SELECT COUNT(*) AS c FROM \\`${tableName}\\``,\n    )) as Array<Record<string, unknown>>\n    const raw = rows[0] || {}\n    const firstValue = Object.values(raw)[0]\n    const count = Number(firstValue || 0)\n    return [name, Number.isFinite(count) ? count : 0] as const\n  }))\n  const out: CountMap = {}\n  for (const [name, count] of resolved) out[name] = count\n  return out\n}\n\nfunction printDiff(expected: CountMap, actual: CountMap) {\n  const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort()\n  let hasDiff = false\n\n  _ulogInfo('table\\texpected\\tactual\\tdelta')\n  for (const key of keys) {\n    const e = expected[key] ?? 0\n    const a = actual[key] ?? 0\n    const d = a - e\n    if (d !== 0) hasDiff = true\n    _ulogInfo(`${key}\\t${e}\\t${a}\\t${d >= 0 ? '+' : ''}${d}`)\n  }\n\n  return hasDiff\n}\n\nasync function main() {\n  const explicit = process.argv.find((arg) => arg.startsWith('--backup='))\n  const backupDir = explicit ? path.resolve(explicit.split('=')[1]) : await findLatestBackupDir()\n\n  _ulogInfo(`[media-restore-dry-run] backupDir=${backupDir}`)\n\n  const expected = await readExpectedCounts(backupDir)\n  const actual = await currentCounts()\n  const hasDiff = printDiff(expected, actual)\n\n  if (hasDiff) {\n    _ulogInfo('[media-restore-dry-run] drift detected (dry-run only, no writes executed).')\n    process.exitCode = 2\n    return\n  }\n\n  _ulogInfo('[media-restore-dry-run] ok: counts match expected snapshot.')\n}\n\nmain()\n  .catch((error) => {\n    _ulogError('[media-restore-dry-run] failed:', error)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/media-safety-backup.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { createHash } from 'node:crypto'\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport COS from 'cos-nodejs-sdk-v5'\nimport { prisma } from '@/lib/prisma'\n\ntype SnapshotTask = {\n  name: string\n  tableName: string\n}\n\ntype StorageIndexRow = {\n  key: string\n  hash: string | null\n  sizeBytes: number\n  lastModified: string | null\n}\n\ntype CosBucketPage = {\n  Contents?: Array<{\n    Key: string\n    ETag?: string\n    Size?: string | number\n    LastModified?: string\n  }>\n  IsTruncated?: string | boolean\n  NextMarker?: string\n}\n\nconst BACKUP_ROOT = path.join(process.cwd(), 'data', 'migration-backups')\n\nfunction nowStamp() {\n  return new Date().toISOString().replace(/[:.]/g, '-')\n}\n\nfunction toJson(value: unknown) {\n  return JSON.stringify(\n    value,\n    (_key, val) => (typeof val === 'bigint' ? String(val) : val),\n    2,\n  )\n}\n\nasync function writeJson(filePath: string, data: unknown) {\n  await fs.writeFile(filePath, toJson(data), 'utf8')\n}\n\nfunction sha256Text(input: string) {\n  return createHash('sha256').update(input).digest('hex')\n}\n\nfunction resolveDatabaseFilePath(databaseUrl: string | undefined): string | null {\n  if (!databaseUrl) return null\n  if (databaseUrl.startsWith('file:')) {\n    const raw = databaseUrl.slice('file:'.length)\n    if (!raw) return null\n    return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw)\n  }\n  return null\n}\n\nasync function listLocalFilesRecursively(rootDir: string, prefix = ''): Promise<StorageIndexRow[]> {\n  const fullDir = path.join(rootDir, prefix)\n  const entries = await fs.readdir(fullDir, { withFileTypes: true })\n  const out: StorageIndexRow[] = []\n\n  for (const entry of entries) {\n    const rel = path.join(prefix, entry.name)\n    if (entry.isDirectory()) {\n      out.push(...(await listLocalFilesRecursively(rootDir, rel)))\n      continue\n    }\n    if (!entry.isFile()) continue\n\n    const filePath = path.join(rootDir, rel)\n    const stat = await fs.stat(filePath)\n    const buf = await fs.readFile(filePath)\n    out.push({\n      key: rel.split(path.sep).join('/'),\n      hash: createHash('sha256').update(buf).digest('hex'),\n      sizeBytes: stat.size,\n      lastModified: stat.mtime.toISOString(),\n    })\n  }\n\n  return out\n}\n\nasync function listCosObjects(): Promise<StorageIndexRow[]> {\n  const secretId = process.env.COS_SECRET_ID\n  const secretKey = process.env.COS_SECRET_KEY\n  const bucket = process.env.COS_BUCKET\n  const region = process.env.COS_REGION\n\n  if (!secretId || !secretKey || !bucket || !region) {\n    throw new Error('Missing COS env: COS_SECRET_ID/COS_SECRET_KEY/COS_BUCKET/COS_REGION')\n  }\n\n  const cos = new COS({ SecretId: secretId, SecretKey: secretKey, Timeout: 60_000 })\n  const out: StorageIndexRow[] = []\n  let marker = ''\n\n  while (true) {\n    const page = await new Promise<CosBucketPage>((resolve, reject) => {\n      cos.getBucket(\n        {\n          Bucket: bucket,\n          Region: region,\n          Marker: marker,\n          MaxKeys: 1000,\n        },\n        (err, data) => (err ? reject(err) : resolve((data || {}) as CosBucketPage)),\n      )\n    })\n\n    const contents = page.Contents || []\n    for (const item of contents) {\n      out.push({\n        key: item.Key,\n        hash: item.ETag ? String(item.ETag).replaceAll('\"', '') : null,\n        sizeBytes: Number(item.Size || 0),\n        lastModified: item.LastModified || null,\n      })\n    }\n\n    const truncated = String(page.IsTruncated || 'false') === 'true'\n    if (!truncated) break\n    marker = page.NextMarker || (contents.length ? contents[contents.length - 1].Key : '')\n    if (!marker) break\n  }\n\n  return out\n}\n\nasync function buildStorageIndex(): Promise<{ storageType: string; rows: StorageIndexRow[] }> {\n  const storageType = process.env.STORAGE_TYPE || 'cos'\n  if (storageType === 'local') {\n    const uploadDir = process.env.UPLOAD_DIR || './data/uploads'\n    const rootDir = path.isAbsolute(uploadDir) ? uploadDir : path.join(process.cwd(), uploadDir)\n    const exists = await fs.stat(rootDir).then(() => true).catch(() => false)\n    if (!exists) {\n      return { storageType, rows: [] }\n    }\n    const rows = await listLocalFilesRecursively(rootDir)\n    return { storageType, rows }\n  }\n\n  const rows = await listCosObjects()\n  return { storageType, rows }\n}\n\nasync function snapshotTables(backupDir: string) {\n  const tasks: SnapshotTask[] = [\n    { name: 'projects', tableName: 'projects' },\n    { name: 'novel_promotion_projects', tableName: 'novel_promotion_projects' },\n    { name: 'novel_promotion_episodes', tableName: 'novel_promotion_episodes' },\n    { name: 'novel_promotion_panels', tableName: 'novel_promotion_panels' },\n    { name: 'novel_promotion_voice_lines', tableName: 'novel_promotion_voice_lines' },\n    { name: 'global_characters', tableName: 'global_characters' },\n    { name: 'global_character_appearances', tableName: 'global_character_appearances' },\n    { name: 'global_locations', tableName: 'global_locations' },\n    { name: 'global_location_images', tableName: 'global_location_images' },\n    { name: 'global_voices', tableName: 'global_voices' },\n    { name: 'tasks', tableName: 'tasks' },\n    { name: 'task_events', tableName: 'task_events' },\n  ]\n\n  const counts: Record<string, number> = {}\n  for (const task of tasks) {\n    const rows = (await prisma.$queryRawUnsafe(`SELECT * FROM \\`${task.tableName}\\``)) as unknown[]\n    counts[task.name] = rows.length\n    await writeJson(path.join(backupDir, `${task.name}.json`), rows)\n  }\n\n  return counts\n}\n\nasync function writeChecksums(backupDir: string) {\n  const files = (await fs.readdir(backupDir)).sort()\n  const sums: Record<string, string> = {}\n\n  for (const file of files) {\n    const filePath = path.join(backupDir, file)\n    const stat = await fs.stat(filePath)\n    if (!stat.isFile()) continue\n    const buf = await fs.readFile(filePath)\n    sums[file] = createHash('sha256').update(buf).digest('hex')\n  }\n\n  await writeJson(path.join(backupDir, 'checksums.json'), sums)\n}\n\nasync function backupDbFile(backupDir: string) {\n  const dbFile = resolveDatabaseFilePath(process.env.DATABASE_URL)\n  if (!dbFile) return null\n\n  const stat = await fs.stat(dbFile).catch(() => null)\n  if (!stat || !stat.isFile()) return null\n\n  const fileName = path.basename(dbFile)\n  const target = path.join(backupDir, `db-file-${fileName}`)\n  await fs.copyFile(dbFile, target)\n  return path.basename(target)\n}\n\nasync function main() {\n  const stamp = nowStamp()\n  const backupDir = path.join(BACKUP_ROOT, stamp)\n  await fs.mkdir(backupDir, { recursive: true })\n\n  const meta: Record<string, unknown> = {\n    createdAt: new Date().toISOString(),\n    backupDir,\n    databaseUrl: process.env.DATABASE_URL || null,\n    storageType: process.env.STORAGE_TYPE || 'cos',\n    nodeEnv: process.env.NODE_ENV || null,\n  }\n\n  const copiedDbFile = await backupDbFile(backupDir)\n  meta.copiedDbFile = copiedDbFile\n\n  const tableCounts = await snapshotTables(backupDir)\n  meta.tableCounts = tableCounts\n\n  const storage = await buildStorageIndex()\n  meta.storageType = storage.storageType\n  meta.storageObjectCount = storage.rows.length\n  await writeJson(path.join(backupDir, 'storage-object-index.json'), storage.rows)\n\n  await writeChecksums(backupDir)\n  meta.metadataChecksum = sha256Text(toJson(meta))\n  await writeJson(path.join(backupDir, 'metadata.json'), meta)\n\n  _ulogInfo(`[media-safety-backup] done: ${backupDir}`)\n  _ulogInfo(`[media-safety-backup] tableCounts=${JSON.stringify(tableCounts)}`)\n  _ulogInfo(`[media-safety-backup] storageObjects=${storage.rows.length}`)\n}\n\nmain()\n  .catch((error) => {\n    _ulogError('[media-safety-backup] failed:', error)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/migrate-cancelled-to-failed.ts",
    "content": "import { prisma } from '@/lib/prisma'\n\nconst OLD_STATUS = 'cancelled'\nconst NEW_STATUS = 'failed'\nconst OLD_EVENT_TYPE = 'task.cancelled'\nconst NEW_EVENT_TYPE = 'task.failed'\nconst MIGRATION_ERROR_CODE = 'USER_CANCELLED'\nconst MIGRATION_ERROR_MESSAGE = '用户已停止任务。'\n\nfunction log(message: string) {\n  process.stdout.write(`${message}\\n`)\n}\n\nfunction logError(message: string) {\n  process.stderr.write(`${message}\\n`)\n}\n\nasync function main() {\n  const totalTasks = await prisma.task.count({\n    where: { status: OLD_STATUS },\n  })\n  const totalEvents = await prisma.taskEvent.count({\n    where: { eventType: OLD_EVENT_TYPE },\n  })\n\n  log(`[migrate-cancelled-to-failed] matched tasks: ${totalTasks}`)\n  log(`[migrate-cancelled-to-failed] matched events: ${totalEvents}`)\n  if (totalTasks === 0 && totalEvents === 0) {\n    log('[migrate-cancelled-to-failed] no rows to migrate')\n    return\n  }\n\n  const taskEmptyMessageResult = await prisma.task.updateMany({\n    where: {\n      status: OLD_STATUS,\n      OR: [{ errorMessage: null }, { errorMessage: '' }],\n    },\n    data: {\n      status: NEW_STATUS,\n      errorCode: MIGRATION_ERROR_CODE,\n      errorMessage: MIGRATION_ERROR_MESSAGE,\n    },\n  })\n\n  const taskResult = await prisma.task.updateMany({\n    where: { status: OLD_STATUS },\n    data: {\n      status: NEW_STATUS,\n      errorCode: MIGRATION_ERROR_CODE,\n    },\n  })\n\n  const eventResult = await prisma.taskEvent.updateMany({\n    where: { eventType: OLD_EVENT_TYPE },\n    data: {\n      eventType: NEW_EVENT_TYPE,\n    },\n  })\n\n  log(`[migrate-cancelled-to-failed] updated tasks (empty message): ${taskEmptyMessageResult.count}`)\n  log(`[migrate-cancelled-to-failed] updated tasks (remaining): ${taskResult.count}`)\n  log(`[migrate-cancelled-to-failed] updated events: ${eventResult.count}`)\n}\n\nmain()\n  .catch((error) => {\n    logError(`[migrate-cancelled-to-failed] failed: ${error instanceof Error ? error.stack || error.message : String(error)}`)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/migrate-image-urls-contract.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { prisma } from '@/lib/prisma'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\n\ntype AppearanceRow = {\n  id: string\n  imageUrls: string | null\n  previousImageUrls: string | null\n}\n\ntype DynamicModel = {\n  findMany: (args: unknown) => Promise<AppearanceRow[]>\n  update: (args: unknown) => Promise<unknown>\n}\n\ntype FieldName = 'imageUrls' | 'previousImageUrls'\n\ntype NormalizeResult = {\n  next: string\n  changed: boolean\n  reason: 'ok' | 'null' | 'invalid_json' | 'not_array' | 'filtered_non_string' | 'normalized_json'\n}\n\ntype ModelStats = {\n  scanned: number\n  updatedRows: number\n  changedFields: number\n  reasons: Record<string, number>\n}\n\nconst BATCH_SIZE = 200\nconst APPLY = process.argv.includes('--apply')\n\nconst MODELS: Array<{ name: string; model: string }> = [\n  { name: 'CharacterAppearance', model: 'characterAppearance' },\n  { name: 'GlobalCharacterAppearance', model: 'globalCharacterAppearance' },\n]\n\nconst prismaDynamic = prisma as unknown as Record<string, DynamicModel>\n\nfunction print(message: string) {\n  process.stdout.write(`${message}\\n`)\n}\n\nfunction normalizeField(raw: string | null): NormalizeResult {\n  if (raw === null) {\n    return {\n      next: encodeImageUrls([]),\n      changed: true,\n      reason: 'null',\n    }\n  }\n\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!Array.isArray(parsed)) {\n      return {\n        next: encodeImageUrls([]),\n        changed: true,\n        reason: 'not_array',\n      }\n    }\n\n    const stringOnly = parsed.filter((item): item is string => typeof item === 'string')\n    const next = encodeImageUrls(stringOnly)\n\n    if (parsed.length !== stringOnly.length) {\n      return {\n        next,\n        changed: true,\n        reason: 'filtered_non_string',\n      }\n    }\n\n    if (raw !== next) {\n      return {\n        next,\n        changed: true,\n        reason: 'normalized_json',\n      }\n    }\n\n    return {\n      next,\n      changed: false,\n      reason: 'ok',\n    }\n  } catch {\n    return {\n      next: encodeImageUrls([]),\n      changed: true,\n      reason: 'invalid_json',\n    }\n  }\n}\n\nasync function migrateModel(modelName: string, modelKey: string) {\n  const model = prismaDynamic[modelKey]\n  if (!model) {\n    throw new Error(`Prisma model not found: ${modelKey}`)\n  }\n\n  const stats: ModelStats = {\n    scanned: 0,\n    updatedRows: 0,\n    changedFields: 0,\n    reasons: {\n      ok: 0,\n      null: 0,\n      invalid_json: 0,\n      not_array: 0,\n      filtered_non_string: 0,\n      normalized_json: 0,\n    },\n  }\n\n  const samples: Array<{ id: string; field: FieldName; reason: NormalizeResult['reason']; before: string | null; after: string }> = []\n\n  let cursor: string | null = null\n\n  while (true) {\n    const rows = await model.findMany({\n      select: {\n        id: true,\n        imageUrls: true,\n        previousImageUrls: true,\n      },\n      ...(cursor\n        ? {\n          cursor: { id: cursor },\n          skip: 1,\n        }\n        : {}),\n      orderBy: { id: 'asc' },\n      take: BATCH_SIZE,\n    })\n\n    if (rows.length === 0) break\n\n    for (const row of rows) {\n      stats.scanned += 1\n\n      const imageUrlsResult = normalizeField(row.imageUrls)\n      const previousImageUrlsResult = normalizeField(row.previousImageUrls)\n\n      stats.reasons[imageUrlsResult.reason] += 1\n      stats.reasons[previousImageUrlsResult.reason] += 1\n\n      const data: Partial<Record<FieldName, string>> = {}\n\n      if (imageUrlsResult.changed) {\n        data.imageUrls = imageUrlsResult.next\n        stats.changedFields += 1\n        if (samples.length < 20) {\n          samples.push({\n            id: row.id,\n            field: 'imageUrls',\n            reason: imageUrlsResult.reason,\n            before: row.imageUrls,\n            after: imageUrlsResult.next,\n          })\n        }\n      }\n\n      if (previousImageUrlsResult.changed) {\n        data.previousImageUrls = previousImageUrlsResult.next\n        stats.changedFields += 1\n        if (samples.length < 20) {\n          samples.push({\n            id: row.id,\n            field: 'previousImageUrls',\n            reason: previousImageUrlsResult.reason,\n            before: row.previousImageUrls,\n            after: previousImageUrlsResult.next,\n          })\n        }\n      }\n\n      if (Object.keys(data).length > 0) {\n        stats.updatedRows += 1\n        if (APPLY) {\n          await model.update({\n            where: { id: row.id },\n            data,\n          })\n        }\n      }\n    }\n\n    cursor = rows[rows.length - 1]?.id || null\n  }\n\n  const summary = `[migrate-image-urls-contract] ${modelName}: scanned=${stats.scanned} updatedRows=${stats.updatedRows} changedFields=${stats.changedFields}`\n  _ulogInfo(summary)\n  print(summary)\n  print(`[migrate-image-urls-contract] ${modelName}: reasons=${JSON.stringify(stats.reasons)}`)\n\n  if (samples.length > 0) {\n    print(`[migrate-image-urls-contract] ${modelName}: sampleChanges=${JSON.stringify(samples, null, 2)}`)\n  }\n\n  return stats\n}\n\nasync function main() {\n  print(`[migrate-image-urls-contract] mode=${APPLY ? 'apply' : 'dry-run'}`)\n\n  const totals = {\n    scanned: 0,\n    updatedRows: 0,\n    changedFields: 0,\n  }\n\n  for (const target of MODELS) {\n    const stats = await migrateModel(target.name, target.model)\n    totals.scanned += stats.scanned\n    totals.updatedRows += stats.updatedRows\n    totals.changedFields += stats.changedFields\n  }\n\n  print(`[migrate-image-urls-contract] done scanned=${totals.scanned} updatedRows=${totals.updatedRows} changedFields=${totals.changedFields} mode=${APPLY ? 'apply' : 'dry-run'}`)\n}\n\nmain()\n  .catch((error) => {\n    _ulogError('[migrate-image-urls-contract] failed:', error)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/migrate-local-to-minio.ts",
    "content": "#!/usr/bin/env npx tsx\n/**\n * 本地存储 → MinIO 迁移脚本\n * 使用 @aws-sdk/client-s3（项目已有依赖）\n * \n * 用法: npx tsx scripts/migrate-local-to-minio.ts\n */\n\nimport { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'\nimport * as fs from 'fs/promises'\nimport * as path from 'path'\nimport { createReadStream } from 'fs'\n\n// ==================== 配置 ====================\nconst LOCAL_DIR = process.env.LOCAL_UPLOAD_DIR || './data/uploads'\nconst MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'http://127.0.0.1:19000'\nconst MINIO_BUCKET = process.env.MINIO_BUCKET || 'waoowaoo'\nconst MINIO_REGION = process.env.MINIO_REGION || 'us-east-1'\nconst MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'minioadmin'\nconst MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'minioadmin'\nconst CONCURRENCY = parseInt(process.env.MIGRATE_CONCURRENCY || '10')\nconst DRY_RUN = process.env.MIGRATE_DRY_RUN === 'true'\n\n// ==================== S3 客户端 ====================\nconst s3 = new S3Client({\n    endpoint: MINIO_ENDPOINT,\n    region: MINIO_REGION,\n    forcePathStyle: true,\n    credentials: {\n        accessKeyId: MINIO_ACCESS_KEY,\n        secretAccessKey: MINIO_SECRET_KEY,\n    },\n})\n\n// ==================== 工具函数 ====================\nfunction guessContentType(filename: string): string {\n    const ext = path.extname(filename).toLowerCase()\n    const types: Record<string, string> = {\n        '.jpg': 'image/jpeg',\n        '.jpeg': 'image/jpeg',\n        '.png': 'image/png',\n        '.gif': 'image/gif',\n        '.webp': 'image/webp',\n        '.mp4': 'video/mp4',\n        '.webm': 'video/webm',\n        '.mp3': 'audio/mpeg',\n        '.wav': 'audio/wav',\n        '.ogg': 'audio/ogg',\n        '.json': 'application/json',\n        '.txt': 'text/plain',\n    }\n    return types[ext] || 'application/octet-stream'\n}\n\nfunction formatBytes(bytes: number): string {\n    if (bytes === 0) return '0 B'\n    const k = 1024\n    const sizes = ['B', 'KB', 'MB', 'GB']\n    const i = Math.floor(Math.log(bytes) / Math.log(k))\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]\n}\n\n// ==================== 扫描本地文件 ====================\nasync function scanLocalFiles(dir: string, basePath = ''): Promise<Array<{ localPath: string; key: string; size: number }>> {\n    const files: Array<{ localPath: string; key: string; size: number }> = []\n\n    try {\n        const entries = await fs.readdir(dir, { withFileTypes: true })\n        for (const entry of entries) {\n            const fullPath = path.join(dir, entry.name)\n            const relativePath = path.join(basePath, entry.name)\n\n            if (entry.isDirectory()) {\n                files.push(...await scanLocalFiles(fullPath, relativePath))\n            } else {\n                // 跳过隐藏文件\n                if (entry.name.startsWith('.')) continue\n                const stats = await fs.stat(fullPath)\n                files.push({\n                    localPath: fullPath,\n                    key: relativePath.replace(/\\\\/g, '/'),\n                    size: stats.size,\n                })\n            }\n        }\n    } catch (err: unknown) {\n        console.error(`  ⚠️ 无法读取目录: ${dir}`, (err as Error).message)\n    }\n\n    return files\n}\n\n// ==================== 检查文件是否已存在 ====================\nasync function objectExists(key: string): Promise<boolean> {\n    try {\n        await s3.send(new HeadObjectCommand({ Bucket: MINIO_BUCKET, Key: key }))\n        return true\n    } catch {\n        return false\n    }\n}\n\n// ==================== 上传文件 ====================\nasync function uploadFile(file: { localPath: string; key: string; size: number }): Promise<'success' | 'skipped' | 'error'> {\n    // 检查是否已存在\n    if (await objectExists(file.key)) {\n        return 'skipped'\n    }\n\n    if (DRY_RUN) {\n        console.log(`  [DRY RUN] 将上传: ${file.key} (${formatBytes(file.size)})`)\n        return 'skipped'\n    }\n\n    try {\n        const body = await fs.readFile(file.localPath)\n        await s3.send(new PutObjectCommand({\n            Bucket: MINIO_BUCKET,\n            Key: file.key,\n            Body: body,\n            ContentType: guessContentType(file.key),\n        }))\n        return 'success'\n    } catch (err: unknown) {\n        console.error(`  ✗ 上传失败: ${file.key}`, (err as Error).message)\n        return 'error'\n    }\n}\n\n// ==================== 并行控制 ====================\nasync function runBatched<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>) {\n    for (let i = 0; i < items.length; i += concurrency) {\n        const batch = items.slice(i, i + concurrency)\n        await Promise.all(batch.map(fn))\n    }\n}\n\n// ==================== 主流程 ====================\nasync function main() {\n    console.log()\n    console.log('╔══════════════════════════════════════════════════════╗')\n    console.log('║      Local Storage → MinIO Migration Tool           ║')\n    console.log('╚══════════════════════════════════════════════════════╝')\n    console.log()\n    console.log(`  📂 源目录:    ${path.resolve(LOCAL_DIR)}`)\n    console.log(`  🪣 目标桶:    ${MINIO_ENDPOINT}/${MINIO_BUCKET}`)\n    console.log(`  ⚡ 并发数:    ${CONCURRENCY}`)\n    console.log(`  🔍 干运行:    ${DRY_RUN}`)\n    console.log()\n\n    // 1. 扫描文件\n    console.log('📦 扫描本地文件...')\n    const files = await scanLocalFiles(LOCAL_DIR)\n\n    if (files.length === 0) {\n        console.log('  没有需要迁移的文件')\n        return\n    }\n\n    const totalSize = files.reduce((sum, f) => sum + f.size, 0)\n    console.log(`  找到 ${files.length} 个文件, 总大小: ${formatBytes(totalSize)}`)\n    console.log()\n\n    // 2. 开始上传\n    console.log('🚀 开始迁移...')\n    const startTime = Date.now()\n    let success = 0\n    let skipped = 0\n    let failed = 0\n    let processed = 0\n\n    await runBatched(files, CONCURRENCY, async (file) => {\n        const result = await uploadFile(file)\n        processed++\n\n        if (result === 'success') {\n            success++\n            if (success % 50 === 0 || success <= 5) {\n                console.log(`  ✓ [${processed}/${files.length}] ${file.key} (${formatBytes(file.size)})`)\n            }\n        } else if (result === 'skipped') {\n            skipped++\n        } else {\n            failed++\n        }\n\n        if (processed % 100 === 0) {\n            const pct = ((processed / files.length) * 100).toFixed(1)\n            console.log(`  📊 进度: ${pct}% (${processed}/${files.length}) | ✓${success} ⏭${skipped} ✗${failed}`)\n        }\n    })\n\n    // 3. 结果\n    const duration = ((Date.now() - startTime) / 1000).toFixed(1)\n    console.log()\n    console.log('╔══════════════════════════════════════════════════════╗')\n    console.log('║                    迁移完成                          ║')\n    console.log('╠══════════════════════════════════════════════════════╣')\n    console.log(`║  总文件:  ${String(files.length).padEnd(40)} ║`)\n    console.log(`║  成功:    ${String(success).padEnd(40)} ║`)\n    console.log(`║  跳过:    ${String(skipped).padEnd(40)} ║`)\n    console.log(`║  失败:    ${String(failed).padEnd(40)} ║`)\n    console.log(`║  耗时:    ${String(duration + 's').padEnd(40)} ║`)\n    console.log(`║  大小:    ${formatBytes(totalSize).padEnd(40)} ║`)\n    console.log('╚══════════════════════════════════════════════════════╝')\n\n    if (failed > 0) {\n        console.log()\n        console.log('⚠️  有文件上传失败，请重新运行脚本（已上传的会自动跳过）')\n        process.exit(1)\n    }\n}\n\nmain().catch(err => {\n    console.error('迁移失败:', err)\n    process.exit(1)\n})\n"
  },
  {
    "path": "scripts/migrate-to-minio.sh",
    "content": "#!/bin/bash\n#\n# 存储迁移快捷脚本\n# 用法: ./scripts/migrate-to-minio.sh [选项]\n#\n\nset -e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJECT_DIR=\"$(dirname \"$SCRIPT_DIR\")\"\ncd \"$PROJECT_DIR\"\n\n# 颜色输出\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\necho -e \"${GREEN}══════════════════════════════════════════════════════════${NC}\"\necho -e \"${GREEN}     Local Storage → MinIO Migration Tool${NC}\"\necho -e \"${GREEN}══════════════════════════════════════════════════════════${NC}\"\necho\n\n# 检查 MinIO 是否运行\nif ! curl -sf http://127.0.0.1:19000/minio/health/live >/devdev/null 2>&1; then\n    echo -e \"${YELLOW}⚠ MinIO 未检测到在 127.0.0.1:19000${NC}\"\n    echo \"  请先启动 MinIO: docker compose up -d minio\"\n    echo\n    read -p \"是否尝试自动启动 MinIO? [Y/n] \" -n 1 -r\n    echo\n    if [[ ! $REPLY =~ ^[Nn]$ ]]; then\n        docker compose up -d minio\n        echo -e \"${GREEN}✓ MinIO 启动中，等待 5 秒...${NC}\"\n        sleep 5\n    else\n        exit 1\n    fi\nfi\n\necho -e \"${GREEN}✓ MinIO 服务正常${NC}\"\necho\n\n# 检查本地数据目录\nif [ ! -d \"./data/uploads\" ]; then\n    echo -e \"${YELLOW}⚠ 本地数据目录 ./data/uploads 不存在${NC}\"\n    echo \"  无需迁移\"\n    exit 0\nfi\n\nFILE_COUNT=$(find ./data/uploads -type f 2>/dev/null | wc -l)\nif [ \"$FILE_COUNT\" -eq 0 ]; then\n    echo -e \"${YELLOW}⚠ 本地数据目录为空${NC}\"\n    echo \"  无需迁移\"\n    exit 0\nfi\n\necho \"本地文件数: $FILE_COUNT\"\necho\n\n# 运行干运行模式预览\necho -e \"${YELLOW}▶ 干运行预览 (Dry Run)...${NC}\"\nMIGRATE_DRY_RUN=true npx tsx scripts/migrate-to-minio.ts\necho\n\n# 确认执行\nread -p \"是否开始实际迁移? [y/N] \" -n 1 -r\necho\nif [[ ! $REPLY =~ ^[Yy]$ ]]; then\n    echo -e \"${YELLOW}已取消迁移${NC}\"\n    exit 0\nfi\n\necho\necho -e \"${GREEN}▶ 开始迁移...${NC}\"\nnpx tsx scripts/migrate-to-minio.ts\n\nif [ $? -eq 0 ]; then\n    echo\n    echo -e \"${GREEN}══════════════════════════════════════════════════════════${NC}\"\n    echo -e \"${GREEN}              迁移成功完成!${NC}\"\n    echo -e \"${GREEN}══════════════════════════════════════════════════════════${NC}\"\n    echo\n    echo \"后续步骤:\"\n    echo \"  1. 验证 MinIO 控制台: http://127.0.0.1:19001\"\n    echo \"     账号: minioadmin / minioadmin\"\n    echo \"  2. 更新 .env: STORAGE_TYPE=minio\"\n    echo \"  3. 重启应用: docker compose restart app\"\n    echo \"  4. 测试图片/视频访问\"\n    echo \"  5. 确认无误后删除本地数据: rm -rf ./data/uploads\"\n    echo\nelse\n    echo\n    echo -e \"${RED}══════════════════════════════════════════════════════════${NC}\"\n    echo -e \"${RED}                迁移失败${NC}\"\n    echo -e \"${RED}══════════════════════════════════════════════════════════${NC}\"\n    echo\n    echo \"可重新运行继续迁移:\"\n    echo \"  ./scripts/migrate-to-minio.sh\"\n    exit 1\nfi\n"
  },
  {
    "path": "scripts/migrate-to-minio.ts",
    "content": "#!/usr/bin/env node\n/**\n * 存储迁移脚本: Local → MinIO\n * \n * 用途: 将本地文件存储的数据无缝迁移到 MinIO 对象存储\n * 特点:\n * - 断点续传（记录已迁移文件）\n * - 校验和验证\n * - 原子性操作（失败可回滚）\n * - 并行上传加速\n */\n\nimport { Client as MinioClient } from 'minio'\nimport * as fs from 'fs/promises'\nimport * as path from 'path'\nimport { createHash } from 'crypto'\nimport { createReadStream } from 'fs'\n\n// ==================== 配置 ====================\nconst CONFIG = {\n  // 源: 本地存储\n  local: {\n    baseDir: process.env.LOCAL_UPLOAD_DIR || './data/uploads',\n  },\n  // 目标: MinIO\n  minio: {\n    endPoint: process.env.MINIO_ENDPOINT?.replace(/^https?:\\/\\//, '') || '127.0.0.1',\n    port: parseInt(process.env.MINIO_PORT || '9000'),\n    useSSL: process.env.MINIO_USE_SSL === 'true',\n    accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',\n    secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',\n    bucket: process.env.MINIO_BUCKET || 'waoowaoo',\n    region: process.env.MINIO_REGION || 'us-east-1',\n    forcePathStyle: process.env.MINIO_FORCE_PATH_STYLE !== 'false',\n  },\n  // 迁移选项\n  options: {\n    concurrency: parseInt(process.env.MIGRATE_CONCURRENCY || '5'),\n    dryRun: process.env.MIGRATE_DRY_RUN === 'true',\n    resume: process.env.MIGRATE_RESUME !== 'false',\n    progressFile: process.env.MIGRATE_PROGRESS_FILE || './scripts/.migrate-progress.json',\n    logLevel: process.env.MIGRATE_LOG_LEVEL || 'info', // debug, info, warn, error\n  }\n}\n\n// ==================== 日志 ====================\nconst LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }\nfunction log(level: string, message: string, ...args: unknown[]) {\n  if (LOG_LEVELS[level as keyof typeof LOG_LEVELS] >= LOG_LEVELS[CONFIG.options.logLevel as keyof typeof LOG_LEVELS]) {\n    const timestamp = new Date().toISOString()\n    console[level === 'error' ? 'error' : 'log'](`[${timestamp}] [${level.toUpperCase()}] ${message}`, ...args)\n  }\n}\n\n// ==================== MinIO 客户端 ====================\nconst minioClient = new MinioClient({\n  endPoint: CONFIG.minio.endPoint,\n  port: CONFIG.minio.port,\n  useSSL: CONFIG.minio.useSSL,\n  accessKey: CONFIG.minio.accessKey,\n  secretKey: CONFIG.minio.secretKey,\n  region: CONFIG.minio.region,\n})\n\n// ==================== 文件扫描 ====================\nasync function scanLocalFiles(dir: string, basePath = ''): Promise<Array<{localPath: string, key: string, size: number, mtime: Date}>> {\n  const files: Array<{localPath: string, key: string, size: number, mtime: Date}> = []\n  \n  try {\n    const entries = await fs.readdir(dir, { withFileTypes: true })\n    \n    for (const entry of entries) {\n      const fullPath = path.join(dir, entry.name)\n      const relativePath = path.join(basePath, entry.name)\n      \n      if (entry.isDirectory()) {\n        const subFiles = await scanLocalFiles(fullPath, relativePath)\n        files.push(...subFiles)\n      } else {\n        const stats = await fs.stat(fullPath)\n        files.push({\n          localPath: fullPath,\n          key: relativePath.replace(/\\\\/g, '/'), // 统一使用正斜杠\n          size: stats.size,\n          mtime: stats.mtime,\n        })\n      }\n    }\n  } catch (err: unknown) {\n    log('warn', `无法读取目录: ${dir}`, (err as Error).message)\n  }\n  \n  return files\n}\n\n// ==================== 校验和 ====================\nasync function calculateHash(filePath: string): Promise<string> {\n  return new Promise((resolve, reject) => {\n    const hash = createHash('md5')\n    const stream = createReadStream(filePath)\n    \n    stream.on('data', chunk => hash.update(chunk))\n    stream.on('end', () => resolve(hash.digest('hex')))\n    stream.on('error', reject)\n  })\n}\n\n// ==================== 进度管理 ====================\nasync function loadProgress(): Promise<Set<string>> {\n  try {\n    if (!CONFIG.options.resume) {\n      return new Set()\n    }\n    const data = await fs.readFile(CONFIG.options.progressFile, 'utf-8')\n    const progress = JSON.parse(data)\n    return new Set(progress.migrated || [])\n  } catch {\n    return new Set()\n  }\n}\n\nasync function saveProgress(migratedKeys: Set<string>) {\n  const progress = {\n    updatedAt: new Date().toISOString(),\n    migrated: Array.from(migratedKeys),\n  }\n  await fs.writeFile(CONFIG.options.progressFile, JSON.stringify(progress, null, 2))\n}\n\n// ==================== 存储桶检查/创建 ====================\nasync function ensureBucket() {\n  log('info', `检查存储桶: ${CONFIG.minio.bucket}`)\n  \n  const exists = await minioClient.bucketExists(CONFIG.minio.bucket)\n  if (!exists) {\n    log('info', `创建存储桶: ${CONFIG.minio.bucket}`)\n    await minioClient.makeBucket(CONFIG.minio.bucket, CONFIG.minio.region)\n    \n    // 设置存储桶为 public read (可选，根据需求)\n    const policy = {\n      Version: '2012-10-17',\n      Statement: [\n        {\n          Effect: 'Allow',\n          Principal: { AWS: ['*'] },\n          Action: ['s3:GetObject'],\n          Resource: [`arn:aws:s3:::${CONFIG.minio.bucket}/*`]\n        }\n      ]\n    }\n    await minioClient.setBucketPolicy(CONFIG.minio.bucket, JSON.stringify(policy))\n    log('info', '存储桶访问策略已设置为公开读取')\n  }\n}\n\n// ==================== 文件上传 ====================\nasync function uploadFile(fileInfo: {localPath: string, key: string, size: number}, migratedKeys: Set<string>): Promise<{status: string, key: string, size?: number, error?: string}> {\n  const { localPath, key, size } = fileInfo\n  \n  // 检查是否已迁移\n  if (migratedKeys.has(key)) {\n    log('debug', `跳过已迁移: ${key}`)\n    return { status: 'skipped', key }\n  }\n  \n  if (CONFIG.options.dryRun) {\n    log('info', `[DRY RUN] 将上传: ${key} (${formatBytes(size)})`)\n    return { status: 'dry_run', key }\n  }\n  \n  try {\n    // 计算本地文件 MD5\n    const localHash = await calculateHash(localPath)\n    \n    // 上传文件\n    const fileStream = createReadStream(localPath)\n    await minioClient.putObject(CONFIG.minio.bucket, key, fileStream, size, {\n      'Content-Type': guessContentType(key),\n      'X-Amz-Meta-Original-Hash': localHash,\n    })\n    \n    // 验证上传\n    const stat = await minioClient.statObject(CONFIG.minio.bucket, key)\n    \n    // 记录迁移成功\n    migratedKeys.add(key)\n    \n    log('info', `✓ 上传成功: ${key} (${formatBytes(size)})`)\n    return { status: 'success', key, size }\n    \n  } catch (err: unknown) {\n    log('error', `✗ 上传失败: ${key}`, (err as Error).message)\n    return { status: 'error', key, error: (err as Error).message }\n  }\n}\n\n// ==================== 内容类型猜测 ====================\nfunction guessContentType(filename: string): string {\n  const ext = path.extname(filename).toLowerCase()\n  const types: Record<string, string> = {\n    '.jpg': 'image/jpeg',\n    '.jpeg': 'image/jpeg',\n    '.png': 'image/png',\n    '.gif': 'image/gif',\n    '.webp': 'image/webp',\n    '.mp4': 'video/mp4',\n    '.webm': 'video/webm',\n    '.mp3': 'audio/mpeg',\n    '.wav': 'audio/wav',\n    '.json': 'application/json',\n    '.txt': 'text/plain',\n  }\n  return types[ext] || 'application/octet-stream'\n}\n\n// ==================== 字节格式化 ====================\nfunction formatBytes(bytes: number): string {\n  if (bytes === 0) return '0 B'\n  const k = 1024\n  const sizes = ['B', 'KB', 'MB', 'GB', 'TB']\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]\n}\n\n// ==================== 并行任务控制 ====================\nasync function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, concurrency: number): Promise<T[]> {\n  const results: T[] = []\n  const executing: Promise<void>[] = []\n  \n  for (const task of tasks) {\n    const promise = task().then(result => {\n      results.push(result)\n    })\n    executing.push(promise)\n    \n    if (executing.length >= concurrency) {\n      await Promise.race(executing)\n      executing.splice(executing.findIndex(p => p === promise), 1)\n    }\n  }\n  \n  await Promise.all(executing)\n  return results\n}\n\n// ==================== 主流程 ====================\nasync function main() {\n  console.log('╔══════════════════════════════════════════════════════════╗')\n  console.log('║         Local Storage → MinIO Migration Tool             ║')\n  console.log('╚══════════════════════════════════════════════════════════╝')\n  console.log()\n  \n  log('info', '配置信息:')\n  log('info', `  本地目录: ${path.resolve(CONFIG.local.baseDir)}`)\n  log('info', `  MinIO: ${CONFIG.minio.endPoint}:${CONFIG.minio.port}/${CONFIG.minio.bucket}`)\n  log('info', `  并发数: ${CONFIG.options.concurrency}`)\n  log('info', `  干运行: ${CONFIG.options.dryRun}`)\n  log('info', `  断点续传: ${CONFIG.options.resume}`)\n  console.log()\n  \n  // 1. 扫描本地文件\n  log('info', '扫描本地文件...')\n  const files = await scanLocalFiles(CONFIG.local.baseDir)\n  log('info', `找到 ${files.length} 个文件`)\n  \n  if (files.length === 0) {\n    log('info', '没有需要迁移的文件')\n    return\n  }\n  \n  const totalSize = files.reduce((sum, f) => sum + f.size, 0)\n  log('info', `总大小: ${formatBytes(totalSize)}`)\n  console.log()\n  \n  // 2. 加载进度\n  const migratedKeys = await loadProgress()\n  log('info', `已迁移: ${migratedKeys.size} 个文件`)\n  \n  // 3. 确保存储桶存在\n  await ensureBucket()\n  \n  // 4. 执行迁移\n  const startTime = Date.now()\n  let processed = 0\n  let success = 0\n  let failed = 0\n  let skipped = 0\n  \n  const uploadTasks = files.map(file => async () => {\n    const result = await uploadFile(file, migratedKeys)\n    processed++\n    \n    if (result.status === 'success') success++\n    else if (result.status === 'error') failed++\n    else if (result.status === 'skipped') skipped++\n    \n    // 每 10 个文件保存一次进度\n    if (processed % 10 === 0) {\n      await saveProgress(migratedKeys)\n      const progress = ((processed / files.length) * 100).toFixed(1)\n      log('info', `进度: ${progress}% (${processed}/${files.length})`)\n    }\n    \n    return result\n  })\n  \n  await runWithConcurrency(uploadTasks, CONFIG.options.concurrency)\n  \n  // 5. 保存最终进度\n  await saveProgress(migratedKeys)\n  \n  // 6. 报告\n  const duration = ((Date.now() - startTime) / 1000).toFixed(1)\n  console.log()\n  console.log('╔══════════════════════════════════════════════════════════╗')\n  console.log('║                      迁移完成                            ║')\n  console.log('╠══════════════════════════════════════════════════════════╣')\n  console.log(`║ 总文件数:    ${String(files.length).padEnd(39)} ║`)\n  console.log(`║ 成功:        ${String(success).padEnd(39)} ║`)\n  console.log(`║ 失败:        ${String(failed).padEnd(39)} ║`)\n  console.log(`║ 跳过:        ${String(skipped).padEnd(39)} ║`)\n  console.log(`║ 耗时:        ${String(duration + 's').padEnd(39)} ║`)\n  console.log('╚══════════════════════════════════════════════════════════╝')\n  \n  // 7. 后续步骤提示\n  console.log()\n  console.log('📋 后续步骤:')\n  console.log('  1. 验证 MinIO 中的文件: mc ls local/waoowaoo')\n  console.log('  2. 更新 .env: STORAGE_TYPE=minio')\n  console.log('  3. 重启应用: docker compose restart app')\n  console.log('  4. 测试图片/视频访问是否正常')\n  console.log('  5. 确认无误后可删除本地文件: rm -rf ./data/uploads')\n  \n  if (failed > 0) {\n    process.exit(1)\n  }\n}\n\n// 运行\nmain().catch(err => {\n  log('error', '迁移失败:', err)\n  process.exit(1)\n})\n"
  },
  {
    "path": "scripts/migrations/migrate-capability-selections.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport {\n  parseModelKeyStrict,\n  type CapabilitySelections,\n  type CapabilityValue,\n} from '@/lib/model-config-contract'\nimport { findBuiltinCapabilities } from '@/lib/model-capabilities/catalog'\n\nconst APPLY = process.argv.includes('--apply')\n\nconst USER_IMAGE_MODEL_FIELDS = [\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n] as const\n\nconst PROJECT_IMAGE_MODEL_FIELDS = [\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n] as const\n\ntype UserImageModelField = typeof USER_IMAGE_MODEL_FIELDS[number]\ntype ProjectImageModelField = typeof PROJECT_IMAGE_MODEL_FIELDS[number]\n\ninterface UserPreferenceRow {\n  id: string\n  userId: string\n  imageResolution: string\n  capabilityDefaults: string | null\n  characterModel: string | null\n  locationModel: string | null\n  storyboardModel: string | null\n  editModel: string | null\n}\n\ninterface ProjectRow {\n  id: string\n  projectId: string\n  imageResolution: string\n  videoResolution: string\n  capabilityOverrides: string | null\n  characterModel: string | null\n  locationModel: string | null\n  storyboardModel: string | null\n  editModel: string | null\n  videoModel: string | null\n}\n\ninterface MigrationSummary {\n  mode: 'dry-run' | 'apply'\n  userPreference: {\n    scanned: number\n    updated: number\n    migratedImageResolution: number\n  }\n  novelPromotionProject: {\n    scanned: number\n    updated: number\n    migratedImageResolution: number\n    migratedVideoResolution: number\n  }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isCapabilityValue(value: unknown): value is CapabilityValue {\n  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction normalizeSelections(raw: unknown): CapabilitySelections {\n  if (!isRecord(raw)) return {}\n\n  const normalized: CapabilitySelections = {}\n  for (const [modelKey, rawSelection] of Object.entries(raw)) {\n    if (!isRecord(rawSelection)) continue\n\n    const nextSelection: Record<string, CapabilityValue> = {}\n    for (const [field, value] of Object.entries(rawSelection)) {\n      if (isCapabilityValue(value)) {\n        nextSelection[field] = value\n      }\n    }\n\n    normalized[modelKey] = nextSelection\n  }\n\n  return normalized\n}\n\nfunction parseSelections(raw: string | null): CapabilitySelections {\n  if (!raw) return {}\n  try {\n    return normalizeSelections(JSON.parse(raw) as unknown)\n  } catch {\n    return {}\n  }\n}\n\nfunction serializeSelections(selections: CapabilitySelections): string | null {\n  if (Object.keys(selections).length === 0) return null\n  return JSON.stringify(selections)\n}\n\nfunction getCapabilityResolutionOptions(\n  modelType: 'image' | 'video',\n  modelKey: string,\n): string[] {\n  const parsed = parseModelKeyStrict(modelKey)\n  if (!parsed) return []\n\n  const capabilities = findBuiltinCapabilities(modelType, parsed.provider, parsed.modelId)\n  const namespace = capabilities?.[modelType]\n  if (!namespace || !isRecord(namespace)) return []\n\n  const resolutionOptions = namespace.resolutionOptions\n  if (!Array.isArray(resolutionOptions)) return []\n\n  return resolutionOptions.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)\n}\n\nfunction ensureModelResolutionSelection(input: {\n  modelType: 'image' | 'video'\n  modelKey: string\n  resolution: string\n  selections: CapabilitySelections\n}): boolean {\n  const options = getCapabilityResolutionOptions(input.modelType, input.modelKey)\n  if (options.length === 0) return false\n  if (!options.includes(input.resolution)) return false\n\n  const current = input.selections[input.modelKey]\n  if (current && current.resolution !== undefined) {\n    return false\n  }\n\n  input.selections[input.modelKey] = {\n    ...(current || {}),\n    resolution: input.resolution,\n  }\n  return true\n}\n\nfunction collectModelKeys<RowType>(\n  row: RowType,\n  fields: readonly (keyof RowType)[],\n): string[] {\n  const modelKeys: string[] = []\n  for (const field of fields) {\n    const value = row[field]\n    if (typeof value !== 'string') continue\n    const trimmed = value.trim()\n    if (!trimmed) continue\n    modelKeys.push(trimmed)\n  }\n  return modelKeys\n}\n\nasync function migrateUserPreferences(summary: MigrationSummary) {\n  const rows = await prisma.userPreference.findMany({\n    select: {\n      id: true,\n      userId: true,\n      imageResolution: true,\n      capabilityDefaults: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n    },\n  }) as UserPreferenceRow[]\n\n  summary.userPreference.scanned = rows.length\n\n  for (const row of rows) {\n    const nextSelections = parseSelections(row.capabilityDefaults)\n    const modelKeys = collectModelKeys<UserPreferenceRow>(row, USER_IMAGE_MODEL_FIELDS)\n    let changed = false\n\n    for (const modelKey of modelKeys) {\n      if (ensureModelResolutionSelection({\n        modelType: 'image',\n        modelKey,\n        resolution: row.imageResolution,\n        selections: nextSelections,\n      })) {\n        changed = true\n        summary.userPreference.migratedImageResolution += 1\n      }\n    }\n\n    if (!changed) continue\n    summary.userPreference.updated += 1\n\n    if (APPLY) {\n      await prisma.userPreference.update({\n        where: { id: row.id },\n        data: {\n          capabilityDefaults: serializeSelections(nextSelections),\n        },\n      })\n    }\n  }\n}\n\nasync function migrateProjects(summary: MigrationSummary) {\n  const rows = await prisma.novelPromotionProject.findMany({\n    select: {\n      id: true,\n      projectId: true,\n      imageResolution: true,\n      videoResolution: true,\n      capabilityOverrides: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n    },\n  }) as ProjectRow[]\n\n  summary.novelPromotionProject.scanned = rows.length\n\n  for (const row of rows) {\n    const nextSelections = parseSelections(row.capabilityOverrides)\n    const imageModelKeys = collectModelKeys<ProjectRow>(row, PROJECT_IMAGE_MODEL_FIELDS)\n    let changed = false\n\n    for (const modelKey of imageModelKeys) {\n      if (ensureModelResolutionSelection({\n        modelType: 'image',\n        modelKey,\n        resolution: row.imageResolution,\n        selections: nextSelections,\n      })) {\n        changed = true\n        summary.novelPromotionProject.migratedImageResolution += 1\n      }\n    }\n\n    if (typeof row.videoModel === 'string' && row.videoModel.trim()) {\n      if (ensureModelResolutionSelection({\n        modelType: 'video',\n        modelKey: row.videoModel.trim(),\n        resolution: row.videoResolution,\n        selections: nextSelections,\n      })) {\n        changed = true\n        summary.novelPromotionProject.migratedVideoResolution += 1\n      }\n    }\n\n    if (!changed) continue\n    summary.novelPromotionProject.updated += 1\n\n    if (APPLY) {\n      await prisma.novelPromotionProject.update({\n        where: { id: row.id },\n        data: {\n          capabilityOverrides: serializeSelections(nextSelections),\n        },\n      })\n    }\n  }\n}\n\nasync function main() {\n  const summary: MigrationSummary = {\n    mode: APPLY ? 'apply' : 'dry-run',\n    userPreference: {\n      scanned: 0,\n      updated: 0,\n      migratedImageResolution: 0,\n    },\n    novelPromotionProject: {\n      scanned: 0,\n      updated: 0,\n      migratedImageResolution: 0,\n      migratedVideoResolution: 0,\n    },\n  }\n\n  await migrateUserPreferences(summary)\n  await migrateProjects(summary)\n\n  process.stdout.write(`${JSON.stringify(summary, null, 2)}\\n`)\n}\n\nmain()\n  .catch((error: unknown) => {\n    const message = error instanceof Error ? error.message : String(error)\n    const missingColumn =\n      message.includes('capabilityDefaults') || message.includes('capabilityOverrides')\n    if (missingColumn && message.includes('does not exist')) {\n      process.stderr.write(\n        '[migrate-capability-selections] FAILED: required DB columns are missing. ' +\n        'Apply SQL migration `prisma/migrations/20260215_add_capability_selection_columns.sql` first.\\n',\n      )\n    } else {\n      process.stderr.write(`[migrate-capability-selections] FAILED: ${message}\\n`)\n    }\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/migrations/migrate-custom-pricing-v2.ts",
    "content": "import { prisma } from '@/lib/prisma'\n\nconst APPLY = process.argv.includes('--apply')\n\ntype PreferenceRow = {\n  id: string\n  userId: string\n  customModels: string | null\n}\n\ntype MigrationSummary = {\n  mode: 'dry-run' | 'apply'\n  scanned: number\n  updatedRows: number\n  migratedModels: number\n  skippedInvalidRows: number\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction parseCustomModels(raw: string | null): unknown[] | null {\n  if (!raw) return []\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!Array.isArray(parsed)) return null\n    return parsed\n  } catch {\n    return null\n  }\n}\n\nfunction migrateLegacyCustomPricing(raw: unknown): {\n  changed: boolean\n  next: unknown\n} {\n  if (!isRecord(raw)) {\n    return { changed: false, next: raw }\n  }\n\n  const hasLegacyInput = typeof raw.input === 'number' && Number.isFinite(raw.input) && raw.input >= 0\n  const hasLegacyOutput = typeof raw.output === 'number' && Number.isFinite(raw.output) && raw.output >= 0\n  if (!hasLegacyInput && !hasLegacyOutput) {\n    return { changed: false, next: raw }\n  }\n\n  const llmRaw = isRecord(raw.llm) ? raw.llm : {}\n  const llmInput = typeof llmRaw.inputPerMillion === 'number' && Number.isFinite(llmRaw.inputPerMillion) && llmRaw.inputPerMillion >= 0\n    ? llmRaw.inputPerMillion\n    : (hasLegacyInput ? raw.input as number : undefined)\n  const llmOutput = typeof llmRaw.outputPerMillion === 'number' && Number.isFinite(llmRaw.outputPerMillion) && llmRaw.outputPerMillion >= 0\n    ? llmRaw.outputPerMillion\n    : (hasLegacyOutput ? raw.output as number : undefined)\n\n  const nextPricing: Record<string, unknown> = {}\n  for (const [key, value] of Object.entries(raw)) {\n    if (key === 'input' || key === 'output') continue\n    nextPricing[key] = value\n  }\n\n  nextPricing.llm = {\n    ...(llmInput !== undefined ? { inputPerMillion: llmInput } : {}),\n    ...(llmOutput !== undefined ? { outputPerMillion: llmOutput } : {}),\n  }\n\n  return {\n    changed: true,\n    next: nextPricing,\n  }\n}\n\nfunction migrateCustomModel(rawModel: unknown): { changed: boolean; next: unknown } {\n  if (!isRecord(rawModel)) {\n    return { changed: false, next: rawModel }\n  }\n\n  const migratedPricing = migrateLegacyCustomPricing(rawModel.customPricing)\n  if (!migratedPricing.changed) {\n    return { changed: false, next: rawModel }\n  }\n\n  return {\n    changed: true,\n    next: {\n      ...rawModel,\n      customPricing: migratedPricing.next,\n    },\n  }\n}\n\nasync function main() {\n  const summary: MigrationSummary = {\n    mode: APPLY ? 'apply' : 'dry-run',\n    scanned: 0,\n    updatedRows: 0,\n    migratedModels: 0,\n    skippedInvalidRows: 0,\n  }\n\n  const rows = await prisma.userPreference.findMany({\n    select: {\n      id: true,\n      userId: true,\n      customModels: true,\n    },\n  }) as PreferenceRow[]\n\n  summary.scanned = rows.length\n\n  for (const row of rows) {\n    const parsedModels = parseCustomModels(row.customModels)\n    if (parsedModels === null) {\n      summary.skippedInvalidRows += 1\n      continue\n    }\n\n    let rowChanged = false\n    const nextModels = parsedModels.map((model) => {\n      const migrated = migrateCustomModel(model)\n      if (migrated.changed) {\n        rowChanged = true\n        summary.migratedModels += 1\n      }\n      return migrated.next\n    })\n\n    if (!rowChanged) continue\n    summary.updatedRows += 1\n\n    if (APPLY) {\n      await prisma.userPreference.update({\n        where: { id: row.id },\n        data: {\n          customModels: JSON.stringify(nextModels),\n        },\n      })\n    }\n  }\n\n  console.log(JSON.stringify(summary, null, 2))\n}\n\nmain()\n  .then(async () => {\n    await prisma.$disconnect()\n  })\n  .catch(async (error: unknown) => {\n    console.error('[migrate-custom-pricing-v2] failed', error)\n    await prisma.$disconnect()\n    process.exit(1)\n  })\n"
  },
  {
    "path": "scripts/migrations/migrate-gateway-route-openai-compat.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { migrateGatewayRoutePayload } from '@/lib/migrations/gateway-route-openai-compat'\n\nconst APPLY = process.argv.includes('--apply')\n\ntype PreferenceRow = {\n  id: string\n  userId: string\n  customProviders: string | null\n}\n\ntype MigrationSummary = {\n  mode: 'dry-run' | 'apply'\n  scanned: number\n  updatedRows: number\n  migratedProviders: number\n  routeLitellmToOpenaiCompat: number\n  routeForcedOfficial: number\n  geminiApiModeCorrected: number\n  skippedInvalidRows: number\n}\n\nasync function main() {\n  const summary: MigrationSummary = {\n    mode: APPLY ? 'apply' : 'dry-run',\n    scanned: 0,\n    updatedRows: 0,\n    migratedProviders: 0,\n    routeLitellmToOpenaiCompat: 0,\n    routeForcedOfficial: 0,\n    geminiApiModeCorrected: 0,\n    skippedInvalidRows: 0,\n  }\n\n  const rows = await prisma.userPreference.findMany({\n    select: {\n      id: true,\n      userId: true,\n      customProviders: true,\n    },\n  }) as PreferenceRow[]\n  summary.scanned = rows.length\n\n  for (const row of rows) {\n    const result = migrateGatewayRoutePayload(row.customProviders)\n    if (result.status === 'invalid') {\n      summary.skippedInvalidRows += 1\n      continue\n    }\n\n    summary.migratedProviders += result.summary.providersChanged\n    summary.routeLitellmToOpenaiCompat += result.summary.routeLitellmToOpenaiCompat\n    summary.routeForcedOfficial += result.summary.routeForcedOfficial\n    summary.geminiApiModeCorrected += result.summary.geminiApiModeCorrected\n\n    if (!result.changed) continue\n    summary.updatedRows += 1\n\n    if (APPLY) {\n      await prisma.userPreference.update({\n        where: { id: row.id },\n        data: {\n          customProviders: result.nextRaw ?? null,\n        },\n      })\n    }\n  }\n\n  console.log(JSON.stringify(summary, null, 2))\n}\n\nmain()\n  .then(async () => {\n    await prisma.$disconnect()\n  })\n  .catch(async (error: unknown) => {\n    console.error('[migrate-gateway-route-openai-compat] failed', error)\n    await prisma.$disconnect()\n    process.exit(1)\n  })\n"
  },
  {
    "path": "scripts/migrations/migrate-graph-artifacts-unique-index.ts",
    "content": "import { prisma } from '@/lib/prisma'\n\nconst APPLY = process.argv.includes('--apply')\nconst REQUIRED_INDEX_NAME = 'graph_artifacts_runId_stepKey_artifactType_refId_key'\nconst REQUIRED_COLUMNS = ['runId', 'stepKey', 'artifactType', 'refId'] as const\n\ntype IndexRow = {\n  Key_name: string\n  Non_unique: number | string\n  Seq_in_index: number | string\n  Column_name: string\n}\n\ntype DuplicateRow = {\n  runId: string\n  stepKey: string\n  artifactType: string\n  refId: string\n  c: bigint | number\n}\n\ntype MigrationSummary = {\n  mode: 'dry-run' | 'apply'\n  hasRequiredIndexBefore: boolean\n  duplicateGroupCount: number\n  duplicateSamples: Array<{\n    runId: string\n    stepKey: string\n    artifactType: string\n    refId: string\n    count: number\n  }>\n  altered: boolean\n  hasRequiredIndexAfter: boolean\n}\n\nfunction parseIntSafe(value: number | string) {\n  if (typeof value === 'number') return value\n  return Number.parseInt(value, 10)\n}\n\nfunction hasRequiredUniqueIndex(rows: IndexRow[]) {\n  const grouped = new Map<string, Array<{ seq: number; column: string; nonUnique: number }>>()\n  for (const row of rows) {\n    const seq = parseIntSafe(row.Seq_in_index)\n    const nonUnique = parseIntSafe(row.Non_unique)\n    if (!Number.isFinite(seq) || !Number.isFinite(nonUnique)) continue\n    const key = row.Key_name\n    const items = grouped.get(key) || []\n    items.push({\n      seq,\n      column: row.Column_name,\n      nonUnique,\n    })\n    grouped.set(key, items)\n  }\n\n  for (const [key, entries] of grouped.entries()) {\n    if (entries.length !== REQUIRED_COLUMNS.length) continue\n    const sorted = entries.sort((a, b) => a.seq - b.seq)\n    if (sorted[0]?.nonUnique !== 0) continue\n    const columns = sorted.map((entry) => entry.column)\n    const isTarget = columns.every((column, index) => column === REQUIRED_COLUMNS[index])\n    if (isTarget && key === REQUIRED_INDEX_NAME) return true\n    if (isTarget) return true\n  }\n  return false\n}\n\nfunction toNumber(value: bigint | number) {\n  if (typeof value === 'bigint') return Number(value)\n  return value\n}\n\nasync function loadIndexRows() {\n  return await prisma.$queryRawUnsafe<IndexRow[]>('SHOW INDEX FROM graph_artifacts')\n}\n\nasync function loadDuplicateGroups() {\n  return await prisma.$queryRawUnsafe<DuplicateRow[]>(\n    `SELECT runId, stepKey, artifactType, refId, COUNT(*) AS c\n     FROM graph_artifacts\n     WHERE stepKey IS NOT NULL\n     GROUP BY runId, stepKey, artifactType, refId\n     HAVING c > 1\n     LIMIT 20`,\n  )\n}\n\nasync function main() {\n  const beforeRows = await loadIndexRows()\n  const hasBefore = hasRequiredUniqueIndex(beforeRows)\n  const duplicates = await loadDuplicateGroups()\n\n  const summary: MigrationSummary = {\n    mode: APPLY ? 'apply' : 'dry-run',\n    hasRequiredIndexBefore: hasBefore,\n    duplicateGroupCount: duplicates.length,\n    duplicateSamples: duplicates.map((row) => ({\n      runId: row.runId,\n      stepKey: row.stepKey,\n      artifactType: row.artifactType,\n      refId: row.refId,\n      count: toNumber(row.c),\n    })),\n    altered: false,\n    hasRequiredIndexAfter: hasBefore,\n  }\n\n  if (hasBefore) {\n    console.log(JSON.stringify(summary, null, 2))\n    return\n  }\n\n  if (duplicates.length > 0) {\n    throw new Error(\n      `cannot add unique index; found ${duplicates.length} duplicate groups in graph_artifacts (stepKey IS NOT NULL)`,\n    )\n  }\n\n  if (APPLY) {\n    await prisma.$executeRawUnsafe(\n      `ALTER TABLE graph_artifacts\n       ADD UNIQUE INDEX ${REQUIRED_INDEX_NAME} (runId, stepKey, artifactType, refId)`,\n    )\n    summary.altered = true\n    const afterRows = await loadIndexRows()\n    summary.hasRequiredIndexAfter = hasRequiredUniqueIndex(afterRows)\n    if (!summary.hasRequiredIndexAfter) {\n      throw new Error('unique index create verification failed')\n    }\n  }\n\n  console.log(JSON.stringify(summary, null, 2))\n}\n\nmain()\n  .then(async () => {\n    await prisma.$disconnect()\n  })\n  .catch(async (error: unknown) => {\n    console.error('[migrate-graph-artifacts-unique-index] failed', error)\n    await prisma.$disconnect()\n    process.exit(1)\n  })\n"
  },
  {
    "path": "scripts/migrations/migrate-model-config-contract.ts",
    "content": "import fs from 'fs'\nimport path from 'path'\nimport { prisma } from '@/lib/prisma'\nimport {\n  composeModelKey,\n  parseModelKeyStrict,\n  validateModelCapabilities,\n  type ModelCapabilities,\n  type UnifiedModelType,\n} from '@/lib/model-config-contract'\n\ntype ModelField =\n  | 'analysisModel'\n  | 'characterModel'\n  | 'locationModel'\n  | 'storyboardModel'\n  | 'editModel'\n  | 'videoModel'\n\ntype PreferenceRow = {\n  id: string\n  userId: string\n  customModels: string | null\n  analysisModel: string | null\n  characterModel: string | null\n  locationModel: string | null\n  storyboardModel: string | null\n  editModel: string | null\n  videoModel: string | null\n}\n\ntype ProjectRow = {\n  id: string\n  projectId: string\n  analysisModel: string | null\n  characterModel: string | null\n  locationModel: string | null\n  storyboardModel: string | null\n  editModel: string | null\n  videoModel: string | null\n  project: {\n    userId: string\n  }\n}\n\ntype MigrationIssue = {\n  table: 'userPreference' | 'novelPromotionProject'\n  rowId: string\n  userId?: string\n  field: string\n  kind:\n    | 'CUSTOM_MODELS_JSON_INVALID'\n    | 'MODEL_SHAPE_INVALID'\n    | 'MODEL_TYPE_INVALID'\n    | 'MODEL_KEY_INCOMPLETE'\n    | 'MODEL_KEY_MISMATCH'\n    | 'MODEL_CAPABILITY_INVALID'\n    | 'LEGACY_MODEL_ID_NOT_FOUND'\n    | 'LEGACY_MODEL_ID_AMBIGUOUS'\n  rawValue?: string | null\n  candidates?: string[]\n  message: string\n}\n\ntype MigrationReport = {\n  generatedAt: string\n  mode: 'dry-run' | 'apply'\n  userPreference: {\n    scanned: number\n    updated: number\n    updatedCustomModels: number\n    updatedDefaultFields: number\n  }\n  novelPromotionProject: {\n    scanned: number\n    updated: number\n    updatedFields: number\n  }\n  issues: MigrationIssue[]\n}\n\ntype NormalizedModel = {\n  provider: string\n  modelId: string\n  modelKey: string\n  name: string\n  type: UnifiedModelType\n  price: number\n  resolution?: '2K' | '4K'\n  capabilities?: ModelCapabilities\n}\n\nconst APPLY = process.argv.includes('--apply')\nconst MAX_ISSUES = 500\nconst MODEL_FIELDS: readonly ModelField[] = [\n  'analysisModel',\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n  'videoModel',\n]\n\nconst LEGACY_MODEL_ID_MAP = new Map<string, string>([\n  ['anthropic/claude-sonnet-4.5', 'openrouter::anthropic/claude-sonnet-4.5'],\n  ['google/gemini-3-pro-preview', 'openrouter::google/gemini-3-pro-preview'],\n  ['openai/gpt-5.2', 'openrouter::openai/gpt-5.2'],\n  ['banana', 'fal::banana'],\n  ['banana-2k', 'fal::banana'],\n  ['seedream', 'ark::doubao-seedream-4-0-250828'],\n  ['seedream4.5', 'ark::doubao-seedream-4-5-251128'],\n  ['gemini-3-pro-image-preview', 'google::gemini-3-pro-image-preview'],\n  ['gemini-3-pro-image-preview-batch', 'google::gemini-3-pro-image-preview-batch'],\n  ['nano-banana-pro', 'google::gemini-3-pro-image-preview'],\n  ['gemini-3.0-pro-image-portrait', 'flow2api::gemini-3.0-pro-image-portrait'],\n  ['imagen-4.0-ultra-generate-001', 'google::imagen-4.0-ultra-generate-001'],\n  ['doubao-seedance-1-0-pro-250528', 'ark::doubao-seedance-1-0-pro-250528'],\n  ['doubao-seedance-1-0-pro-fast-251015', 'ark::doubao-seedance-1-0-pro-fast-251015'],\n  ['doubao-seedance-1-0-pro-fast-251015-batch', 'ark::doubao-seedance-1-0-pro-fast-251015-batch'],\n])\n\nfunction parseReportPathArg(): string {\n  const flagPrefix = '--report='\n  const inline = process.argv.find((arg) => arg.startsWith(flagPrefix))\n  if (inline) return inline.slice(flagPrefix.length)\n  const flagIndex = process.argv.findIndex((arg) => arg === '--report')\n  if (flagIndex !== -1 && process.argv[flagIndex + 1]) {\n    return process.argv[flagIndex + 1]\n  }\n  return 'scripts/migrations/reports/model-config-migration-report.json'\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction toTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction isUnifiedModelType(value: unknown): value is UnifiedModelType {\n  return value === 'llm'\n    || value === 'image'\n    || value === 'video'\n    || value === 'audio'\n    || value === 'lipsync'\n}\n\nfunction stableStringify(value: unknown): string {\n  return JSON.stringify(value)\n}\n\nfunction parseCustomModels(raw: string | null): { ok: true; value: unknown[] } | { ok: false } {\n  if (!raw) return { ok: true, value: [] }\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!Array.isArray(parsed)) return { ok: false }\n    return { ok: true, value: parsed }\n  } catch {\n    return { ok: false }\n  }\n}\n\nfunction normalizeModel(\n  raw: unknown,\n): { normalized: NormalizedModel | null; changed: boolean; issue?: Omit<MigrationIssue, 'table' | 'rowId'> } {\n  if (!isRecord(raw)) {\n    return {\n      normalized: null,\n      changed: false,\n      issue: {\n        field: 'customModels',\n        kind: 'MODEL_SHAPE_INVALID',\n        message: 'customModels item must be object',\n      },\n    }\n  }\n\n  const modelType = raw.type\n  if (!isUnifiedModelType(modelType)) {\n    return {\n      normalized: null,\n      changed: false,\n      issue: {\n        field: 'customModels.type',\n        kind: 'MODEL_TYPE_INVALID',\n        rawValue: String(raw.type ?? ''),\n        message: 'custom model type must be llm/image/video/audio/lipsync',\n      },\n    }\n  }\n\n  const providerField = toTrimmedString(raw.provider)\n  const modelIdField = toTrimmedString(raw.modelId)\n  const parsedModelKey = parseModelKeyStrict(toTrimmedString(raw.modelKey))\n\n  const provider = providerField || parsedModelKey?.provider || ''\n  const modelId = modelIdField || parsedModelKey?.modelId || ''\n  const modelKey = composeModelKey(provider, modelId)\n  if (!modelKey) {\n    return {\n      normalized: null,\n      changed: false,\n      issue: {\n        field: 'customModels.modelKey',\n        kind: 'MODEL_KEY_INCOMPLETE',\n        rawValue: toTrimmedString(raw.modelKey),\n        message: 'provider/modelId/modelKey cannot compose a valid model_key',\n      },\n    }\n  }\n\n  if (parsedModelKey && parsedModelKey.modelKey !== modelKey) {\n    return {\n      normalized: null,\n      changed: false,\n      issue: {\n        field: 'customModels.modelKey',\n        kind: 'MODEL_KEY_MISMATCH',\n        rawValue: toTrimmedString(raw.modelKey),\n        message: 'modelKey conflicts with provider/modelId',\n      },\n    }\n  }\n\n  const rawResolution = toTrimmedString(raw.resolution)\n  const resolution = rawResolution === '2K' || rawResolution === '4K' ? rawResolution : undefined\n  const capabilities = isRecord(raw.capabilities)\n    ? ({ ...(raw.capabilities as ModelCapabilities) })\n    : undefined\n  const capabilityIssues = validateModelCapabilities(modelType, capabilities)\n  if (capabilityIssues.length > 0) {\n    const firstIssue = capabilityIssues[0]\n    return {\n      normalized: null,\n      changed: false,\n      issue: {\n        field: firstIssue.field,\n        kind: 'MODEL_CAPABILITY_INVALID',\n        message: `${firstIssue.code}: ${firstIssue.message}`,\n      },\n    }\n  }\n\n  const name = toTrimmedString(raw.name) || modelId\n  const price = typeof raw.price === 'number' && Number.isFinite(raw.price) ? raw.price : 0\n\n  const normalized: NormalizedModel = {\n    provider,\n    modelId,\n    modelKey,\n    name,\n    type: modelType,\n    price,\n    ...(resolution ? { resolution } : {}),\n    ...(capabilities ? { capabilities } : {}),\n  }\n\n  const changed = stableStringify(raw) !== stableStringify(normalized)\n  return { normalized, changed }\n}\n\nfunction addIssue(report: MigrationReport, issue: MigrationIssue) {\n  if (report.issues.length >= MAX_ISSUES) return\n  report.issues.push(issue)\n}\n\nfunction normalizeModelFieldValue(\n  rawValue: string | null,\n  field: ModelField,\n  mappingByModelId: Map<string, string[]>,\n): { nextValue: string | null; changed: boolean; issue?: Omit<MigrationIssue, 'table' | 'rowId'> } {\n  if (!rawValue || !rawValue.trim()) return { nextValue: null, changed: rawValue !== null }\n  const trimmed = rawValue.trim()\n  const parsed = parseModelKeyStrict(trimmed)\n  if (parsed) {\n    return { nextValue: parsed.modelKey, changed: parsed.modelKey !== rawValue }\n  }\n\n  const candidates = mappingByModelId.get(trimmed) || []\n  if (candidates.length === 1) {\n    return { nextValue: candidates[0], changed: candidates[0] !== rawValue }\n  }\n  if (candidates.length === 0) {\n    const mappedModelKey = LEGACY_MODEL_ID_MAP.get(trimmed)\n    if (mappedModelKey) {\n      return { nextValue: mappedModelKey, changed: mappedModelKey !== rawValue }\n    }\n  }\n  if (candidates.length === 0) {\n    return {\n      nextValue: rawValue,\n      changed: false,\n      issue: {\n        field,\n        kind: 'LEGACY_MODEL_ID_NOT_FOUND',\n        rawValue,\n        message: `${field} legacy modelId cannot be mapped`,\n      },\n    }\n  }\n  return {\n    nextValue: rawValue,\n    changed: false,\n    issue: {\n      field,\n      kind: 'LEGACY_MODEL_ID_AMBIGUOUS',\n      rawValue,\n      candidates,\n      message: `${field} legacy modelId maps to multiple providers`,\n    },\n  }\n}\n\nasync function main() {\n  const reportPath = parseReportPathArg()\n  const report: MigrationReport = {\n    generatedAt: new Date().toISOString(),\n    mode: APPLY ? 'apply' : 'dry-run',\n    userPreference: {\n      scanned: 0,\n      updated: 0,\n      updatedCustomModels: 0,\n      updatedDefaultFields: 0,\n    },\n    novelPromotionProject: {\n      scanned: 0,\n      updated: 0,\n      updatedFields: 0,\n    },\n    issues: [],\n  }\n\n  const userPrefs = await prisma.userPreference.findMany({\n    select: {\n      id: true,\n      userId: true,\n      customModels: true,\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n    },\n  })\n\n  const userMappings = new Map<string, Map<string, string[]>>()\n\n  for (const pref of userPrefs) {\n    report.userPreference.scanned += 1\n    const updateData: Partial<Record<ModelField | 'customModels', string | null>> = {}\n\n    const parsedCustomModels = parseCustomModels(pref.customModels)\n    const normalizedModels: NormalizedModel[] = []\n    let customModelsChanged = false\n\n    if (!parsedCustomModels.ok) {\n      addIssue(report, {\n        table: 'userPreference',\n        rowId: pref.id,\n        userId: pref.userId,\n        field: 'customModels',\n        kind: 'CUSTOM_MODELS_JSON_INVALID',\n        rawValue: pref.customModels,\n        message: 'customModels JSON is invalid',\n      })\n    } else {\n      for (let index = 0; index < parsedCustomModels.value.length; index += 1) {\n        const normalizedResult = normalizeModel(parsedCustomModels.value[index])\n        if (normalizedResult.issue) {\n          addIssue(report, {\n            table: 'userPreference',\n            rowId: pref.id,\n            userId: pref.userId,\n            ...normalizedResult.issue,\n          })\n          continue\n        }\n        if (normalizedResult.normalized) {\n          normalizedModels.push(normalizedResult.normalized)\n          if (normalizedResult.changed) customModelsChanged = true\n        }\n      }\n    }\n\n    const mappingByModelId = new Map<string, string[]>()\n    for (const model of normalizedModels) {\n      const existing = mappingByModelId.get(model.modelId) || []\n      if (!existing.includes(model.modelKey)) existing.push(model.modelKey)\n      mappingByModelId.set(model.modelId, existing)\n    }\n    userMappings.set(pref.userId, mappingByModelId)\n\n    if (customModelsChanged) {\n      updateData.customModels = JSON.stringify(normalizedModels)\n      report.userPreference.updatedCustomModels += 1\n    }\n\n    for (const field of MODEL_FIELDS) {\n      const normalizedField = normalizeModelFieldValue(pref[field], field, mappingByModelId)\n      if (normalizedField.issue) {\n        addIssue(report, {\n          table: 'userPreference',\n          rowId: pref.id,\n          userId: pref.userId,\n          ...normalizedField.issue,\n        })\n      }\n      if (normalizedField.changed) {\n        updateData[field] = normalizedField.nextValue\n        report.userPreference.updatedDefaultFields += 1\n      }\n    }\n\n    if (Object.keys(updateData).length > 0) {\n      report.userPreference.updated += 1\n      if (APPLY) {\n        await prisma.userPreference.update({\n          where: { id: pref.id },\n          data: updateData,\n        })\n      }\n    }\n  }\n\n  const projects = await prisma.novelPromotionProject.findMany({\n    select: {\n      id: true,\n      projectId: true,\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n      project: {\n        select: {\n          userId: true,\n        },\n      },\n    },\n  })\n\n  for (const row of projects as ProjectRow[]) {\n    report.novelPromotionProject.scanned += 1\n    const mappingByModelId = userMappings.get(row.project.userId) || new Map<string, string[]>()\n    const updateData: Partial<Record<ModelField, string | null>> = {}\n\n    for (const field of MODEL_FIELDS) {\n      const normalizedField = normalizeModelFieldValue(row[field], field, mappingByModelId)\n      if (normalizedField.issue) {\n        addIssue(report, {\n          table: 'novelPromotionProject',\n          rowId: row.id,\n          userId: row.project.userId,\n          ...normalizedField.issue,\n        })\n      }\n      if (normalizedField.changed) {\n        updateData[field] = normalizedField.nextValue\n        report.novelPromotionProject.updatedFields += 1\n      }\n    }\n\n    if (Object.keys(updateData).length > 0) {\n      report.novelPromotionProject.updated += 1\n      if (APPLY) {\n        await prisma.novelPromotionProject.update({\n          where: { id: row.id },\n          data: updateData,\n        })\n      }\n    }\n  }\n\n  const absoluteReportPath = path.isAbsolute(reportPath)\n    ? reportPath\n    : path.join(process.cwd(), reportPath)\n  fs.mkdirSync(path.dirname(absoluteReportPath), { recursive: true })\n  fs.writeFileSync(absoluteReportPath, `${JSON.stringify(report, null, 2)}\\n`, 'utf8')\n\n  process.stdout.write(\n    `[migrate-model-config-contract] mode=${report.mode} ` +\n    `prefs=${report.userPreference.scanned}/${report.userPreference.updated} ` +\n    `projects=${report.novelPromotionProject.scanned}/${report.novelPromotionProject.updated} ` +\n    `issues=${report.issues.length} report=${absoluteReportPath}\\n`,\n  )\n}\n\nmain()\n  .catch((error) => {\n    process.stderr.write(`[migrate-model-config-contract] failed: ${String(error)}\\n`)\n    process.exitCode = 1\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/migrations/migrate-qwen-to-bailian.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { composeModelKey, parseModelKeyStrict, type CapabilitySelections } from '@/lib/model-config-contract'\n\nconst APPLY = process.argv.includes('--apply')\n\ntype PreferenceRow = {\n  id: string\n  userId: string\n  customProviders: string | null\n  customModels: string | null\n  analysisModel: string | null\n  characterModel: string | null\n  locationModel: string | null\n  storyboardModel: string | null\n  editModel: string | null\n  videoModel: string | null\n  lipSyncModel: string | null\n  capabilityDefaults: string | null\n}\n\ntype StoredProvider = {\n  id: string\n  name: string\n  baseUrl?: string\n  apiKey?: string\n  apiMode?: 'gemini-sdk' | 'openai-official'\n  gatewayRoute?: 'official' | 'litellm'\n}\n\ntype StoredModel = {\n  modelId: string\n  modelKey: string\n  name: string\n  type: string\n  provider: string\n  price: number\n}\n\ntype MigrationConflict = {\n  userId: string\n  reason: string\n}\n\ntype MigrationSummary = {\n  mode: 'dry-run' | 'apply'\n  scanned: number\n  updatedRows: number\n  updatedProviders: number\n  updatedModels: number\n  updatedDefaults: number\n  updatedCapabilityDefaults: number\n  invalidRows: number\n  conflicts: MigrationConflict[]\n}\n\ntype DefaultModelField =\n  | 'analysisModel'\n  | 'characterModel'\n  | 'locationModel'\n  | 'storyboardModel'\n  | 'editModel'\n  | 'videoModel'\n  | 'lipSyncModel'\n\nconst DEFAULT_MODEL_FIELDS: readonly DefaultModelField[] = [\n  'analysisModel',\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n  'videoModel',\n  'lipSyncModel',\n]\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction parseProviders(raw: string | null): StoredProvider[] | null {\n  if (!raw) return []\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!Array.isArray(parsed)) return null\n    const providers: StoredProvider[] = []\n    for (const item of parsed) {\n      if (!isRecord(item)) return null\n      const id = readTrimmedString(item.id)\n      const name = readTrimmedString(item.name)\n      if (!id || !name) return null\n      const provider: StoredProvider = { id, name }\n      if (typeof item.baseUrl === 'string' && item.baseUrl.trim()) provider.baseUrl = item.baseUrl.trim()\n      if (typeof item.apiKey === 'string' && item.apiKey.trim()) provider.apiKey = item.apiKey.trim()\n      if (item.apiMode === 'gemini-sdk' || item.apiMode === 'openai-official') provider.apiMode = item.apiMode\n      if (item.gatewayRoute === 'official' || item.gatewayRoute === 'litellm') provider.gatewayRoute = item.gatewayRoute\n      providers.push(provider)\n    }\n    return providers\n  } catch {\n    return null\n  }\n}\n\nfunction parseModels(raw: string | null): StoredModel[] | null {\n  if (!raw) return []\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!Array.isArray(parsed)) return null\n    const models: StoredModel[] = []\n    for (const item of parsed) {\n      if (!isRecord(item)) return null\n      const modelId = readTrimmedString(item.modelId)\n      const modelKey = readTrimmedString(item.modelKey)\n      const provider = readTrimmedString(item.provider)\n      const name = readTrimmedString(item.name)\n      const type = readTrimmedString(item.type)\n      const price = typeof item.price === 'number' && Number.isFinite(item.price) ? item.price : 0\n      if (!modelId || !provider || !type) return null\n      const normalizedModelKey = modelKey || composeModelKey(provider, modelId)\n      if (!normalizedModelKey) return null\n      models.push({\n        modelId,\n        modelKey: normalizedModelKey,\n        provider,\n        name: name || modelId,\n        type,\n        price,\n      })\n    }\n    return models\n  } catch {\n    return null\n  }\n}\n\nfunction parseCapabilityDefaults(raw: string | null): CapabilitySelections | null {\n  if (!raw) return {}\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!isRecord(parsed)) return null\n    const selections: CapabilitySelections = {}\n    for (const [modelKey, value] of Object.entries(parsed)) {\n      if (!isRecord(value)) continue\n      const nextSelection: Record<string, string | number | boolean> = {}\n      for (const [field, option] of Object.entries(value)) {\n        if (typeof option === 'string' || typeof option === 'number' || typeof option === 'boolean') {\n          nextSelection[field] = option\n        }\n      }\n      selections[modelKey] = nextSelection\n    }\n    return selections\n  } catch {\n    return null\n  }\n}\n\nfunction migrateProviderId(providerId: string): string {\n  if (providerId === 'qwen') return 'bailian'\n  const parsed = parseModelKeyStrict(providerId)\n  if (parsed) return providerId\n  const marker = providerId.indexOf(':')\n  if (marker === -1) return providerId\n  const providerKey = providerId.slice(0, marker)\n  if (providerKey !== 'qwen') return providerId\n  return `bailian${providerId.slice(marker)}`\n}\n\nfunction migrateModelKey(rawModelKey: string): string {\n  const parsed = parseModelKeyStrict(rawModelKey)\n  if (!parsed) return rawModelKey\n  if (parsed.provider !== 'qwen') return parsed.modelKey\n  return composeModelKey('bailian', parsed.modelId)\n}\n\nfunction migrateDefaultModel(rawValue: string | null): string | null {\n  if (!rawValue) return rawValue\n  const value = rawValue.trim()\n  if (!value) return null\n  return migrateModelKey(value)\n}\n\nfunction hasProviderByKey(providers: StoredProvider[], providerKey: string): boolean {\n  return providers.some((provider) => {\n    const marker = provider.id.indexOf(':')\n    const key = marker === -1 ? provider.id : provider.id.slice(0, marker)\n    return key === providerKey\n  })\n}\n\nasync function main() {\n  const summary: MigrationSummary = {\n    mode: APPLY ? 'apply' : 'dry-run',\n    scanned: 0,\n    updatedRows: 0,\n    updatedProviders: 0,\n    updatedModels: 0,\n    updatedDefaults: 0,\n    updatedCapabilityDefaults: 0,\n    invalidRows: 0,\n    conflicts: [],\n  }\n\n  const rows = await prisma.userPreference.findMany({\n    select: {\n      id: true,\n      userId: true,\n      customProviders: true,\n      customModels: true,\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n      lipSyncModel: true,\n      capabilityDefaults: true,\n    },\n  }) as PreferenceRow[]\n\n  summary.scanned = rows.length\n\n  for (const row of rows) {\n    const providers = parseProviders(row.customProviders)\n    const models = parseModels(row.customModels)\n    const capabilityDefaults = parseCapabilityDefaults(row.capabilityDefaults)\n    if (!providers || !models || !capabilityDefaults) {\n      summary.invalidRows += 1\n      continue\n    }\n\n    const hasQwenProvider = hasProviderByKey(providers, 'qwen')\n    const hasBailianProvider = hasProviderByKey(providers, 'bailian')\n    if (hasQwenProvider && hasBailianProvider) {\n      summary.conflicts.push({\n        userId: row.userId,\n        reason: 'both qwen and bailian providers exist',\n      })\n      continue\n    }\n\n    let rowChanged = false\n\n    const nextProviders = providers.map((provider) => {\n      const nextId = migrateProviderId(provider.id)\n      if (nextId !== provider.id) {\n        rowChanged = true\n        summary.updatedProviders += 1\n      }\n      return {\n        ...provider,\n        id: nextId,\n        ...(nextId === 'bailian' ? { name: 'Alibaba Bailian' } : {}),\n      }\n    })\n\n    const nextModels = models.map((model) => {\n      const nextProvider = migrateProviderId(model.provider)\n      const nextModelKey = migrateModelKey(model.modelKey)\n      const changed = nextProvider !== model.provider || nextModelKey !== model.modelKey\n      if (changed) {\n        rowChanged = true\n        summary.updatedModels += 1\n      }\n      return {\n        ...model,\n        provider: nextProvider,\n        modelKey: nextModelKey,\n      }\n    })\n    const modelKeySet = new Set<string>()\n    let hasModelConflict = false\n    for (const model of nextModels) {\n      if (!modelKeySet.has(model.modelKey)) {\n        modelKeySet.add(model.modelKey)\n        continue\n      }\n      hasModelConflict = true\n      break\n    }\n    if (hasModelConflict) {\n      summary.conflicts.push({\n        userId: row.userId,\n        reason: 'model key collision after qwen -> bailian migration',\n      })\n      continue\n    }\n\n    const nextDefaults: Partial<Record<DefaultModelField, string | null>> = {}\n    for (const field of DEFAULT_MODEL_FIELDS) {\n      const current = row[field]\n      const next = migrateDefaultModel(current)\n      nextDefaults[field] = next\n      if ((current || null) !== (next || null)) {\n        rowChanged = true\n        summary.updatedDefaults += 1\n      }\n    }\n\n    const nextCapabilityDefaults: CapabilitySelections = {}\n    for (const [modelKey, selection] of Object.entries(capabilityDefaults)) {\n      const nextModelKey = migrateModelKey(modelKey)\n      nextCapabilityDefaults[nextModelKey] = selection\n      if (nextModelKey !== modelKey) {\n        rowChanged = true\n        summary.updatedCapabilityDefaults += 1\n      }\n    }\n\n    if (!rowChanged) continue\n    summary.updatedRows += 1\n\n    if (APPLY) {\n      await prisma.userPreference.update({\n        where: { id: row.id },\n        data: {\n          customProviders: JSON.stringify(nextProviders),\n          customModels: JSON.stringify(nextModels),\n          analysisModel: nextDefaults.analysisModel || null,\n          characterModel: nextDefaults.characterModel || null,\n          locationModel: nextDefaults.locationModel || null,\n          storyboardModel: nextDefaults.storyboardModel || null,\n          editModel: nextDefaults.editModel || null,\n          videoModel: nextDefaults.videoModel || null,\n          lipSyncModel: nextDefaults.lipSyncModel || null,\n          capabilityDefaults: Object.keys(nextCapabilityDefaults).length > 0\n            ? JSON.stringify(nextCapabilityDefaults)\n            : null,\n        },\n      })\n    }\n  }\n\n  console.log(JSON.stringify(summary, null, 2))\n  if (summary.conflicts.length > 0) {\n    process.exitCode = 2\n  }\n}\n\nmain()\n  .then(async () => {\n    await prisma.$disconnect()\n  })\n  .catch(async (error: unknown) => {\n    console.error('[migrate-qwen-to-bailian] failed', error)\n    await prisma.$disconnect()\n    process.exit(1)\n  })\n"
  },
  {
    "path": "scripts/migrations/migrate-release-blockers.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { composeModelKey, parseModelKeyStrict, type CapabilitySelections } from '@/lib/model-config-contract'\n\ntype Mode = 'dry-run' | 'apply'\n\ntype UserPreferenceRow = {\n  id: string\n  userId: string\n  customProviders: string | null\n  customModels: string | null\n  analysisModel: string | null\n  characterModel: string | null\n  locationModel: string | null\n  storyboardModel: string | null\n  editModel: string | null\n  videoModel: string | null\n  audioModel: string | null\n  lipSyncModel: string | null\n  capabilityDefaults: string | null\n}\n\ntype NovelProjectRow = {\n  id: string\n  projectId: string\n  analysisModel: string | null\n  characterModel: string | null\n  locationModel: string | null\n  storyboardModel: string | null\n  editModel: string | null\n  videoModel: string | null\n  capabilityOverrides: string | null\n}\n\ntype StoredProvider = {\n  id: string\n  name: string\n  baseUrl?: string\n  apiKey?: string\n  apiMode?: 'gemini-sdk' | 'openai-official'\n  gatewayRoute?: 'official' | 'openai-compat'\n}\n\ntype StoredModel = {\n  modelId: string\n  modelKey: string\n  provider: string\n  [key: string]: unknown\n}\n\ntype ParseResult<T> = {\n  ok: boolean\n  value: T\n}\n\ntype MigrationSummary = {\n  mode: Mode\n  userPreference: {\n    scanned: number\n    updated: number\n    dirtyClearedProviders: number\n    dirtyClearedModels: number\n    dirtyClearedCapabilityDefaults: number\n    migratedProviders: number\n    migratedModels: number\n    migratedDefaultModelFields: number\n    migratedCapabilityDefaultKeys: number\n    modelCollisionsResolvedByBailian: number\n    providerCollisionsResolvedByBailian: number\n    invalidModelFieldsCleared: number\n  }\n  novelPromotionProject: {\n    scanned: number\n    updated: number\n    migratedModelFields: number\n    migratedCapabilityOverrideKeys: number\n    invalidModelFieldsCleared: number\n    dirtyClearedCapabilityOverrides: number\n  }\n  graphArtifacts: {\n    hasRequiredUniqueIndexBefore: boolean\n    duplicateGroupsBefore: number\n    duplicateGroupSamples: Array<{\n      runId: string\n      stepKey: string\n      artifactType: string\n      refId: string\n      count: number\n    }>\n    deletedRowsForDedup: number\n    duplicateGroupsAfter: number\n    indexAdded: boolean\n    hasRequiredUniqueIndexAfter: boolean\n  }\n}\n\ntype MysqlIndexRow = {\n  Key_name: string\n  Non_unique: number | string\n  Seq_in_index: number | string\n  Column_name: string\n}\n\ntype DuplicateGroupRow = {\n  runId: string\n  stepKey: string\n  artifactType: string\n  refId: string\n  c: bigint | number\n}\n\ntype CountRow = {\n  c: bigint | number\n}\n\ntype DefaultModelField =\n  | 'analysisModel'\n  | 'characterModel'\n  | 'locationModel'\n  | 'storyboardModel'\n  | 'editModel'\n  | 'videoModel'\n  | 'audioModel'\n  | 'lipSyncModel'\n\ntype ProjectModelField =\n  | 'analysisModel'\n  | 'characterModel'\n  | 'locationModel'\n  | 'storyboardModel'\n  | 'editModel'\n  | 'videoModel'\n\ntype UserPreferenceUpdateData = Partial<Record<DefaultModelField, string | null>> & {\n  customProviders?: string | null\n  customModels?: string | null\n  capabilityDefaults?: string | null\n}\n\ntype NovelProjectUpdateData = Partial<Record<ProjectModelField, string | null>> & {\n  capabilityOverrides?: string | null\n}\n\nconst MODE: Mode = process.argv.includes('--dry-run') ? 'dry-run' : 'apply'\nconst APPLY = MODE === 'apply'\n\nconst OFFICIAL_ONLY_PROVIDER_KEYS = new Set(['bailian', 'siliconflow'])\nconst DEFAULT_MODEL_FIELDS: readonly DefaultModelField[] = [\n  'analysisModel',\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n  'videoModel',\n  'audioModel',\n  'lipSyncModel',\n]\nconst PROJECT_MODEL_FIELDS: readonly ProjectModelField[] = [\n  'analysisModel',\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n  'videoModel',\n]\nconst REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS = ['runId', 'stepKey', 'artifactType', 'refId'] as const\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction toNullableModelField(raw: string | null | undefined): string | null {\n  const trimmed = readTrimmedString(raw)\n  return trimmed || null\n}\n\nfunction getProviderKey(providerId: string): string {\n  const index = providerId.indexOf(':')\n  return index === -1 ? providerId : providerId.slice(0, index)\n}\n\nfunction migrateProviderId(providerId: string): string {\n  const trimmed = providerId.trim()\n  if (!trimmed) return trimmed\n  if (trimmed === 'qwen') return 'bailian'\n\n  const providerKey = getProviderKey(trimmed)\n  if (providerKey !== 'qwen') return trimmed\n  return `bailian${trimmed.slice(providerKey.length)}`\n}\n\nfunction migrateModelKey(rawModelKey: string): string {\n  const parsed = parseModelKeyStrict(rawModelKey)\n  if (!parsed) return rawModelKey\n  if (getProviderKey(parsed.provider) !== 'qwen') return parsed.modelKey\n  const nextProvider = migrateProviderId(parsed.provider)\n  return composeModelKey(nextProvider, parsed.modelId)\n}\n\nfunction providerPriorityByOriginalKey(originalProviderId: string): number {\n  const key = getProviderKey(originalProviderId)\n  if (key === 'bailian') return 2\n  if (key === 'qwen') return 1\n  return 0\n}\n\nfunction normalizeGatewayRoute(\n  providerId: string,\n  rawGatewayRoute: unknown,\n): 'official' | 'openai-compat' {\n  const providerKey = getProviderKey(providerId)\n  if (providerKey === 'openai-compatible') return 'openai-compat'\n  if (providerKey === 'gemini-compatible') return 'official'\n  if (OFFICIAL_ONLY_PROVIDER_KEYS.has(providerKey)) return 'official'\n  return rawGatewayRoute === 'openai-compat' ? 'openai-compat' : 'official'\n}\n\nfunction parseJsonArray(raw: string | null): ParseResult<unknown[]> {\n  if (!raw) return { ok: true, value: [] }\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!Array.isArray(parsed)) return { ok: false, value: [] }\n    return { ok: true, value: parsed }\n  } catch {\n    return { ok: false, value: [] }\n  }\n}\n\nfunction parseJsonRecord(raw: string | null): ParseResult<Record<string, unknown>> {\n  if (!raw) return { ok: true, value: {} }\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!isRecord(parsed)) return { ok: false, value: {} }\n    return { ok: true, value: parsed }\n  } catch {\n    return { ok: false, value: {} }\n  }\n}\n\nfunction migrateProviders(\n  rawProviders: string | null,\n): {\n  ok: boolean\n  nextRaw: string | null\n  changed: boolean\n  migratedProviders: number\n  collisionsResolvedByBailian: number\n} {\n  const parsed = parseJsonArray(rawProviders)\n  if (!parsed.ok) {\n    return {\n      ok: false,\n      nextRaw: null,\n      changed: rawProviders !== null,\n      migratedProviders: 0,\n      collisionsResolvedByBailian: 0,\n    }\n  }\n\n  const deduped = new Map<string, { provider: StoredProvider; priority: number }>()\n  let migratedProviders = 0\n  let collisionsResolvedByBailian = 0\n\n  for (const item of parsed.value) {\n    if (!isRecord(item)) {\n      return {\n        ok: false,\n        nextRaw: null,\n        changed: true,\n        migratedProviders: 0,\n        collisionsResolvedByBailian: 0,\n      }\n    }\n\n    const id = readTrimmedString(item.id)\n    const name = readTrimmedString(item.name)\n    if (!id || !name) {\n      return {\n        ok: false,\n        nextRaw: null,\n        changed: true,\n        migratedProviders: 0,\n        collisionsResolvedByBailian: 0,\n      }\n    }\n\n    const nextId = migrateProviderId(id)\n    if (nextId !== id) migratedProviders += 1\n\n    const apiModeRaw = readTrimmedString(item.apiMode)\n    let apiMode: 'gemini-sdk' | 'openai-official' | undefined\n    if (apiModeRaw === 'gemini-sdk' || apiModeRaw === 'openai-official') {\n      apiMode = apiModeRaw\n    }\n    if (getProviderKey(nextId) === 'gemini-compatible' && apiMode === 'openai-official') {\n      apiMode = 'gemini-sdk'\n    }\n\n    const nextProvider: StoredProvider = {\n      id: nextId,\n      name: getProviderKey(nextId) === 'bailian' ? 'Alibaba Bailian' : name,\n      baseUrl: readTrimmedString(item.baseUrl) || undefined,\n      apiKey: typeof item.apiKey === 'string' ? item.apiKey.trim() : undefined,\n      apiMode,\n      gatewayRoute: normalizeGatewayRoute(nextId, item.gatewayRoute),\n    }\n\n    const dedupeKey = nextProvider.id.toLowerCase()\n    const nextPriority = providerPriorityByOriginalKey(id)\n    const existing = deduped.get(dedupeKey)\n    if (!existing) {\n      deduped.set(dedupeKey, { provider: nextProvider, priority: nextPriority })\n      continue\n    }\n\n    if (nextPriority > existing.priority) {\n      deduped.set(dedupeKey, { provider: nextProvider, priority: nextPriority })\n      collisionsResolvedByBailian += 1\n    }\n  }\n\n  const nextProviders = Array.from(deduped.values()).map((entry) => entry.provider)\n  const nextRaw = nextProviders.length > 0 ? JSON.stringify(nextProviders) : null\n  return {\n    ok: true,\n    nextRaw,\n    changed: (rawProviders || null) !== (nextRaw || null),\n    migratedProviders,\n    collisionsResolvedByBailian,\n  }\n}\n\nfunction migrateModels(\n  rawModels: string | null,\n): {\n  ok: boolean\n  nextRaw: string | null\n  changed: boolean\n  migratedModels: number\n  collisionsResolvedByBailian: number\n} {\n  const parsed = parseJsonArray(rawModels)\n  if (!parsed.ok) {\n    return {\n      ok: false,\n      nextRaw: null,\n      changed: rawModels !== null,\n      migratedModels: 0,\n      collisionsResolvedByBailian: 0,\n    }\n  }\n\n  const deduped = new Map<string, { model: StoredModel; priority: number }>()\n  let migratedModels = 0\n  let collisionsResolvedByBailian = 0\n\n  for (const item of parsed.value) {\n    if (!isRecord(item)) {\n      return {\n        ok: false,\n        nextRaw: null,\n        changed: true,\n        migratedModels: 0,\n        collisionsResolvedByBailian: 0,\n      }\n    }\n\n    const providerRaw = readTrimmedString(item.provider)\n    const modelIdRaw = readTrimmedString(item.modelId)\n    const modelKeyRaw = readTrimmedString(item.modelKey)\n    const parsedModelKey = parseModelKeyStrict(modelKeyRaw)\n\n    const sourceProvider = providerRaw || parsedModelKey?.provider || ''\n    const sourceModelId = modelIdRaw || parsedModelKey?.modelId || ''\n    if (!sourceProvider || !sourceModelId) {\n      return {\n        ok: false,\n        nextRaw: null,\n        changed: true,\n        migratedModels: 0,\n        collisionsResolvedByBailian: 0,\n      }\n    }\n\n    const nextProvider = migrateProviderId(sourceProvider)\n    const nextModelKey = composeModelKey(nextProvider, sourceModelId)\n    if (!nextModelKey) {\n      return {\n        ok: false,\n        nextRaw: null,\n        changed: true,\n        migratedModels: 0,\n        collisionsResolvedByBailian: 0,\n      }\n    }\n\n    if (nextProvider !== sourceProvider || nextModelKey !== modelKeyRaw) migratedModels += 1\n\n    const nextModel: StoredModel = {\n      ...item,\n      provider: nextProvider,\n      modelId: sourceModelId,\n      modelKey: nextModelKey,\n    }\n    const dedupeKey = nextModelKey.toLowerCase()\n    const nextPriority = providerPriorityByOriginalKey(sourceProvider)\n    const existing = deduped.get(dedupeKey)\n    if (!existing) {\n      deduped.set(dedupeKey, { model: nextModel, priority: nextPriority })\n      continue\n    }\n\n    if (nextPriority > existing.priority) {\n      deduped.set(dedupeKey, { model: nextModel, priority: nextPriority })\n      collisionsResolvedByBailian += 1\n    }\n  }\n\n  const nextModels = Array.from(deduped.values()).map((entry) => entry.model)\n  const nextRaw = nextModels.length > 0 ? JSON.stringify(nextModels) : null\n  return {\n    ok: true,\n    nextRaw,\n    changed: (rawModels || null) !== (nextRaw || null),\n    migratedModels,\n    collisionsResolvedByBailian,\n  }\n}\n\nfunction migrateModelField(\n  raw: string | null,\n): {\n  nextValue: string | null\n  changed: boolean\n  migrated: boolean\n  clearedInvalid: boolean\n} {\n  const current = toNullableModelField(raw)\n  if (!current) {\n    return {\n      nextValue: null,\n      changed: current !== raw,\n      migrated: false,\n      clearedInvalid: false,\n    }\n  }\n\n  const parsed = parseModelKeyStrict(current)\n  if (!parsed) {\n    return {\n      nextValue: null,\n      changed: true,\n      migrated: false,\n      clearedInvalid: true,\n    }\n  }\n\n  const nextProvider = migrateProviderId(parsed.provider)\n  const nextKey = composeModelKey(nextProvider, parsed.modelId)\n  return {\n    nextValue: nextKey || null,\n    changed: (nextKey || null) !== (raw || null),\n    migrated: parsed.provider !== nextProvider,\n    clearedInvalid: false,\n  }\n}\n\nfunction migrateCapabilitySelections(\n  raw: string | null,\n): {\n  ok: boolean\n  nextRaw: string | null\n  changed: boolean\n  migratedKeys: number\n} {\n  const parsed = parseJsonRecord(raw)\n  if (!parsed.ok) {\n    return {\n      ok: false,\n      nextRaw: null,\n      changed: raw !== null,\n      migratedKeys: 0,\n    }\n  }\n\n  const deduped: CapabilitySelections = {}\n  const priorities = new Map<string, number>()\n  let migratedKeys = 0\n\n  for (const [modelKey, rawSelection] of Object.entries(parsed.value)) {\n    if (!isRecord(rawSelection)) {\n      return {\n        ok: false,\n        nextRaw: null,\n        changed: raw !== null,\n        migratedKeys: 0,\n      }\n    }\n\n    const parsedKey = parseModelKeyStrict(modelKey)\n    if (!parsedKey) {\n      return {\n        ok: false,\n        nextRaw: null,\n        changed: raw !== null,\n        migratedKeys: 0,\n      }\n    }\n\n    const nextKey = migrateModelKey(parsedKey.modelKey)\n    if (nextKey !== parsedKey.modelKey) migratedKeys += 1\n\n    const nextSelection: Record<string, string | number | boolean> = {}\n    for (const [field, value] of Object.entries(rawSelection)) {\n      if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {\n        return {\n          ok: false,\n          nextRaw: null,\n          changed: raw !== null,\n          migratedKeys: 0,\n        }\n      }\n      nextSelection[field] = value\n    }\n\n    const sourcePriority = providerPriorityByOriginalKey(parsedKey.provider)\n    const existingPriority = priorities.get(nextKey)\n    if (existingPriority === undefined || sourcePriority > existingPriority) {\n      deduped[nextKey] = nextSelection\n      priorities.set(nextKey, sourcePriority)\n    }\n  }\n\n  const nextRaw = Object.keys(deduped).length > 0 ? JSON.stringify(deduped) : null\n  return {\n    ok: true,\n    nextRaw,\n    changed: (raw || null) !== (nextRaw || null),\n    migratedKeys,\n  }\n}\n\nfunction toIndexNumber(value: number | string): number {\n  if (typeof value === 'number') return value\n  return Number.parseInt(value, 10)\n}\n\nfunction hasRequiredGraphArtifactUniqueIndex(rows: MysqlIndexRow[]): boolean {\n  const indexColumns = new Map<string, Array<{ seq: number; column: string; nonUnique: number }>>()\n  for (const row of rows) {\n    const seq = toIndexNumber(row.Seq_in_index)\n    const nonUnique = toIndexNumber(row.Non_unique)\n    if (!Number.isFinite(seq) || !Number.isFinite(nonUnique)) continue\n    const key = row.Key_name\n    const list = indexColumns.get(key) || []\n    list.push({\n      seq,\n      column: row.Column_name,\n      nonUnique,\n    })\n    indexColumns.set(key, list)\n  }\n\n  for (const entries of indexColumns.values()) {\n    if (entries.length !== REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS.length) continue\n    const sorted = entries.sort((a, b) => a.seq - b.seq)\n    if (sorted[0]?.nonUnique !== 0) continue\n    const columns = sorted.map((entry) => entry.column)\n    const match = columns.every((column, index) => column === REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS[index])\n    if (match) return true\n  }\n  return false\n}\n\nfunction toNumber(value: bigint | number): number {\n  if (typeof value === 'bigint') return Number(value)\n  return value\n}\n\nasync function loadGraphArtifactIndexes(): Promise<MysqlIndexRow[]> {\n  return await prisma.$queryRawUnsafe<MysqlIndexRow[]>('SHOW INDEX FROM graph_artifacts')\n}\n\nasync function countGraphArtifactDuplicateGroups(): Promise<number> {\n  const rows = await prisma.$queryRawUnsafe<CountRow[]>(\n    `SELECT COUNT(*) AS c\n       FROM (\n         SELECT 1\n         FROM graph_artifacts\n         WHERE stepKey IS NOT NULL\n         GROUP BY runId, stepKey, artifactType, refId\n         HAVING COUNT(*) > 1\n       ) duplicate_groups`,\n  )\n  return rows.length > 0 ? toNumber(rows[0].c) : 0\n}\n\nasync function sampleGraphArtifactDuplicateGroups(limit: number): Promise<DuplicateGroupRow[]> {\n  return await prisma.$queryRawUnsafe<DuplicateGroupRow[]>(\n    `SELECT runId, stepKey, artifactType, refId, COUNT(*) AS c\n     FROM graph_artifacts\n     WHERE stepKey IS NOT NULL\n     GROUP BY runId, stepKey, artifactType, refId\n     HAVING c > 1\n     LIMIT ${limit}`,\n  )\n}\n\nasync function dedupeGraphArtifacts(): Promise<number> {\n  return await prisma.$executeRawUnsafe(\n    `DELETE ga1 FROM graph_artifacts ga1\n     JOIN graph_artifacts ga2\n       ON ga1.runId = ga2.runId\n      AND ga1.stepKey = ga2.stepKey\n      AND ga1.artifactType = ga2.artifactType\n      AND ga1.refId = ga2.refId\n      AND (\n        ga1.createdAt < ga2.createdAt\n        OR (ga1.createdAt = ga2.createdAt AND ga1.id < ga2.id)\n      )\n     WHERE ga1.stepKey IS NOT NULL`,\n  )\n}\n\nasync function addGraphArtifactUniqueIndex(): Promise<void> {\n  await prisma.$executeRawUnsafe(\n    'ALTER TABLE graph_artifacts ADD UNIQUE INDEX graph_artifacts_runId_stepKey_artifactType_refId_key (runId, stepKey, artifactType, refId)',\n  )\n}\n\nasync function migrateUserPreferences(summary: MigrationSummary): Promise<void> {\n  const rows = await prisma.userPreference.findMany({\n    select: {\n      id: true,\n      userId: true,\n      customProviders: true,\n      customModels: true,\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n      audioModel: true,\n      lipSyncModel: true,\n      capabilityDefaults: true,\n    },\n  }) as UserPreferenceRow[]\n\n  summary.userPreference.scanned = rows.length\n\n  for (const row of rows) {\n    const updateData: UserPreferenceUpdateData = {}\n    let changed = false\n\n    const providerResult = migrateProviders(row.customProviders)\n    if (!providerResult.ok) {\n      updateData.customProviders = null\n      changed = changed || row.customProviders !== null\n      summary.userPreference.dirtyClearedProviders += 1\n    } else if (providerResult.changed) {\n      updateData.customProviders = providerResult.nextRaw\n      changed = true\n      summary.userPreference.migratedProviders += providerResult.migratedProviders\n      summary.userPreference.providerCollisionsResolvedByBailian += providerResult.collisionsResolvedByBailian\n    }\n\n    const modelResult = migrateModels(row.customModels)\n    if (!modelResult.ok) {\n      updateData.customModels = null\n      changed = changed || row.customModels !== null\n      summary.userPreference.dirtyClearedModels += 1\n    } else if (modelResult.changed) {\n      updateData.customModels = modelResult.nextRaw\n      changed = true\n      summary.userPreference.migratedModels += modelResult.migratedModels\n      summary.userPreference.modelCollisionsResolvedByBailian += modelResult.collisionsResolvedByBailian\n    }\n\n    const capabilityResult = migrateCapabilitySelections(row.capabilityDefaults)\n    if (!capabilityResult.ok) {\n      updateData.capabilityDefaults = null\n      changed = changed || row.capabilityDefaults !== null\n      summary.userPreference.dirtyClearedCapabilityDefaults += 1\n    } else if (capabilityResult.changed) {\n      updateData.capabilityDefaults = capabilityResult.nextRaw\n      changed = true\n      summary.userPreference.migratedCapabilityDefaultKeys += capabilityResult.migratedKeys\n    }\n\n    for (const field of DEFAULT_MODEL_FIELDS) {\n      const fieldResult = migrateModelField(row[field])\n      if (!fieldResult.changed) continue\n      updateData[field] = fieldResult.nextValue\n      changed = true\n      if (fieldResult.migrated) {\n        summary.userPreference.migratedDefaultModelFields += 1\n      }\n      if (fieldResult.clearedInvalid) {\n        summary.userPreference.invalidModelFieldsCleared += 1\n      }\n    }\n\n    if (!changed) continue\n    summary.userPreference.updated += 1\n\n    if (APPLY) {\n      await prisma.userPreference.update({\n        where: { id: row.id },\n        data: updateData,\n      })\n    }\n  }\n}\n\nasync function migrateNovelProjects(summary: MigrationSummary): Promise<void> {\n  const rows = await prisma.novelPromotionProject.findMany({\n    select: {\n      id: true,\n      projectId: true,\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n      capabilityOverrides: true,\n    },\n  }) as NovelProjectRow[]\n\n  summary.novelPromotionProject.scanned = rows.length\n\n  for (const row of rows) {\n    const updateData: NovelProjectUpdateData = {}\n    let changed = false\n\n    for (const field of PROJECT_MODEL_FIELDS) {\n      const fieldResult = migrateModelField(row[field])\n      if (!fieldResult.changed) continue\n      updateData[field] = fieldResult.nextValue\n      changed = true\n      if (fieldResult.migrated) {\n        summary.novelPromotionProject.migratedModelFields += 1\n      }\n      if (fieldResult.clearedInvalid) {\n        summary.novelPromotionProject.invalidModelFieldsCleared += 1\n      }\n    }\n\n    const capabilityResult = migrateCapabilitySelections(row.capabilityOverrides)\n    if (!capabilityResult.ok) {\n      updateData.capabilityOverrides = null\n      changed = changed || row.capabilityOverrides !== null\n      summary.novelPromotionProject.dirtyClearedCapabilityOverrides += 1\n    } else if (capabilityResult.changed) {\n      updateData.capabilityOverrides = capabilityResult.nextRaw\n      changed = true\n      summary.novelPromotionProject.migratedCapabilityOverrideKeys += capabilityResult.migratedKeys\n    }\n\n    if (!changed) continue\n    summary.novelPromotionProject.updated += 1\n\n    if (APPLY) {\n      await prisma.novelPromotionProject.update({\n        where: { id: row.id },\n        data: updateData,\n      })\n    }\n  }\n}\n\nasync function migrateGraphArtifacts(summary: MigrationSummary): Promise<void> {\n  const beforeIndexes = await loadGraphArtifactIndexes()\n  const hasRequiredBefore = hasRequiredGraphArtifactUniqueIndex(beforeIndexes)\n  const duplicateGroupsBefore = await countGraphArtifactDuplicateGroups()\n  const duplicateGroupSamples = await sampleGraphArtifactDuplicateGroups(20)\n\n  summary.graphArtifacts.hasRequiredUniqueIndexBefore = hasRequiredBefore\n  summary.graphArtifacts.duplicateGroupsBefore = duplicateGroupsBefore\n  summary.graphArtifacts.duplicateGroupSamples = duplicateGroupSamples.map((row) => ({\n    runId: row.runId,\n    stepKey: row.stepKey,\n    artifactType: row.artifactType,\n    refId: row.refId,\n    count: toNumber(row.c),\n  }))\n\n  if (APPLY && duplicateGroupsBefore > 0) {\n    const deleted = await dedupeGraphArtifacts()\n    summary.graphArtifacts.deletedRowsForDedup = deleted\n  }\n\n  const duplicateGroupsAfter = APPLY ? await countGraphArtifactDuplicateGroups() : duplicateGroupsBefore\n  summary.graphArtifacts.duplicateGroupsAfter = duplicateGroupsAfter\n\n  if (APPLY && !hasRequiredBefore) {\n    if (duplicateGroupsAfter > 0) {\n      throw new Error(\n        `GRAPH_ARTIFACT_DEDUPE_INCOMPLETE: still has ${duplicateGroupsAfter} duplicate groups, unique index not added`,\n      )\n    }\n    await addGraphArtifactUniqueIndex()\n    summary.graphArtifacts.indexAdded = true\n  }\n\n  const afterIndexes = await loadGraphArtifactIndexes()\n  summary.graphArtifacts.hasRequiredUniqueIndexAfter = hasRequiredGraphArtifactUniqueIndex(afterIndexes)\n  if (APPLY && !summary.graphArtifacts.hasRequiredUniqueIndexAfter) {\n    throw new Error('GRAPH_ARTIFACT_UNIQUE_INDEX_MISSING_AFTER_MIGRATION')\n  }\n}\n\nasync function main() {\n  const summary: MigrationSummary = {\n    mode: MODE,\n    userPreference: {\n      scanned: 0,\n      updated: 0,\n      dirtyClearedProviders: 0,\n      dirtyClearedModels: 0,\n      dirtyClearedCapabilityDefaults: 0,\n      migratedProviders: 0,\n      migratedModels: 0,\n      migratedDefaultModelFields: 0,\n      migratedCapabilityDefaultKeys: 0,\n      modelCollisionsResolvedByBailian: 0,\n      providerCollisionsResolvedByBailian: 0,\n      invalidModelFieldsCleared: 0,\n    },\n    novelPromotionProject: {\n      scanned: 0,\n      updated: 0,\n      migratedModelFields: 0,\n      migratedCapabilityOverrideKeys: 0,\n      invalidModelFieldsCleared: 0,\n      dirtyClearedCapabilityOverrides: 0,\n    },\n    graphArtifacts: {\n      hasRequiredUniqueIndexBefore: false,\n      duplicateGroupsBefore: 0,\n      duplicateGroupSamples: [],\n      deletedRowsForDedup: 0,\n      duplicateGroupsAfter: 0,\n      indexAdded: false,\n      hasRequiredUniqueIndexAfter: false,\n    },\n  }\n\n  await migrateUserPreferences(summary)\n  await migrateNovelProjects(summary)\n  await migrateGraphArtifacts(summary)\n\n  console.log(JSON.stringify(summary, null, 2))\n}\n\nmain()\n  .then(async () => {\n    await prisma.$disconnect()\n  })\n  .catch(async (error: unknown) => {\n    console.error('[migrate-release-blockers] failed', error)\n    await prisma.$disconnect()\n    process.exit(1)\n  })\n"
  },
  {
    "path": "scripts/migrations/reports/model-config-migration-report.apply.json",
    "content": "{\n  \"generatedAt\": \"2026-02-12T12:51:54.103Z\",\n  \"mode\": \"apply\",\n  \"userPreference\": {\n    \"scanned\": 7,\n    \"updated\": 1,\n    \"updatedCustomModels\": 1,\n    \"updatedDefaultFields\": 2\n  },\n  \"novelPromotionProject\": {\n    \"scanned\": 70,\n    \"updated\": 65,\n    \"updatedFields\": 189\n  },\n  \"issues\": [\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"anthropic/claude-sonnet-4.5\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"anthropic/claude-sonnet-4.5\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0574a234-e128-4196-a103-1fa8305f3ca0\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0574a234-e128-4196-a103-1fa8305f3ca0\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0a235755-3a90-44b1-813d-d00d163662b9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0a235755-3a90-44b1-813d-d00d163662b9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0a235755-3a90-44b1-813d-d00d163662b9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"155e9723-b2ef-414f-8c5e-f9e1e34de536\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"1979ed4c-0d16-4696-a9ea-4a15e0c4cd0f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"1979ed4c-0d16-4696-a9ea-4a15e0c4cd0f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"1a296d18-c305-4639-9b11-3c694cd0718d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"1a296d18-c305-4639-9b11-3c694cd0718d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"2f898804-cda1-417a-9f58-6d19fa0f134d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3.0-pro-image-portrait\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"374c975a-8223-453e-81a4-d110c7e4b685\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"374c975a-8223-453e-81a4-d110c7e4b685\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"374c975a-8223-453e-81a4-d110c7e4b685\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"49909329-a771-4b18-a305-ff7d284bcf4e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"49909329-a771-4b18-a305-ff7d284bcf4e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"49909329-a771-4b18-a305-ff7d284bcf4e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"49909329-a771-4b18-a305-ff7d284bcf4e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"4f0eaccc-5fe4-4440-bcc0-be70892626c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"4f0eaccc-5fe4-4440-bcc0-be70892626c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"55e56c35-f59f-4d32-9e99-d67b46846870\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"55e56c35-f59f-4d32-9e99-d67b46846870\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5cdef710-6aef-445a-b217-2cdb6987a68a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5cdef710-6aef-445a-b217-2cdb6987a68a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5cdef710-6aef-445a-b217-2cdb6987a68a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5cdef710-6aef-445a-b217-2cdb6987a68a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5d97db15-8e16-45dc-8fa7-5efa5a237a99\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5d97db15-8e16-45dc-8fa7-5efa5a237a99\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"673acab3-9d09-4b1f-87cc-9cafbe60af80\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"673acab3-9d09-4b1f-87cc-9cafbe60af80\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"anthropic/claude-sonnet-4.5\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"77b44773-1264-419f-b145-4c2a9cbbd977\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"77b44773-1264-419f-b145-4c2a9cbbd977\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"77b44773-1264-419f-b145-4c2a9cbbd977\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"77b44773-1264-419f-b145-4c2a9cbbd977\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"788c5fc0-e3b6-48dd-a2e5-719069726a08\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"788c5fc0-e3b6-48dd-a2e5-719069726a08\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"811a2cde-2a8e-4934-947b-18bf9d6331e0\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana-2k\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"88dbc942-e549-48f8-808b-53e57fbdb97f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"88dbc942-e549-48f8-808b-53e57fbdb97f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"8ef54b9c-0142-49ad-a9cd-ec94c5132731\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"8ef54b9c-0142-49ad-a9cd-ec94c5132731\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"8ef54b9c-0142-49ad-a9cd-ec94c5132731\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"9b48f96d-7d46-4264-abfd-82ee338eecd7\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b1013460-0798-43f2-87bd-7b018cca1d58\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana-2k\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b8740c4d-0694-4106-9831-db665225a0c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b8740c4d-0694-4106-9831-db665225a0c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b8740c4d-0694-4106-9831-db665225a0c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b9f3f933-13d6-461a-bb80-1e94b48c855d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b9f3f933-13d6-461a-bb80-1e94b48c855d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b9f3f933-13d6-461a-bb80-1e94b48c855d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b9f3f933-13d6-461a-bb80-1e94b48c855d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bcf736cd-aeb9-49ce-bb36-d878400bbfd6\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bcf736cd-aeb9-49ce-bb36-d878400bbfd6\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bcf736cd-aeb9-49ce-bb36-d878400bbfd6\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bcf736cd-aeb9-49ce-bb36-d878400bbfd6\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c38b1c73-9baa-4a03-86b7-5f6dbecc0e1e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c38b1c73-9baa-4a03-86b7-5f6dbecc0e1e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ca374b6b-5666-4940-a859-23761fbacd62\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"openai/gpt-5.2\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d1bbeef0-94d5-4247-b90f-105b432649e1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d1bbeef0-94d5-4247-b90f-105b432649e1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d2c7979d-839b-4baa-bbf0-c726f2cc09bb\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"imagen-4.0-ultra-generate-001\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d2c7979d-839b-4baa-bbf0-c726f2cc09bb\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d2c7979d-839b-4baa-bbf0-c726f2cc09bb\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"dc0669ec-effc-4626-a038-bbf48994d65a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"dc0669ec-effc-4626-a038-bbf48994d65a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"dc0669ec-effc-4626-a038-bbf48994d65a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"de428a7c-e719-4cda-874a-7c54878a14bc\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana-2k\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ec2526c0-1acd-4081-b13d-019d5d425710\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ec2526c0-1acd-4081-b13d-019d5d425710\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ec2526c0-1acd-4081-b13d-019d5d425710\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ed2d93ad-5cfa-4bd0-b373-a65aae679ef5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ee5e7440-c825-4c0c-b903-0a51d983db6f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ee5e7440-c825-4c0c-b903-0a51d983db6f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f36d983d-ba52-4849-bcda-0d710b2e04f9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"nano-banana-pro\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f36d983d-ba52-4849-bcda-0d710b2e04f9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"nano-banana-pro\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f45efd24-37b1-4ca6-8808-0b5c9de205f1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f45efd24-37b1-4ca6-8808-0b5c9de205f1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f45efd24-37b1-4ca6-8808-0b5c9de205f1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ff9129c7-f76f-4ab0-8fae-84be89b62390\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ff9129c7-f76f-4ab0-8fae-84be89b62390\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ff9129c7-f76f-4ab0-8fae-84be89b62390\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    }\n  ]\n}\n"
  },
  {
    "path": "scripts/migrations/reports/model-config-migration-report.post-alias-apply.json",
    "content": "{\n  \"generatedAt\": \"2026-02-12T12:53:18.381Z\",\n  \"mode\": \"apply\",\n  \"userPreference\": {\n    \"scanned\": 7,\n    \"updated\": 4,\n    \"updatedCustomModels\": 0,\n    \"updatedDefaultFields\": 24\n  },\n  \"novelPromotionProject\": {\n    \"scanned\": 70,\n    \"updated\": 40,\n    \"updatedFields\": 106\n  },\n  \"issues\": []\n}\n"
  },
  {
    "path": "scripts/migrations/reports/model-config-migration-report.post-alias-dryrun.json",
    "content": "{\n  \"generatedAt\": \"2026-02-12T12:53:12.288Z\",\n  \"mode\": \"dry-run\",\n  \"userPreference\": {\n    \"scanned\": 7,\n    \"updated\": 4,\n    \"updatedCustomModels\": 0,\n    \"updatedDefaultFields\": 24\n  },\n  \"novelPromotionProject\": {\n    \"scanned\": 70,\n    \"updated\": 40,\n    \"updatedFields\": 106\n  },\n  \"issues\": []\n}\n"
  },
  {
    "path": "scripts/migrations/reports/model-config-migration-report.pre-apply.json",
    "content": "{\n  \"generatedAt\": \"2026-02-12T12:51:46.934Z\",\n  \"mode\": \"dry-run\",\n  \"userPreference\": {\n    \"scanned\": 7,\n    \"updated\": 1,\n    \"updatedCustomModels\": 1,\n    \"updatedDefaultFields\": 2\n  },\n  \"novelPromotionProject\": {\n    \"scanned\": 70,\n    \"updated\": 65,\n    \"updatedFields\": 189\n  },\n  \"issues\": [\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"anthropic/claude-sonnet-4.5\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"0c3ddcbd-d6f7-4b57-90d0-3c2fe0d48c5c\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"4c73593e-148d-4707-a2c2-087d2fd43a68\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"b33bc1c7-0ba8-475e-87e0-ba6ef897c2d2\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"userPreference\",\n      \"rowId\": \"bb2a2aa3-29f9-4f85-9e93-6363faf17dc4\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"anthropic/claude-sonnet-4.5\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"04ebb651-dc90-4c69-99d4-343dd037a9b9\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0574a234-e128-4196-a103-1fa8305f3ca0\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0574a234-e128-4196-a103-1fa8305f3ca0\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0a235755-3a90-44b1-813d-d00d163662b9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0a235755-3a90-44b1-813d-d00d163662b9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"0a235755-3a90-44b1-813d-d00d163662b9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"155e9723-b2ef-414f-8c5e-f9e1e34de536\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"1979ed4c-0d16-4696-a9ea-4a15e0c4cd0f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"1979ed4c-0d16-4696-a9ea-4a15e0c4cd0f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"1a296d18-c305-4639-9b11-3c694cd0718d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"1a296d18-c305-4639-9b11-3c694cd0718d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"2f898804-cda1-417a-9f58-6d19fa0f134d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3.0-pro-image-portrait\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"374c975a-8223-453e-81a4-d110c7e4b685\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"374c975a-8223-453e-81a4-d110c7e4b685\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"374c975a-8223-453e-81a4-d110c7e4b685\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"49909329-a771-4b18-a305-ff7d284bcf4e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"49909329-a771-4b18-a305-ff7d284bcf4e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"49909329-a771-4b18-a305-ff7d284bcf4e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"49909329-a771-4b18-a305-ff7d284bcf4e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"4f0eaccc-5fe4-4440-bcc0-be70892626c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"4f0eaccc-5fe4-4440-bcc0-be70892626c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"55e56c35-f59f-4d32-9e99-d67b46846870\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"55e56c35-f59f-4d32-9e99-d67b46846870\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5cdef710-6aef-445a-b217-2cdb6987a68a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5cdef710-6aef-445a-b217-2cdb6987a68a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5cdef710-6aef-445a-b217-2cdb6987a68a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5cdef710-6aef-445a-b217-2cdb6987a68a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5d97db15-8e16-45dc-8fa7-5efa5a237a99\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"5d97db15-8e16-45dc-8fa7-5efa5a237a99\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"673acab3-9d09-4b1f-87cc-9cafbe60af80\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"673acab3-9d09-4b1f-87cc-9cafbe60af80\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"anthropic/claude-sonnet-4.5\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"6bbb564a-14bd-4bc2-83e7-1a8e8612eb8e\",\n      \"userId\": \"edb99912-94ce-403d-97bb-2a29d5d20cb6\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"77b44773-1264-419f-b145-4c2a9cbbd977\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"77b44773-1264-419f-b145-4c2a9cbbd977\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"77b44773-1264-419f-b145-4c2a9cbbd977\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"77b44773-1264-419f-b145-4c2a9cbbd977\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"788c5fc0-e3b6-48dd-a2e5-719069726a08\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"788c5fc0-e3b6-48dd-a2e5-719069726a08\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"811a2cde-2a8e-4934-947b-18bf9d6331e0\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana-2k\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"88dbc942-e549-48f8-808b-53e57fbdb97f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"88dbc942-e549-48f8-808b-53e57fbdb97f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"8ef54b9c-0142-49ad-a9cd-ec94c5132731\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"8ef54b9c-0142-49ad-a9cd-ec94c5132731\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"8ef54b9c-0142-49ad-a9cd-ec94c5132731\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"9b48f96d-7d46-4264-abfd-82ee338eecd7\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b1013460-0798-43f2-87bd-7b018cca1d58\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana-2k\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b8740c4d-0694-4106-9831-db665225a0c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b8740c4d-0694-4106-9831-db665225a0c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b8740c4d-0694-4106-9831-db665225a0c5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b9f3f933-13d6-461a-bb80-1e94b48c855d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b9f3f933-13d6-461a-bb80-1e94b48c855d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b9f3f933-13d6-461a-bb80-1e94b48c855d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"b9f3f933-13d6-461a-bb80-1e94b48c855d\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bc31aa21-9eec-47d2-9853-e4b34d926b6a\",\n      \"userId\": \"5a28aff0-e370-4337-acf6-0f8ce86f346a\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bcf736cd-aeb9-49ce-bb36-d878400bbfd6\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bcf736cd-aeb9-49ce-bb36-d878400bbfd6\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bcf736cd-aeb9-49ce-bb36-d878400bbfd6\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"bcf736cd-aeb9-49ce-bb36-d878400bbfd6\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c38b1c73-9baa-4a03-86b7-5f6dbecc0e1e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c38b1c73-9baa-4a03-86b7-5f6dbecc0e1e\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"c61f1a58-42e8-4989-968b-5a373ae77e1a\",\n      \"userId\": \"bea0c5b7-73f0-4048-8cb9-e6ef18650a0f\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ca374b6b-5666-4940-a859-23761fbacd62\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"openai/gpt-5.2\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d1bbeef0-94d5-4247-b90f-105b432649e1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d1bbeef0-94d5-4247-b90f-105b432649e1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d2c7979d-839b-4baa-bbf0-c726f2cc09bb\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"imagen-4.0-ultra-generate-001\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d2c7979d-839b-4baa-bbf0-c726f2cc09bb\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"d2c7979d-839b-4baa-bbf0-c726f2cc09bb\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"dc0669ec-effc-4626-a038-bbf48994d65a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"dc0669ec-effc-4626-a038-bbf48994d65a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"dc0669ec-effc-4626-a038-bbf48994d65a\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"de428a7c-e719-4cda-874a-7c54878a14bc\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana-2k\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ec2526c0-1acd-4081-b13d-019d5d425710\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ec2526c0-1acd-4081-b13d-019d5d425710\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ec2526c0-1acd-4081-b13d-019d5d425710\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ed2d93ad-5cfa-4bd0-b373-a65aae679ef5\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ee5e7440-c825-4c0c-b903-0a51d983db6f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ee5e7440-c825-4c0c-b903-0a51d983db6f\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f36d983d-ba52-4849-bcda-0d710b2e04f9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"nano-banana-pro\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f36d983d-ba52-4849-bcda-0d710b2e04f9\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"editModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"nano-banana-pro\",\n      \"message\": \"editModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"analysisModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"google/gemini-3-pro-preview\",\n      \"message\": \"analysisModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"banana\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f38e00d3-09b0-47a5-8caf-71d491bae4d1\",\n      \"userId\": \"91c9fe74-556d-4036-a2a0-ebd3336fd8d8\",\n      \"field\": \"videoModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"doubao-seedance-1-0-pro-250528\",\n      \"message\": \"videoModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f45efd24-37b1-4ca6-8808-0b5c9de205f1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f45efd24-37b1-4ca6-8808-0b5c9de205f1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"f45efd24-37b1-4ca6-8808-0b5c9de205f1\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ff9129c7-f76f-4ab0-8fae-84be89b62390\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"characterModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"characterModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ff9129c7-f76f-4ab0-8fae-84be89b62390\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"locationModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"seedream4.5\",\n      \"message\": \"locationModel legacy modelId cannot be mapped\"\n    },\n    {\n      \"table\": \"novelPromotionProject\",\n      \"rowId\": \"ff9129c7-f76f-4ab0-8fae-84be89b62390\",\n      \"userId\": \"3d84c341-87d7-4165-971d-a3f6c576aa21\",\n      \"field\": \"storyboardModel\",\n      \"kind\": \"LEGACY_MODEL_ID_NOT_FOUND\",\n      \"rawValue\": \"gemini-3-pro-image-preview-batch\",\n      \"message\": \"storyboardModel legacy modelId cannot be mapped\"\n    }\n  ]\n}\n"
  },
  {
    "path": "scripts/task-error-stats.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { prisma } from '@/lib/prisma'\n\nfunction parseMinutesArg() {\n  const raw = process.argv.find((arg) => arg.startsWith('--minutes='))\n  const value = raw ? Number.parseInt(raw.split('=')[1], 10) : 5\n  return Number.isFinite(value) && value > 0 ? value : 5\n}\n\nasync function main() {\n  const minutes = parseMinutesArg()\n  const since = new Date(Date.now() - minutes * 60_000)\n\n  const rows = await prisma.task.groupBy({\n    by: ['errorCode'],\n    where: {\n      status: 'failed',\n      finishedAt: { gte: since },\n    },\n    _count: {\n      _all: true,\n    },\n    orderBy: {\n      _count: {\n        errorCode: 'desc',\n      },\n    },\n  })\n\n  const total = rows.reduce((sum: number, row) => sum + (row._count?._all || 0), 0)\n\n  _ulogInfo(`[TaskErrorStats] window=${minutes}m failed_total=${total}`)\n  if (!rows.length) {\n    _ulogInfo('No failed tasks in the selected window.')\n    return\n  }\n\n  for (const row of rows) {\n    const code = row.errorCode || 'UNKNOWN'\n    const count = row?._count?._all || 0\n    const ratio = total > 0 ? ((count / total) * 100).toFixed(1) : '0.0'\n    _ulogInfo(`${code}\\t${count}\\t${ratio}%`)\n  }\n}\n\nmain()\n  .catch((error) => {\n    _ulogError('[TaskErrorStats] failed:', error?.message || error)\n    process.exit(1)\n  })\n  .finally(async () => {\n    await prisma.$disconnect()\n  })\n"
  },
  {
    "path": "scripts/test-full-image-flow.ts",
    "content": "/**\n * 模拟完整的图像生成和显示流程\n * 运行: npx tsx scripts/test-full-image-flow.ts\n */\nimport { config } from 'dotenv'\nconfig()\n\nimport { uploadObject, getStorageProvider } from '../src/lib/storage'\nimport { extractStorageKeyFromLegacyValue, resolveMediaRefFromLegacyValue, getMediaObjectByPublicId } from '../src/lib/media/service'\nimport { attachMediaFieldsToProject } from '../src/lib/media/attach'\nimport { randomUUID } from 'crypto'\n\nasync function testFullImageFlow() {\n  console.log('🧪 模拟完整图像生成和显示流程...\\n')\n\n  const provider = getStorageProvider()\n  console.log(`存储类型: ${provider.kind}\\n`)\n\n  // 1. 模拟图像生成后的上传\n  console.log('1️⃣ 模拟图像生成后上传:')\n  const testKey = `images/location-${randomUUID()}.jpg`\n  const testImageContent = Buffer.from('fake-generated-image-data')\n  \n  const storedKey = await uploadObject(testImageContent, testKey)\n  console.log(`  ✅ 上传成功，返回 key: ${storedKey}`)\n\n  // 2. 模拟数据库存储（存储 key）\n  console.log('\\n2️⃣ 模拟数据库存储:')\n  const mockDbLocation = {\n    id: 'loc-test-123',\n    name: '测试场景',\n    images: [\n      {\n        id: 'img-1',\n        imageUrl: storedKey,  // 存储的是 key，不是完整 URL\n        imageIndex: 0,\n      }\n    ]\n  }\n  console.log(`  存储的 imageUrl: ${storedKey}`)\n\n  // 3. 测试 extractStorageKeyFromLegacyValue\n  console.log('\\n3️⃣ 测试 extractStorageKeyFromLegacyValue:')\n  const extractedKey = extractStorageKeyFromLegacyValue(storedKey)\n  console.log(`  输入: ${storedKey}`)\n  console.log(`  输出: ${extractedKey}`)\n  if (extractedKey) {\n    console.log(`  ✅ 成功提取 storageKey`)\n  } else {\n    console.log(`  ❌ 未能提取 storageKey - 这是问题所在！`)\n  }\n\n  // 4. 测试 resolveMediaRefFromLegacyValue（创建 MediaObject）\n  console.log('\\n4️⃣ 测试 resolveMediaRefFromLegacyValue:')\n  try {\n    const mediaRef = await resolveMediaRefFromLegacyValue(storedKey)\n    if (mediaRef) {\n      console.log(`  ✅ MediaObject 创建/获取成功`)\n      console.log(`     id: ${mediaRef.id}`)\n      console.log(`     publicId: ${mediaRef.publicId}`)\n      console.log(`     url: ${mediaRef.url}`)\n      console.log(`     storageKey: ${mediaRef.storageKey}`)\n    } else {\n      console.log(`  ❌ MediaRef 为 null`)\n    }\n  } catch (error) {\n    console.log(`  ❌ 失败:`, error)\n  }\n\n  // 5. 测试 attachMediaFieldsToProject（完整流程）\n  console.log('\\n5️⃣ 测试 attachMediaFieldsToProject（API 层转换）:')\n  try {\n    const mockProject = {\n      id: 'proj-test',\n      locations: [mockDbLocation]\n    }\n    \n    const result = await attachMediaFieldsToProject(mockProject)\n    const location = result.locations?.[0]\n    const image = location?.images?.[0]\n    \n    console.log(`  转换后的 imageUrl: ${image?.imageUrl}`)\n    \n    if (image?.imageUrl?.startsWith('/m/')) {\n      console.log(`  ✅ 正确生成了 /m/ 格式的 URL`)\n      \n      // 提取 publicId\n      const publicId = image.imageUrl.replace('/m/', '').split('?')[0]\n      console.log(`  publicId: ${publicId}`)\n      \n      // 验证 MediaObject 存在\n      const media = await getMediaObjectByPublicId(publicId)\n      if (media) {\n        console.log(`  ✅ MediaObject 存在，storageKey: ${media.storageKey}`)\n      } else {\n        console.log(`  ❌ MediaObject 不存在！`)\n      }\n    } else if (image?.imageUrl?.startsWith('http')) {\n      console.log(`  ⚠️ 返回了完整 HTTP URL: ${image.imageUrl}`)\n    } else if (!image?.imageUrl) {\n      console.log(`  ❌ imageUrl 为空！`)\n    } else {\n      console.log(`  ⚠️ URL 格式: ${image.imageUrl}`)\n    }\n  } catch (error) {\n    console.log(`  ❌ 失败:`, error)\n  }\n\n  // 6. 测试访问 /m/ URL\n  console.log('\\n6️⃣ 测试访问 /m/ URL:')\n  try {\n    const mockProject = {\n      id: 'proj-test',\n      locations: [mockDbLocation]\n    }\n    \n    const result = await attachMediaFieldsToProject(mockProject)\n    const imageUrl = result.locations?.[0]?.images?.[0]?.imageUrl\n    \n    if (imageUrl?.startsWith('/m/')) {\n      const fullUrl = `http://localhost:3000${imageUrl}`\n      console.log(`  尝试访问: ${fullUrl}`)\n      \n      try {\n        const response = await fetch(fullUrl, { redirect: 'manual' })\n        console.log(`  状态: ${response.status}`)\n        \n        if (response.status === 200) {\n          console.log(`  ✅ /m/ 端点工作正常`)\n        } else if (response.status === 307 || response.status === 302) {\n          console.log(`  ✅ /m/ 端点返回重定向（正常）`)\n          console.log(`  Location: ${response.headers.get('location')?.substring(0, 80)}...`)\n        } else if (response.status === 404) {\n          console.log(`  ❌ MediaObject 未找到（404）`)\n        } else {\n          console.log(`  ⚠️ 状态码: ${response.status}`)\n        }\n      } catch (error) {\n        console.log(`  ⚠️ 请求失败（可能服务器未启动）:`, error)\n      }\n    } else {\n      console.log(`  跳过测试（URL 格式不正确）`)\n    }\n  } catch (error) {\n    console.log(`  跳过测试:`, error)\n  }\n\n  // 清理\n  console.log('\\n7️⃣ 清理测试数据:')\n  try {\n    const { deleteObject } = await import('../src/lib/storage')\n    await deleteObject(storedKey)\n    console.log(`  ✅ 删除成功`)\n  } catch (error) {\n    console.log(`  ⚠️ 删除失败:`, error)\n  }\n\n  console.log('\\n✨ 测试完成!')\n}\n\ntestFullImageFlow().catch(console.error)\n"
  },
  {
    "path": "scripts/test-image-url-flow.ts",
    "content": "/**\n * 图片 URL 流程测试\n * 模拟图片生成后的存储和读取流程\n * 运行: npx tsx scripts/test-image-url-flow.ts\n */\nimport { config } from 'dotenv'\nconfig()\n\nimport { uploadObject, getSignedUrl, extractStorageKey, toFetchableUrl } from '../src/lib/storage'\nimport { keyToSignedUrl, addSignedUrlToLocation } from '../src/lib/storage'\nimport { encodeImageUrls, decodeImageUrlsFromDb } from '../src/lib/contracts/image-urls-contract'\nimport { randomUUID } from 'crypto'\n\nasync function testImageUrlFlow() {\n  console.log('🧪 测试图片 URL 全流程...\\n')\n\n  // 1. 模拟上传图片到存储\n  console.log('1️⃣ 模拟上传图片:')\n  const testKey = `images/location-${randomUUID()}.jpg`\n  const testImageContent = Buffer.from('fake-image-data')\n\n  let storedKey: string\n  try {\n    storedKey = await uploadObject(testImageContent, testKey)\n    console.log(`  ✅ 上传成功，返回 key: ${storedKey}`)\n  } catch (error) {\n    console.log(`  ❌ 上传失败:`, error)\n    process.exit(1)\n  }\n\n  // 2. 模拟存储到数据库（encodeImageUrls）\n  console.log('\\n2️⃣ 模拟数据库存储（encodeImageUrls）:')\n  const imageUrlsArray = [storedKey]\n  const dbValue = encodeImageUrls(imageUrlsArray)\n  console.log(`  ✅ 数据库值: ${dbValue}`)\n\n  // 3. 模拟从数据库读取（decodeImageUrlsFromDb）\n  console.log('\\n3️⃣ 模拟数据库读取（decodeImageUrlsFromDb）:')\n  const decodedKeys = decodeImageUrlsFromDb(dbValue)\n  console.log(`  ✅ 解析出的 keys: ${JSON.stringify(decodedKeys)}`)\n\n  // 4. 测试 keyToSignedUrl（用于 API 返回给前端）\n  console.log('\\n4️⃣ 测试 keyToSignedUrl（API 层转换）:')\n  for (const key of decodedKeys) {\n    const signedUrl = keyToSignedUrl(key)\n    console.log(`  Key: ${key}`)\n    console.log(`  → Signed URL: ${signedUrl}`)\n\n    // 检查是否是 /api/storage/sign 格式\n    if (signedUrl?.startsWith('/api/storage/sign')) {\n      console.log(`  ✅ 正确生成了签名 URL 路径`)\n    } else if (signedUrl?.startsWith('http')) {\n      console.log(`  ⚠️ 返回了完整 HTTP URL，可能无法直接访问`)\n    } else {\n      console.log(`  ⚠️ URL 格式: ${signedUrl}`)\n    }\n  }\n\n  // 5. 测试 addSignedUrlToLocation（完整对象转换）\n  console.log('\\n5️⃣ 测试 addSignedUrlToLocation（完整对象转换）:')\n  const mockLocationFromDb = {\n    id: 'loc-123',\n    name: '测试场景',\n    images: [\n      {\n        id: 'img-1',\n        imageUrl: storedKey,\n        imageIndex: 0,\n      }\n    ]\n  }\n\n  const locationWithSignedUrls = addSignedUrlToLocation(mockLocationFromDb)\n  console.log(`  转换后的 location.images:`)\n  for (const img of locationWithSignedUrls.images || []) {\n    console.log(`    - imageIndex: ${img.imageIndex}`)\n    console.log(`    - imageUrl: ${img.imageUrl}`)\n\n    if (img.imageUrl?.startsWith('/api/storage/sign')) {\n      console.log(`    ✅ 正确: 是相对路径签名 URL`)\n    } else if (img.imageUrl?.startsWith('http://127.0.0.1:19000')) {\n      console.log(`    ❌ 错误: 是 MinIO 直链，可能需要签名`)\n    } else if (img.imageUrl?.startsWith('http')) {\n      console.log(`    ⚠️ 是外部 HTTP URL`)\n    } else {\n      console.log(`    ⚠️ 其他格式: ${img.imageUrl}`)\n    }\n  }\n\n  // 6. 测试 getSignedUrl 直接调用\n  console.log('\\n6️⃣ 测试 getSignedUrl 直接调用:')\n  const directSignedUrl = getSignedUrl(storedKey)\n  console.log(`  Key: ${storedKey}`)\n  console.log(`  → URL: ${directSignedUrl}`)\n\n  // 7. 测试 extractStorageKey\n  console.log('\\n7️⃣ 测试 extractStorageKey（从各种 URL 提取 key）:')\n  const testUrls = [\n    storedKey,\n    `http://127.0.0.1:19000/waoowaoo/${storedKey}`,\n    directSignedUrl,\n  ]\n  for (const url of testUrls) {\n    const extracted = extractStorageKey(url)\n    console.log(`  ${url.substring(0, 60)}...`)\n    console.log(`    → extracted: ${extracted}`)\n  }\n\n  // 8. 清理测试数据\n  console.log('\\n8️⃣ 清理测试数据:')\n  try {\n    const { deleteObject } = await import('../src/lib/storage')\n    await deleteObject(storedKey)\n    console.log(`  ✅ 删除成功`)\n  } catch (error) {\n    console.log(`  ⚠️ 删除失败（可忽略）:`, error)\n  }\n\n  console.log('\\n✨ 测试完成!')\n  console.log('\\n📋 总结:')\n  console.log('  如果第4、5步返回的是 /api/storage/sign?key=... 格式 → ✅ 正常')\n  console.log('  如果第4、5步返回的是 http://127.0.0.1:19000/... 格式 → ❌ 需要修复')\n}\n\ntestImageUrlFlow().catch(console.error)\n"
  },
  {
    "path": "scripts/test-minio.ts",
    "content": "/**\n * MinIO 存储测试脚本\n * 运行: npx tsx scripts/test-minio.ts\n */\nimport { config } from 'dotenv'\nconfig() // 加载 .env 文件\n\nimport { getStorageProvider, uploadObject, getSignedObjectUrl, getObjectBuffer, deleteObject } from '../src/lib/storage'\nimport { randomUUID } from 'crypto'\n\nasync function testMinio() {\n  console.log('🧪 开始测试 MinIO 存储...\\n')\n\n  // 1. 检查环境变量\n  console.log('1️⃣ 检查环境变量:')\n  const requiredEnv = [\n    'STORAGE_TYPE',\n    'MINIO_ENDPOINT',\n    'MINIO_ACCESS_KEY',\n    'MINIO_SECRET_KEY',\n    'MINIO_BUCKET',\n  ]\n  for (const key of requiredEnv) {\n    const value = process.env[key]\n    if (value) {\n      // 隐藏敏感信息\n      const displayValue = key.includes('SECRET') || key.includes('KEY') && key !== 'STORAGE_TYPE'\n        ? '*'.repeat(Math.min(value.length, 8))\n        : value\n      console.log(`  ✅ ${key}=${displayValue}`)\n    } else {\n      console.log(`  ❌ ${key}=未设置`)\n    }\n  }\n\n  // 2. 初始化 Provider\n  console.log('\\n2️⃣ 初始化存储 Provider:')\n  try {\n    const provider = getStorageProvider()\n    console.log(`  ✅ Provider 类型: ${provider.kind}`)\n  } catch (error) {\n    console.log(`  ❌ 初始化失败:`, error)\n    process.exit(1)\n  }\n\n  // 3. 测试上传\n  console.log('\\n3️⃣ 测试上传:')\n  const testKey = `test/${randomUUID()}.txt`\n  const testContent = `Hello MinIO! 测试时间: ${new Date().toISOString()}`\n  let uploadedKey: string\n\n  try {\n    uploadedKey = await uploadObject(Buffer.from(testContent), testKey)\n    console.log(`  ✅ 上传成功: ${uploadedKey}`)\n  } catch (error) {\n    console.log(`  ❌ 上传失败:`, error)\n    process.exit(1)\n  }\n\n  // 4. 测试获取签名 URL\n  console.log('\\n4️⃣ 测试获取签名 URL:')\n  let signedUrl: string\n  try {\n    signedUrl = await getSignedObjectUrl(uploadedKey, 300)\n    console.log(`  ✅ 签名 URL 生成成功`)\n    console.log(`     URL: ${signedUrl.substring(0, 100)}...`)\n  } catch (error) {\n    console.log(`  ❌ 签名 URL 生成失败:`, error)\n    process.exit(1)\n  }\n\n  // 5. 测试下载\n  console.log('\\n5️⃣ 测试下载:')\n  try {\n    const buffer = await getObjectBuffer(uploadedKey)\n    const content = buffer.toString()\n    if (content === testContent) {\n      console.log(`  ✅ 下载成功，内容匹配`)\n    } else {\n      console.log(`  ❌ 下载成功，但内容不匹配`)\n      console.log(`     预期: ${testContent}`)\n      console.log(`     实际: ${content}`)\n    }\n  } catch (error) {\n    console.log(`  ❌ 下载失败:`, error)\n    process.exit(1)\n  }\n\n  // 6. 通过 HTTP 访问签名 URL\n  console.log('\\n6️⃣ 测试通过 HTTP 访问签名 URL:')\n  try {\n    const response = await fetch(signedUrl)\n    if (response.ok) {\n      const content = await response.text()\n      if (content === testContent) {\n        console.log(`  ✅ HTTP 访问成功，内容匹配`)\n      } else {\n        console.log(`  ❌ HTTP 访问成功，但内容不匹配`)\n      }\n    } else {\n      console.log(`  ❌ HTTP 访问失败: ${response.status} ${response.statusText}`)\n    }\n  } catch (error) {\n    console.log(`  ❌ HTTP 请求失败:`, error)\n  }\n\n  // 7. 清理测试文件\n  console.log('\\n7️⃣ 清理测试文件:')\n  try {\n    await deleteObject(uploadedKey)\n    console.log(`  ✅ 删除成功`)\n  } catch (error) {\n    console.log(`  ❌ 删除失败:`, error)\n  }\n\n  console.log('\\n✨ 测试完成!')\n}\n\ntestMinio().catch(console.error)\n"
  },
  {
    "path": "scripts/test-regression-runner.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nif [ \"$#\" -eq 0 ]; then\n  echo \"[regression-runner] missing command\"\n  exit 2\nfi\n\nLOG_FILE=\"$(mktemp -t regression-runner.XXXXXX.log)\"\n\nset +e\n\"$@\" 2>&1 | tee \"$LOG_FILE\"\nCMD_STATUS=${PIPESTATUS[0]}\nset -e\n\nif [ \"$CMD_STATUS\" -ne 0 ]; then\n  echo\n  echo \"[regression-runner] regression failed, collecting diagnostics...\"\n\n  FAILED_FILES=\"$(grep -E '^ FAIL  ' \"$LOG_FILE\" | sed -E 's/^ FAIL  ([^ ]+).*/\\1/' | sort -u || true)\"\n  if [ -z \"$FAILED_FILES\" ]; then\n    echo \"[regression-runner] no explicit FAIL file lines found in output\"\n  else\n    echo \"[regression-runner] failed files:\"\n    while IFS= read -r file; do\n      [ -z \"$file\" ] && continue\n      echo \"  - $file\"\n      LAST_COMMIT=\"$(git log -n 1 --format='%h %ad %an %s' --date=short -- \"$file\" || true)\"\n      FIRST_COMMIT=\"$(git log --diff-filter=A --follow --format='%h %ad %an %s' --date=short -- \"$file\" | tail -n 1 || true)\"\n      if [ -n \"$LAST_COMMIT\" ]; then\n        echo \"    latest: $LAST_COMMIT\"\n      fi\n      if [ -n \"$FIRST_COMMIT\" ]; then\n        echo \"    first:  $FIRST_COMMIT\"\n      fi\n    done <<< \"$FAILED_FILES\"\n  fi\nfi\n\nrm -f \"$LOG_FILE\"\nexit \"$CMD_STATUS\"\n"
  },
  {
    "path": "scripts/test-sign-api.ts",
    "content": "/**\n * 测试 /api/storage/sign 端点\n * 运行: npx tsx scripts/test-sign-api.ts\n */\nimport { config } from 'dotenv'\nconfig()\n\nimport { uploadObject, getSignedObjectUrl } from '../src/lib/storage'\nimport { randomUUID } from 'crypto'\nimport http from 'http'\n\nasync function testSignApi() {\n  console.log('🧪 测试 /api/storage/sign API...\\n')\n\n  // 1. 上传测试文件\n  console.log('1️⃣ 上传测试文件:')\n  const testKey = `images/test-${randomUUID()}.txt`\n  const testContent = 'Hello from MinIO test!'\n  \n  await uploadObject(Buffer.from(testContent), testKey)\n  console.log(`  ✅ 上传成功: ${testKey}`)\n\n  // 2. 生成签名 URL（服务端直接调用）\n  console.log('\\n2️⃣ 服务端生成签名 URL:')\n  const signedUrl = await getSignedObjectUrl(testKey, 300)\n  console.log(`  URL: ${signedUrl}`)\n\n  // 3. 测试直接访问签名 URL\n  console.log('\\n3️⃣ 测试直接访问签名 URL:')\n  try {\n    const response = await fetch(signedUrl)\n    if (response.ok) {\n      const content = await response.text()\n      console.log(`  ✅ 访问成功，内容: \"${content}\"`)\n    } else {\n      console.log(`  ❌ 访问失败: ${response.status} ${response.statusText}`)\n    }\n  } catch (error) {\n    console.log(`  ❌ 请求失败:`, error)\n  }\n\n  // 4. 测试 /api/storage/sign 端点（模拟前端访问）\n  console.log('\\n4️⃣ 测试 /api/storage/sign 端点（模拟前端）:')\n  const signApiUrl = `http://localhost:3000/api/storage/sign?key=${encodeURIComponent(testKey)}&expires=300`\n  console.log(`  URL: ${signApiUrl}`)\n  \n  try {\n    const response = await fetch(signApiUrl, { redirect: 'manual' })\n    console.log(`  状态: ${response.status}`)\n    console.log(`  Location: ${response.headers.get('location')}`)\n    \n    if (response.status === 307 || response.status === 302) {\n      const redirectUrl = response.headers.get('location')\n      console.log(`  ✅ 重定向 URL: ${redirectUrl?.substring(0, 80)}...`)\n      \n      // 5. 测试跟随重定向\n      console.log('\\n5️⃣ 测试跟随重定向访问图片:')\n      const finalResponse = await fetch(signApiUrl, { redirect: 'follow' })\n      if (finalResponse.ok) {\n        const content = await finalResponse.text()\n        console.log(`  ✅ 最终访问成功，内容: \"${content}\"`)\n      } else {\n        console.log(`  ❌ 最终访问失败: ${finalResponse.status}`)\n      }\n    } else {\n      const body = await response.text()\n      console.log(`  响应: ${body.substring(0, 200)}`)\n    }\n  } catch (error) {\n    console.log(`  ❌ 请求失败（可能服务器未启动）:`, error)\n  }\n\n  // 6. 测试 /api/cos/image 端点（旧版兼容）\n  console.log('\\n6️⃣ 测试 /api/cos/image 端点（旧版兼容）:')\n  const cosApiUrl = `http://localhost:3000/api/cos/image?key=${encodeURIComponent(testKey)}&expires=300`\n  console.log(`  URL: ${cosApiUrl}`)\n  \n  try {\n    const response = await fetch(cosApiUrl, { redirect: 'manual' })\n    console.log(`  状态: ${response.status}`)\n    console.log(`  Location: ${response.headers.get('location')}`)\n  } catch (error) {\n    console.log(`  ❌ 请求失败（可能服务器未启动）:`, error)\n  }\n\n  // 清理\n  console.log('\\n7️⃣ 清理测试文件:')\n  const { deleteObject } = await import('../src/lib/storage')\n  await deleteObject(testKey)\n  console.log(`  ✅ 清理完成`)\n\n  console.log('\\n✨ 测试完成!')\n}\n\ntestSignApi().catch(console.error)\n"
  },
  {
    "path": "scripts/tmp-cleanup-project-models.mjs",
    "content": "import { PrismaClient } from '@prisma/client';\nconst p = new PrismaClient();\nsetTimeout(() => { console.error('TIMEOUT'); process.exit(1); }, 30000);\n\nconst userId = '3d84c341-87d7-4165-971d-a3f6c576aa21';\nconst needle = 'gemini-compatible:5b127c32-136e-4e5a-af74-8bae3e28be7a';\nconst modelFields = ['characterModel', 'locationModel', 'storyboardModel', 'editModel'];\n\n// novelPromotionData is a relation, query directly\nconst npProjects = await p.novelPromotionProject.findMany({\n    where: { project: { userId } },\n    select: { id: true, projectId: true, characterModel: true, locationModel: true, storyboardModel: true, editModel: true, project: { select: { name: true } } }\n});\n\nlet totalCleaned = 0;\n\nfor (const np of npProjects) {\n    const updates = {};\n    const cleanedFields = [];\n\n    for (const field of modelFields) {\n        if (typeof np[field] === 'string' && np[field].includes(needle)) {\n            updates[field] = '';\n            cleanedFields.push(`${field}: ${np[field]}`);\n        }\n    }\n\n    if (cleanedFields.length > 0) {\n        await p.novelPromotionProject.update({\n            where: { id: np.id },\n            data: updates\n        });\n        console.log(`✓ ${np.project.name} (${np.projectId}): cleared ${cleanedFields.length} fields`);\n        cleanedFields.forEach(f => console.log(`    - ${f}`));\n        totalCleaned++;\n    }\n}\n\nconsole.log(`\\nDone. Cleaned ${totalCleaned} projects.`);\nawait p.$disconnect();\nprocess.exit(0);\n"
  },
  {
    "path": "scripts/tmp-find-old-model.mjs",
    "content": "import { PrismaClient } from '@prisma/client';\nconst p = new PrismaClient();\nsetTimeout(() => { console.error('TIMEOUT'); process.exit(1); }, 15000);\n\nconst userId = '3d84c341-87d7-4165-971d-a3f6c576aa21';\nconst needle = 'gemini-compatible:5b';\n\n// 1. Check userPreference default models\nconst pref = await p.userPreference.findUnique({\n    where: { userId },\n    select: { analysisModel: true, characterModel: true, locationModel: true, storyboardModel: true, editModel: true, videoModel: true }\n});\nconsole.log('=== UserPreference defaults ===');\nlet found = false;\nfor (const [k, v] of Object.entries(pref || {})) {\n    if (typeof v === 'string' && v.includes(needle)) {\n        console.log('  FOUND in', k, ':', v);\n        found = true;\n    }\n}\nif (!found) console.log('  (clean)');\n\n// 2. Check novelPromotionData JSON for any reference\nconst projects = await p.project.findMany({\n    where: { userId },\n    select: { id: true, name: true, novelPromotionData: true }\n});\nconsole.log('\\n=== Project novelPromotionData ===');\nfor (const proj of projects) {\n    const data = JSON.stringify(proj.novelPromotionData || {});\n    if (data.includes(needle)) {\n        // Find which keys reference it\n        const parsed = proj.novelPromotionData;\n        for (const [k, v] of Object.entries(parsed || {})) {\n            if (typeof v === 'string' && v.includes(needle)) {\n                console.log('  FOUND in project', proj.id, '(' + proj.name + ') field:', k, '=', v);\n            }\n        }\n    }\n}\n\nawait p.$disconnect();\nprocess.exit(0);\n"
  },
  {
    "path": "scripts/watchdog.ts",
    "content": "import { createScopedLogger } from '@/lib/logging/core'\nimport { prisma } from '@/lib/prisma'\nimport { addTaskJob } from '@/lib/task/queues'\nimport { resolveTaskLocaleFromBody } from '@/lib/task/resolve-locale'\nimport { markTaskFailed } from '@/lib/task/service'\nimport { publishTaskEvent } from '@/lib/task/publisher'\nimport { TASK_EVENT_TYPE, TASK_TYPE, type TaskType } from '@/lib/task/types'\nimport { cleanupAllProjectLogs } from '@/lib/logging/file-writer'\n\nconst INTERVAL_MS = Number.parseInt(process.env.WATCHDOG_INTERVAL_MS || '30000', 10) || 30000\nconst HEARTBEAT_TIMEOUT_MS = Number.parseInt(process.env.TASK_HEARTBEAT_TIMEOUT_MS || '90000', 10) || 90000\nconst TASK_TYPE_SET: ReadonlySet<string> = new Set(Object.values(TASK_TYPE))\n// 每小时执行一次日志清理\nconst LOG_CLEANUP_INTERVAL_TICKS = Math.ceil(3600_000 / INTERVAL_MS)\nlet tickCount = 0\nconst logger = createScopedLogger({\n  module: 'watchdog',\n  action: 'watchdog.tick',\n})\n\nfunction toTaskType(value: string): TaskType | null {\n  if (TASK_TYPE_SET.has(value)) {\n    return value as TaskType\n  }\n  return null\n}\n\nfunction toTaskPayload(value: unknown): Record<string, unknown> | null {\n  if (value && typeof value === 'object' && !Array.isArray(value)) {\n    return value as Record<string, unknown>\n  }\n  return null\n}\n\nasync function recoverQueuedTasks() {\n  const rows = await prisma.task.findMany({\n    where: {\n      status: 'queued',\n      enqueuedAt: null,\n    },\n    take: 100,\n    orderBy: { createdAt: 'asc' },\n  })\n\n  for (const task of rows) {\n    const taskType = toTaskType(task.type)\n    if (!taskType) {\n      logger.error({\n        action: 'watchdog.reenqueue_invalid_type',\n        message: `invalid task type: ${task.type}`,\n        taskId: task.id,\n        projectId: task.projectId,\n        userId: task.userId,\n        errorCode: 'INVALID_PARAMS',\n        retryable: false,\n      })\n      continue\n    }\n    try {\n      const locale = resolveTaskLocaleFromBody(task.payload)\n      if (!locale) {\n        await markTaskFailed(task.id, 'TASK_LOCALE_REQUIRED', 'task locale is missing')\n        logger.error({\n          action: 'watchdog.reenqueue_locale_missing',\n          message: 'task locale is missing',\n          taskId: task.id,\n          projectId: task.projectId,\n          userId: task.userId,\n          errorCode: 'TASK_LOCALE_REQUIRED',\n          retryable: false,\n        })\n        continue\n      }\n\n      await addTaskJob({\n        taskId: task.id,\n        type: taskType,\n        locale,\n        projectId: task.projectId,\n        episodeId: task.episodeId,\n        targetType: task.targetType,\n        targetId: task.targetId,\n        payload: toTaskPayload(task.payload),\n        userId: task.userId,\n      })\n      await prisma.task.update({\n        where: { id: task.id },\n        data: {\n          enqueuedAt: new Date(),\n          enqueueAttempts: { increment: 1 },\n          lastEnqueueError: null,\n        },\n      })\n      logger.info({\n        action: 'watchdog.reenqueue',\n        message: 'watchdog re-enqueued queued task',\n        taskId: task.id,\n        projectId: task.projectId,\n        userId: task.userId,\n        details: {\n          type: task.type,\n          targetType: task.targetType,\n          targetId: task.targetId,\n        },\n      })\n    } catch (error: unknown) {\n      const message = error instanceof Error ? error.message : 're-enqueue failed'\n      await prisma.task.update({\n        where: { id: task.id },\n        data: {\n          enqueueAttempts: { increment: 1 },\n          lastEnqueueError: message,\n        },\n      })\n      logger.error({\n        action: 'watchdog.reenqueue_failed',\n        message,\n        taskId: task.id,\n        projectId: task.projectId,\n        userId: task.userId,\n        errorCode: 'EXTERNAL_ERROR',\n        retryable: true,\n      })\n    }\n  }\n}\n\nasync function cleanupZombieProcessingTasks() {\n  const timeoutAt = new Date(Date.now() - HEARTBEAT_TIMEOUT_MS)\n  const rows = await prisma.task.findMany({\n    where: {\n      status: 'processing',\n      heartbeatAt: { lt: timeoutAt },\n    },\n    take: 100,\n  })\n\n  for (const task of rows) {\n    if ((task.attempt || 0) >= (task.maxAttempts || 5)) {\n      await markTaskFailed(task.id, 'WATCHDOG_TIMEOUT', 'Task heartbeat timeout')\n      await publishTaskEvent({\n        taskId: task.id,\n        projectId: task.projectId,\n        userId: task.userId,\n        type: TASK_EVENT_TYPE.FAILED,\n        payload: { reason: 'watchdog_timeout' },\n      })\n      logger.error({\n        action: 'watchdog.fail_timeout',\n        message: 'watchdog marked task as failed due to heartbeat timeout',\n        taskId: task.id,\n        projectId: task.projectId,\n        userId: task.userId,\n        errorCode: 'WATCHDOG_TIMEOUT',\n        retryable: true,\n      })\n      continue\n    }\n\n    await prisma.task.update({\n      where: { id: task.id },\n      data: {\n        status: 'queued',\n        enqueuedAt: null,\n        heartbeatAt: null,\n        startedAt: null,\n      },\n    })\n    await publishTaskEvent({\n      taskId: task.id,\n      projectId: task.projectId,\n      userId: task.userId,\n      type: TASK_EVENT_TYPE.CREATED,\n      payload: { reason: 'watchdog_requeue' },\n    })\n    logger.warn({\n      action: 'watchdog.requeue_processing',\n      message: 'watchdog re-queued stalled processing task',\n      taskId: task.id,\n      projectId: task.projectId,\n      userId: task.userId,\n      retryable: true,\n    })\n  }\n}\n\nasync function tick() {\n  tickCount++\n  const startedAt = Date.now()\n  try {\n    await recoverQueuedTasks()\n    await cleanupZombieProcessingTasks()\n    // 每小时清理一次日志（过滤 24h 前内容）\n    if (tickCount % LOG_CLEANUP_INTERVAL_TICKS === 0) {\n      void cleanupAllProjectLogs()\n    }\n    logger.info({\n      action: 'watchdog.tick.ok',\n      message: 'watchdog tick completed',\n      durationMs: Date.now() - startedAt,\n    })\n  } catch (error: unknown) {\n    const message = error instanceof Error ? error.message : 'watchdog tick failed'\n    logger.error({\n      action: 'watchdog.tick.failed',\n      message,\n      durationMs: Date.now() - startedAt,\n      errorCode: 'INTERNAL_ERROR',\n      retryable: true,\n    })\n  }\n}\n\nlogger.info({\n  action: 'watchdog.started',\n  message: 'watchdog started',\n  details: {\n    intervalMs: INTERVAL_MS,\n    heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS,\n  },\n})\nvoid tick()\nsetInterval(() => {\n  void tick()\n}, INTERVAL_MS)\n"
  },
  {
    "path": "src/app/[locale]/auth/signin/page.tsx",
    "content": "'use client'\n\nimport { useState } from \"react\"\nimport { signIn } from \"next-auth/react\"\nimport { useTranslations } from 'next-intl'\nimport Navbar from \"@/components/Navbar\"\nimport { Link, useRouter } from '@/i18n/navigation'\n\nexport default function SignIn() {\n  const [username, setUsername] = useState(\"\")\n  const [password, setPassword] = useState(\"\")\n  const [loading, setLoading] = useState(false)\n  const [error, setError] = useState(\"\")\n  const router = useRouter()\n  const t = useTranslations('auth')\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault()\n    setLoading(true)\n    setError(\"\")\n\n    try {\n      const result = await signIn(\"credentials\", {\n        username,\n        password,\n        redirect: false,\n      })\n\n      if (result?.error === 'RateLimited') {\n        setError(t('rateLimited'))\n      } else if (result?.error) {\n        setError(t('loginFailed'))\n      } else {\n        router.push({ pathname: '/' })\n        router.refresh()\n      }\n    } catch {\n      setError(t('loginError'))\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  return (\n    <div className=\"glass-page min-h-screen\">\n      <Navbar />\n      <div className=\"flex items-center justify-center px-4 py-12\">\n        <div className=\"max-w-md w-full\">\n          <div className=\"glass-surface-modal p-8\">\n            <div className=\"text-center mb-8\">\n              <h1 className=\"text-3xl font-bold text-[var(--glass-text-primary)] mb-2\">\n                {t('welcomeBack')}\n              </h1>\n              <p className=\"text-[var(--glass-text-secondary)]\">{t('loginTo')}</p>\n            </div>\n\n            <form onSubmit={handleSubmit} className=\"space-y-6\">\n              <div>\n                <label htmlFor=\"username\" className=\"glass-field-label block mb-2\">\n                  {t('phoneNumber')}\n                </label>\n                <input\n                  id=\"username\"\n                  name=\"username\"\n                  type=\"text\"\n                  autoComplete=\"username\"\n                  value={username}\n                  onChange={(e) => setUsername(e.target.value)}\n                  required\n                  className=\"glass-input-base w-full px-4 py-3\"\n                  placeholder={t('phoneNumberPlaceholder')}\n                />\n              </div>\n\n              <div>\n                <label htmlFor=\"password\" className=\"glass-field-label block mb-2\">\n                  {t('password')}\n                </label>\n                <input\n                  id=\"password\"\n                  name=\"password\"\n                  type=\"password\"\n                  autoComplete=\"current-password\"\n                  value={password}\n                  onChange={(e) => setPassword(e.target.value)}\n                  required\n                  className=\"glass-input-base w-full px-4 py-3\"\n                  placeholder={t('passwordPlaceholder')}\n                />\n              </div>\n\n              {error && (\n                <div className=\"bg-[var(--glass-tone-danger-bg)] border border-[color:color-mix(in_srgb,var(--glass-tone-danger-fg)_22%,transparent)] text-[var(--glass-tone-danger-fg)] px-4 py-3 rounded-lg text-sm\">\n                  {error}\n                </div>\n              )}\n\n              <button\n                type=\"submit\"\n                disabled={loading}\n                className=\"glass-btn-base glass-btn-primary w-full py-3 px-4 font-semibold disabled:opacity-50 disabled:cursor-not-allowed\"\n              >\n                {loading ? t('loginButtonLoading') : t('loginButton')}\n              </button>\n            </form>\n\n            <div className=\"mt-6 text-center\">\n              <p className=\"text-[var(--glass-text-secondary)]\">\n                {t('noAccount')}{\" \"}\n                <Link href={{ pathname: '/auth/signup' }} className=\"text-[var(--glass-tone-info-fg)] hover:underline font-medium\">\n                  {t('signupNow')}\n                </Link>\n              </p>\n            </div>\n\n            <div className=\"mt-6 text-center\">\n              <Link href={{ pathname: '/' }} className=\"text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] text-sm\">\n                {t('backToHome')}\n              </Link>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/auth/signup/page.tsx",
    "content": "'use client'\n\nimport { useState } from \"react\"\nimport { useTranslations } from 'next-intl'\nimport Navbar from \"@/components/Navbar\"\nimport PasswordStrengthIndicator from \"@/components/auth/PasswordStrengthIndicator\"\nimport { apiFetch } from '@/lib/api-fetch'\nimport { Link, useRouter } from '@/i18n/navigation'\n\nexport default function SignUp() {\n  const [name, setName] = useState(\"\")\n  const [password, setPassword] = useState(\"\")\n  const [confirmPassword, setConfirmPassword] = useState(\"\")\n  const [loading, setLoading] = useState(false)\n  const [error, setError] = useState(\"\")\n  const [success, setSuccess] = useState(\"\")\n  const router = useRouter()\n  const t = useTranslations('auth')\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault()\n    setLoading(true)\n    setError(\"\")\n    setSuccess(\"\")\n\n    if (password !== confirmPassword) {\n      setError(t('passwordMismatch'))\n      setLoading(false)\n      return\n    }\n\n    if (password.length < 6) {\n      setError(t('passwordTooShort'))\n      setLoading(false)\n      return\n    }\n\n    try {\n      const response = await apiFetch(\"/api/auth/register\", {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          name,\n          password,\n        }),\n      })\n\n      const data = await response.json()\n\n      if (response.ok) {\n        setSuccess(t('signupSuccess'))\n        setTimeout(() => {\n          router.push({ pathname: '/auth/signin' })\n        }, 2000)\n      } else {\n        setError(data.message || t('signupFailed'))\n      }\n    } catch {\n      setError(t('signupError'))\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  return (\n    <div className=\"glass-page min-h-screen\">\n      <Navbar />\n      <div className=\"flex items-center justify-center px-4 py-12\">\n        <div className=\"max-w-md w-full\">\n          <div className=\"glass-surface-modal p-8\">\n            <div className=\"text-center mb-8\">\n              <h1 className=\"text-3xl font-bold text-[var(--glass-text-primary)] mb-2\">\n                {t('createAccount')}\n              </h1>\n              <p className=\"text-[var(--glass-text-secondary)]\">{t('joinPlatform')}</p>\n            </div>\n\n            <form onSubmit={handleSubmit} className=\"space-y-6\">\n              <div>\n                <label htmlFor=\"name\" className=\"glass-field-label block mb-2\">\n                  {t('phoneNumber')}\n                </label>\n                <input\n                  id=\"name\"\n                  name=\"username\"\n                  type=\"text\"\n                  autoComplete=\"username\"\n                  value={name}\n                  onChange={(e) => setName(e.target.value)}\n                  required\n                  className=\"glass-input-base w-full px-4 py-3\"\n                  placeholder={t('phoneNumberPlaceholder')}\n                />\n              </div>\n\n              <div>\n                <label htmlFor=\"password\" className=\"glass-field-label block mb-2\">\n                  {t('password')}\n                </label>\n                <input\n                  id=\"password\"\n                  name=\"password\"\n                  type=\"password\"\n                  autoComplete=\"new-password\"\n                  value={password}\n                  onChange={(e) => setPassword(e.target.value)}\n                  required\n                  className=\"glass-input-base w-full px-4 py-3\"\n                  placeholder={t('passwordMinPlaceholder')}\n                />\n                <PasswordStrengthIndicator password={password} />\n              </div>\n\n              <div>\n                <label htmlFor=\"confirmPassword\" className=\"glass-field-label block mb-2\">\n                  {t('confirmPassword')}\n                </label>\n                <input\n                  id=\"confirmPassword\"\n                  name=\"confirmPassword\"\n                  type=\"password\"\n                  autoComplete=\"new-password\"\n                  value={confirmPassword}\n                  onChange={(e) => setConfirmPassword(e.target.value)}\n                  required\n                  className=\"glass-input-base w-full px-4 py-3\"\n                  placeholder={t('confirmPasswordPlaceholder')}\n                />\n              </div>\n\n              {error && (\n                <div className=\"bg-[var(--glass-tone-danger-bg)] border border-[color:color-mix(in_srgb,var(--glass-tone-danger-fg)_22%,transparent)] text-[var(--glass-tone-danger-fg)] px-4 py-3 rounded-lg text-sm\">\n                  {error}\n                </div>\n              )}\n\n              {success && (\n                <div className=\"bg-[var(--glass-tone-success-bg)] border border-[color:color-mix(in_srgb,var(--glass-tone-success-fg)_22%,transparent)] text-[var(--glass-tone-success-fg)] px-4 py-3 rounded-lg text-sm\">\n                  {success}\n                </div>\n              )}\n\n              <button\n                type=\"submit\"\n                disabled={loading}\n                className=\"glass-btn-base glass-btn-primary w-full py-3 px-4 font-semibold disabled:opacity-50 disabled:cursor-not-allowed\"\n              >\n                {loading ? t('signupButtonLoading') : t('signupButton')}\n              </button>\n            </form>\n\n            <div className=\"mt-6 text-center\">\n              <p className=\"text-[var(--glass-text-secondary)]\">\n                {t('hasAccount')}{\" \"}\n                <Link href={{ pathname: '/auth/signin' }} className=\"text-[var(--glass-tone-info-fg)] hover:underline font-medium\">\n                  {t('signinNow')}\n                </Link>\n              </p>\n            </div>\n\n            <div className=\"mt-6 text-center\">\n              <Link href={{ pathname: '/' }} className=\"text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] text-sm\">\n                {t('backToHome')}\n              </Link>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/layout.tsx",
    "content": "import type { Metadata } from \"next\";\nimport Script from \"next/script\";\nimport { Geist, Geist_Mono } from \"next/font/google\";\nimport { NextIntlClientProvider } from 'next-intl';\nimport { getMessages, getTranslations } from 'next-intl/server';\nimport { notFound } from 'next/navigation';\nimport \"../globals.css\";\nimport { Providers } from \"./providers\";\n\nimport { locales } from '@/i18n/routing';\n\nconst geistSans = Geist({\n    variable: \"--font-geist-sans\",\n    subsets: [\"latin\"],\n});\n\nconst geistMono = Geist_Mono({\n    variable: \"--font-geist-mono\",\n    subsets: [\"latin\"],\n});\n\n\n\ntype SupportedLocale = (typeof locales)[number]\n\n// 动态元数据生成\nexport async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {\n    const { locale } = await params;\n    const t = await getTranslations({ locale, namespace: 'layout' })\n\n    return {\n        title: t('title'),\n        description: t('description'),\n        icons: {\n            icon: '/logo.ico?v=2',\n            shortcut: '/logo.ico?v=2',\n            apple: '/logo.png?v=2',\n        },\n    };\n}\n\nexport function generateStaticParams() {\n    return locales.map((locale) => ({ locale }));\n}\n\nexport default async function LocaleLayout({\n    children,\n    params,\n}: {\n    children: React.ReactNode;\n    params: Promise<{ locale: string }>;\n}) {\n    const { locale } = await params;\n\n    // 验证 locale 是否有效\n    if (!locales.includes(locale as SupportedLocale)) {\n        notFound();\n    }\n\n    // 获取翻译消息\n    const messages = await getMessages();\n\n    return (\n        <html lang={locale}>\n            <head>\n                {process.env.NODE_ENV === \"development\" && (\n                    <Script\n                        src=\"//unpkg.com/react-grab/dist/index.global.js\"\n                        crossOrigin=\"anonymous\"\n                        strategy=\"beforeInteractive\"\n                    />\n                )}\n            </head>\n            <body\n                className={`${geistSans.variable} ${geistMono.variable} antialiased`}\n            >\n                <NextIntlClientProvider messages={messages}>\n                    <Providers>\n                        {children}\n                    </Providers>\n                </NextIntlClientProvider>\n\n            </body>\n        </html>\n    );\n}\n"
  },
  {
    "path": "src/app/[locale]/page.tsx",
    "content": "'use client'\n\nimport { useEffect } from 'react'\nimport Image from 'next/image'\nimport { useTranslations } from 'next-intl'\nimport { useSession } from 'next-auth/react'\nimport { useRouter } from '@/i18n/navigation'\nimport Navbar from '@/components/Navbar'\nimport { Link } from '@/i18n/navigation'\n\nexport default function Home() {\n  const t = useTranslations('landing')\n  const { data: session, status } = useSession()\n  const router = useRouter()\n\n  // 已登录用户自动跳转到 workspace\n  useEffect(() => {\n    if (status === 'authenticated') {\n      router.replace({ pathname: '/workspace' })\n    }\n  }, [status, router])\n\n  // session 加载中或已登录（即将跳转），不渲染落地页，避免闪烁\n  if (status !== 'unauthenticated') {\n    return (\n      <div className=\"glass-page min-h-screen flex items-center justify-center\">\n        <div className=\"flex flex-col items-center gap-4\">\n          <Image\n            src=\"/logo-small.png?v=1\"\n            alt=\"waoowaoo\"\n            width={80}\n            height={80}\n            className=\"animate-pulse\"\n          />\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"glass-page min-h-screen overflow-hidden font-sans selection:bg-[var(--glass-tone-info-bg)]\">\n      {/* Navbar */}\n      <div className=\"relative z-50\">\n        <Navbar />\n      </div>\n\n      {/* Background */}\n      <div className=\"fixed inset-0 z-0 pointer-events-none\">\n        <div className=\"absolute inset-0 bg-[radial-gradient(1200px_600px_at_80%_-10%,rgba(138,170,255,0.12),transparent),radial-gradient(900px_500px_at_0%_100%,rgba(148,163,184,0.16),transparent)]\"></div>\n      </div>\n\n      <main className=\"relative z-10\">\n        <section className=\"relative min-h-screen flex items-center justify-center -mt-16 px-4\">\n          <div className=\"container mx-auto grid lg:grid-cols-2 gap-16 items-center\">\n            <div className=\"text-left space-y-8 animate-slide-up\" style={{ animationDuration: '0.8s' }}>\n              <h1 className=\"text-5xl md:text-7xl font-bold tracking-tight leading-[1.1] animate-fade-in\" style={{ animationDelay: '0.2s' }}>\n                <span className=\"block text-[var(--glass-text-primary)]\">\n                  {t('title')}\n                </span>\n                <span className=\"text-[var(--glass-tone-info-fg)]\">\n                  {t('subtitle')}\n                </span>\n              </h1>\n\n              <div className=\"flex flex-wrap gap-4 pt-4 animate-fade-in\" style={{ animationDelay: '0.6s' }}>\n                <Link\n                  href={{ pathname: '/auth/signup' }}\n                  className=\"glass-btn-base glass-btn-primary px-8 py-4 rounded-xl font-semibold transition-all duration-300\"\n                >\n                  {t('getStarted')}\n                </Link>\n              </div>\n            </div>\n\n            <div className=\"relative h-[600px] hidden lg:flex items-center justify-center animate-scale-in\" style={{ animationDuration: '1s' }}>\n              <div className=\"relative w-full max-w-md aspect-square\">\n                <div className=\"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[120%] h-[120%] bg-[radial-gradient(circle,rgba(148,163,184,0.2),transparent_65%)] rounded-full blur-3xl opacity-70\"></div>\n                <div className=\"absolute top-0 right-10 w-64 h-80 glass-surface rounded-3xl transform rotate-6 animate-float-delayed\"></div>\n                <div className=\"absolute bottom-10 left-10 w-72 h-80 glass-surface-soft rounded-3xl transform -rotate-3 animate-float-slow\"></div>\n                <div className=\"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-96 glass-surface-modal rounded-3xl overflow-hidden animate-float\">\n                  <div className=\"p-6 h-full flex flex-col\">\n                    <div className=\"w-full h-48 bg-[var(--glass-bg-muted)] rounded-2xl mb-6 relative overflow-hidden group\">\n                      <div className=\"absolute inset-0 bg-[var(--glass-tone-info-bg)]/20 group-hover:bg-[var(--glass-tone-info-bg)]/35 transition-colors\"></div>\n                      <div className=\"absolute top-4 right-4 w-8 h-8 rounded-full bg-[var(--glass-bg-surface)]\"></div>\n                      <div className=\"absolute bottom-4 left-4 w-12 h-12 rounded-lg bg-[var(--glass-bg-surface-strong)] rotate-12\"></div>\n                    </div>\n                    <div className=\"space-y-3\">\n                      <div className=\"h-3 w-3/4 bg-[var(--glass-bg-muted)] rounded-full\"></div>\n                      <div className=\"h-3 w-1/2 bg-[var(--glass-bg-muted)] rounded-full\"></div>\n                      <div className=\"pt-4 flex gap-2\">\n                        <div className=\"h-10 w-10 rounded-full bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-soft)]\"></div>\n                        <div className=\"h-10 flex-1 rounded-full bg-[var(--glass-tone-info-bg)]/40 border border-[var(--glass-stroke-base)]\"></div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </section>\n      </main>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/ApiConfigTab.tsx",
    "content": "'use client'\n\nimport { ApiConfigTabContainer } from './api-config-tab/ApiConfigTabContainer'\n\nexport default function ApiConfigTab() {\n  return <ApiConfigTabContainer />\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/DefaultModelSection.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport { useTranslations } from 'next-intl'\nimport { CustomModel } from './types'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface DefaultModelSectionProps {\n    type: 'llm' | 'image' | 'video' | 'lipsync'\n    models: CustomModel[]\n    defaultModels: {\n        analysisModel?: string\n        imageModel?: string\n        videoModel?: string\n        lipSyncModel?: string\n    }\n    onUpdateDefault: (field: string, modelKey: string) => void\n}\n\nexport function DefaultModelSection({\n    type,\n    models,\n    defaultModels,\n    onUpdateDefault\n}: DefaultModelSectionProps) {\n    const t = useTranslations('apiConfig')\n\n    // 只显示已启用的模型\n    const enabledModels = models.filter(m => m.enabled)\n\n    if (enabledModels.length === 0) {\n        return null\n    }\n\n    // 根据类型确定要显示的选择器\n    const selectors = type === 'llm'\n        ? [{ field: 'analysisModel', label: t('defaultModel.analysis') }]\n        : type === 'image'\n            ? [{ field: 'imageModel', label: t('defaultModel.image') }]\n            : type === 'video'\n                ? [{ field: 'videoModel', label: t('defaultModel.video') }]\n                : [{ field: 'lipSyncModel', label: t('lipsyncDefault') }]\n\n    return (\n        <div className=\"glass-surface rounded-2xl p-5\">\n            <div className=\"flex items-center gap-2 mb-4\">\n                <span className=\"inline-flex h-7 w-7 items-center justify-center rounded-lg border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]\">\n                    <AppIcon name=\"sparklesAlt\" className=\"w-4 h-4\" />\n                </span>\n                <h3 className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">{t('defaultModel.title')}</h3>\n            </div>\n\n            <p className=\"mb-4 text-xs text-[var(--glass-text-secondary)]\">{t('defaultModel.hint')}</p>\n\n            <div className=\"grid gap-3\">\n                {selectors.map(({ field, label }) => (\n                    <div key={field} className=\"flex items-center gap-3\">\n                        <label className=\"w-24 shrink-0 text-sm text-[var(--glass-text-secondary)]\">{label}</label>\n                        <select\n                            value={defaultModels[field as keyof typeof defaultModels] || ''}\n                            onChange={(e) => onUpdateDefault(field, e.target.value)}\n                            className=\"glass-select-base flex-1 px-3 py-2 text-sm\"\n                        >\n                            <option value=\"\">{t('defaultModel.notSelected')}</option>\n                            {enabledModels.map((model) => (\n                                <option key={model.modelKey} value={model.modelKey}>\n                                    {model.name}\n                                </option>\n                            ))}\n                        </select>\n                    </div>\n                ))}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/ProviderCard.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { ProviderAdvancedFields } from './provider-card/ProviderAdvancedFields'\nimport { ProviderBaseFields } from './provider-card/ProviderBaseFields'\nimport { ProviderCardShell } from './provider-card/ProviderCardShell'\nimport { useProviderCardState } from './provider-card/hooks/useProviderCardState'\nimport type { ProviderCardProps } from './provider-card/types'\n\nexport function ProviderCard({\n  provider,\n  dragHandle,\n  models,\n  allModels,\n  defaultModels,\n  onToggleModel,\n  onUpdateApiKey,\n  onUpdateBaseUrl,\n  onDeleteModel,\n  onUpdateModel,\n  onDeleteProvider,\n  onToggleProviderHidden,\n  onAddModel,\n  onFlushConfig,\n  hideProviderLabel,\n  showProviderLabel,\n}: ProviderCardProps) {\n  const t = useTranslations('apiConfig')\n\n  const state = useProviderCardState({\n    provider,\n    models,\n    allModels,\n    defaultModels,\n    onUpdateApiKey,\n    onUpdateBaseUrl,\n    onUpdateModel,\n    onAddModel,\n    onFlushConfig,\n    t,\n  })\n\n  return (\n    <ProviderCardShell\n      provider={provider}\n      dragHandle={dragHandle}\n      onDeleteProvider={onDeleteProvider}\n      onToggleProviderHidden={onToggleProviderHidden}\n      hideProviderLabel={hideProviderLabel}\n      showProviderLabel={showProviderLabel}\n      t={t}\n      state={state}\n    >\n      <ProviderBaseFields provider={provider} t={t} state={state} />\n      <ProviderAdvancedFields\n        provider={provider}\n        onToggleModel={onToggleModel}\n        onDeleteModel={onDeleteModel}\n        onUpdateModel={onUpdateModel}\n        t={t}\n        state={state}\n      />\n    </ProviderCardShell>\n  )\n}\n\nexport default ProviderCard\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/ProviderSection.tsx",
    "content": "'use client'\n\nimport React, { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Provider, PRESET_PROVIDERS } from './types'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ProviderSectionProps {\n    title: string\n    icon: React.ReactNode\n    type: 'audio' | 'lipsync'\n    providers: Provider[]\n    onUpdateApiKey: (providerId: string, apiKey: string) => void\n    onUpdateInfo?: (providerId: string, name: string, baseUrl?: string) => void\n    onDelete?: (providerId: string) => void\n    onAdd?: (provider: Omit<Provider, 'hasApiKey'>) => void\n    showBaseUrl?: boolean\n    showAddButton?: boolean\n}\n\nexport function ProviderSection({\n    title,\n    icon,\n    providers,\n    onUpdateApiKey,\n    onUpdateInfo,\n    onDelete,\n    onAdd,\n    showBaseUrl = false,\n    showAddButton = false\n}: ProviderSectionProps) {\n    const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({})\n    const [editingId, setEditingId] = useState<string | null>(null)\n    const [editData, setEditData] = useState({ name: '', baseUrl: '' })\n    const [showAddForm, setShowAddForm] = useState(false)\n    const [newProvider, setNewProvider] = useState({ name: '', baseUrl: '', apiKey: '' })\n    const t = useTranslations('providerSection')\n    const tc = useTranslations('common')\n\n    const isPreset = (id: string) => PRESET_PROVIDERS.some(p => p.id === id)\n\n    const handleSaveEdit = (provider: Provider) => {\n        onUpdateInfo?.(provider.id, editData.name, editData.baseUrl || undefined)\n        setEditingId(null)\n    }\n\n    const handleAdd = () => {\n        if (!newProvider.name) {\n            alert(t('fillRequired'))\n            return\n        }\n        onAdd?.({\n            id: `custom-${Date.now()}`,\n            name: newProvider.name,\n            baseUrl: newProvider.baseUrl || undefined,\n            apiKey: newProvider.apiKey\n        })\n        setNewProvider({ name: '', baseUrl: '', apiKey: '' })\n        setShowAddForm(false)\n    }\n\n    return (\n        <div className=\"glass-surface mb-5 rounded-2xl p-5\">\n            <div className=\"flex items-center justify-between mb-4\">\n                <h3 className=\"flex items-center gap-2 text-sm font-semibold text-[var(--glass-text-primary)]\">\n                    {icon}\n                    {title}\n                </h3>\n                {showAddButton && (\n                    <button\n                        onClick={() => setShowAddForm(!showAddForm)}\n                        className=\"glass-btn-base glass-btn-tone-info cursor-pointer px-2 py-1 text-xs font-medium\"\n                    >\n                        {t('addProvider')}\n                    </button>\n                )}\n            </div>\n\n            {/* 添加表单 */}\n            {showAddForm && (\n                <div className=\"glass-surface-soft mb-4 flex items-center gap-2 rounded-xl p-3\">\n                    <input\n                        type=\"text\"\n                        value={newProvider.name}\n                        onChange={e => setNewProvider({ ...newProvider, name: e.target.value })}\n                        placeholder={t('name')}\n                        className=\"glass-input-base w-24 px-2 py-1.5 text-sm\"\n                    />\n                    {showBaseUrl && (\n                        <input\n                            type=\"text\"\n                            value={newProvider.baseUrl}\n                            onChange={e => setNewProvider({ ...newProvider, baseUrl: e.target.value })}\n                            placeholder=\"Base URL\"\n                            className=\"glass-input-base flex-1 px-2 py-1.5 text-sm font-mono\"\n                        />\n                    )}\n                    <input\n                        type=\"password\"\n                        value={newProvider.apiKey}\n                        onChange={e => setNewProvider({ ...newProvider, apiKey: e.target.value })}\n                        placeholder=\"API Key\"\n                        className=\"glass-input-base w-40 px-2 py-1.5 text-sm\"\n                    />\n                    <button onClick={handleAdd} className=\"glass-btn-base glass-btn-primary rounded-lg px-3 py-1.5 text-sm\">\n                        {t('add')}\n                    </button>\n                    <button onClick={() => setShowAddForm(false)} className=\"glass-btn-base glass-btn-secondary px-2 py-1.5 text-sm\">\n                        {tc('cancel')}\n                    </button>\n                </div>\n            )}\n\n            {/* 提供商列表 */}\n            <div className=\"space-y-2\">\n                {providers.map(provider => {\n                    const isEditing = editingId === provider.id\n                    const isVisible = showApiKeys[provider.id]\n\n                    if (isEditing && showBaseUrl) {\n                        return (\n                            <div key={provider.id} className=\"glass-surface-soft flex items-center gap-3 rounded-xl px-3 py-2.5\">\n                                <input\n                                    type=\"text\"\n                                    value={editData.name}\n                                    onChange={e => setEditData({ ...editData, name: e.target.value })}\n                                    className=\"glass-input-base w-28 px-2 py-1.5 text-sm\"\n                                />\n                                <input\n                                    type=\"text\"\n                                    value={editData.baseUrl}\n                                    onChange={e => setEditData({ ...editData, baseUrl: e.target.value })}\n                                    className=\"glass-input-base flex-1 px-2 py-1.5 text-sm font-mono\"\n                                />\n                                <button onClick={() => handleSaveEdit(provider)} className=\"glass-btn-base glass-btn-primary rounded-lg px-3 py-1 text-sm\">{t('save')}</button>\n                                <button onClick={() => setEditingId(null)} className=\"glass-btn-base glass-btn-secondary rounded-lg px-2 py-1 text-sm\">{tc('cancel')}</button>\n                            </div>\n                        )\n                    }\n\n                    return (\n                        <div key={provider.id} className=\"glass-surface-soft group flex items-center gap-3 rounded-xl px-3 py-2.5\">\n                            {showBaseUrl && (\n                                <button\n                                    onClick={() => {\n                                        setEditingId(provider.id)\n                                        setEditData({ name: provider.name, baseUrl: provider.baseUrl || '' })\n                                    }}\n                                    className=\"glass-btn-base glass-btn-tone-info cursor-pointer rounded-lg p-1.5\"\n                                >\n                                    <AppIcon name=\"edit\" className=\"w-4 h-4\" />\n                                </button>\n                            )}\n                            <span className=\"w-28 truncate text-sm font-medium text-[var(--glass-text-primary)]\">{provider.name}</span>\n                            {showBaseUrl && (\n                                <span className=\"w-64 truncate font-mono text-xs text-[var(--glass-text-tertiary)]\">{provider.baseUrl}</span>\n                            )}\n                            <div className=\"relative flex-1\">\n                                <input\n                                    type={isVisible ? 'text' : 'password'}\n                                    value={provider.apiKey || ''}\n                                    onChange={e => onUpdateApiKey(provider.id, e.target.value)}\n                                    placeholder=\"API Key\"\n                                    className=\"glass-input-base w-full px-3 py-1.5 pr-9 text-sm\"\n                                />\n                                <button\n                                    onClick={() => setShowApiKeys({ ...showApiKeys, [provider.id]: !isVisible })}\n                                    className=\"glass-btn-base glass-btn-soft absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer p-1\"\n                                >\n                                    {isVisible ? (\n                                        <AppIcon name=\"eye\" className=\"w-4 h-4\" />\n                                    ) : (\n                                        <AppIcon name=\"eyeOff\" className=\"w-4 h-4\" />\n                                    )}\n                                </button>\n                            </div>\n                            {provider.apiKey && (\n                                <span className=\"glass-chip glass-chip-success px-1.5 py-0.5\">\n                                    <AppIcon name=\"checkDot\" className=\"h-3 w-3\" />\n                                </span>\n                            )}\n                            {!isPreset(provider.id) && onDelete && (\n                                <button\n                                    onClick={() => onDelete(provider.id)}\n                                    className=\"glass-btn-base glass-btn-tone-danger cursor-pointer rounded-lg p-1.5\"\n                                >\n                                    <AppIcon name=\"trash\" className=\"w-4 h-4\" />\n                                </button>\n                            )}\n                        </div>\n                    )\n                })}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/hooks.ts",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useLocale, useTranslations } from 'next-intl'\nimport { apiFetch } from '@/lib/api-fetch'\n\nimport { useState, useEffect, useRef, useCallback } from 'react'\nimport {\n    Provider,\n    CustomModel,\n    PRESET_PROVIDERS,\n    PRESET_MODELS,\n    encodeModelKey,\n    getProviderKey,\n    isPresetComingSoonModelKey,\n    resolvePresetProviderName,\n    type PricingDisplayItem,\n    type PricingDisplayMap,\n} from './types'\nimport type { CapabilitySelections, CapabilityValue } from '@/lib/model-config-contract'\nimport {\n    DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY,\n    DEFAULT_IMAGE_WORKFLOW_CONCURRENCY,\n    DEFAULT_VIDEO_WORKFLOW_CONCURRENCY,\n    normalizeWorkflowConcurrencyValue,\n} from '@/lib/workflow-concurrency'\n\ninterface DefaultModels {\n    analysisModel?: string\n    characterModel?: string\n    locationModel?: string\n    storyboardModel?: string\n    editModel?: string\n    videoModel?: string\n    audioModel?: string\n    lipSyncModel?: string\n    voiceDesignModel?: string\n}\n\ninterface WorkflowConcurrency {\n    analysis: number\n    image: number\n    video: number\n}\n\ninterface UseProvidersReturn {\n    providers: Provider[]\n    models: CustomModel[]\n    defaultModels: DefaultModels\n    workflowConcurrency: WorkflowConcurrency\n    capabilityDefaults: CapabilitySelections\n    loading: boolean\n    saveStatus: 'idle' | 'saving' | 'saved' | 'error'\n    flushConfig: () => Promise<void>\n    updateProviderHidden: (providerId: string, hidden: boolean) => void\n    updateProviderApiKey: (providerId: string, apiKey: string) => void\n    updateProviderBaseUrl: (providerId: string, baseUrl: string) => void\n    reorderProviders: (activeProviderId: string, overProviderId: string) => void\n    addProvider: (provider: Omit<Provider, 'hasApiKey'>) => void\n    deleteProvider: (providerId: string) => void\n    updateProviderInfo: (providerId: string, name: string, baseUrl?: string) => void\n    toggleModel: (modelKey: string, providerId?: string) => void\n    updateModel: (modelKey: string, updates: Partial<CustomModel>, providerId?: string) => void\n    addModel: (model: Omit<CustomModel, 'enabled'>) => void\n    deleteModel: (modelKey: string, providerId?: string) => void\n    updateDefaultModel: (field: string, modelKey: string, capabilityFieldsToDefault?: Array<{ field: string; options: CapabilityValue[] }>) => void\n    batchUpdateDefaultModels: (fields: string[], modelKey: string, capabilityFieldsToDefault?: Array<{ field: string; options: CapabilityValue[] }>) => void\n    updateWorkflowConcurrency: (field: keyof WorkflowConcurrency, value: number) => void\n    updateCapabilityDefault: (modelKey: string, field: string, value: string | number | boolean | null) => void\n    getModelsByType: (type: CustomModel['type']) => CustomModel[]\n}\n\nexport function mergeProvidersForDisplay(\n    savedProviders: Provider[],\n    presetProviders: Provider[],\n): Provider[] {\n    const merged: Provider[] = []\n    const seenProviderIds = new Set<string>()\n    const seenPresetKeys = new Set<string>()\n\n    for (const savedProvider of savedProviders) {\n        if (seenProviderIds.has(savedProvider.id)) continue\n        seenProviderIds.add(savedProvider.id)\n\n        const providerKey = getProviderKey(savedProvider.id)\n        const matchedPreset = presetProviders.find((presetProvider) => presetProvider.id === providerKey)\n        if (matchedPreset) {\n            const apiKey = savedProvider.apiKey || ''\n            const providerBaseUrl = providerKey === 'minimax'\n                ? matchedPreset.baseUrl\n                : (savedProvider.baseUrl || matchedPreset.baseUrl)\n            merged.push({\n                ...matchedPreset,\n                apiKey,\n                hasApiKey: apiKey.length > 0,\n                hidden: savedProvider.hidden === true,\n                baseUrl: providerBaseUrl,\n                apiMode: savedProvider.apiMode,\n                gatewayRoute: savedProvider.gatewayRoute,\n            })\n            seenPresetKeys.add(providerKey)\n            continue\n        }\n\n        merged.push({\n            ...savedProvider,\n            hasApiKey: !!savedProvider.apiKey,\n        })\n    }\n\n    for (const presetProvider of presetProviders) {\n        if (seenPresetKeys.has(presetProvider.id)) continue\n        merged.push({\n            ...presetProvider,\n            apiKey: '',\n            hasApiKey: false,\n            hidden: false,\n        })\n    }\n\n    return merged\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n    return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction composePricingDisplayKey(type: CustomModel['type'], provider: string, modelId: string): string {\n    return `${type}::${provider}::${modelId}`\n}\n\nfunction parsePricingDisplayMap(raw: unknown): PricingDisplayMap {\n    if (!isRecord(raw)) return {}\n\n    const map: PricingDisplayMap = {}\n    for (const [key, value] of Object.entries(raw)) {\n        if (!isRecord(value)) continue\n        const min = typeof value.min === 'number' && Number.isFinite(value.min) ? value.min : null\n        const max = typeof value.max === 'number' && Number.isFinite(value.max) ? value.max : null\n        const label = typeof value.label === 'string' ? value.label.trim() : ''\n        const input = typeof value.input === 'number' && Number.isFinite(value.input) ? value.input : undefined\n        const output = typeof value.output === 'number' && Number.isFinite(value.output) ? value.output : undefined\n        if (min === null || max === null || !label) continue\n        map[key] = {\n            min,\n            max,\n            label,\n            ...(typeof input === 'number' ? { input } : {}),\n            ...(typeof output === 'number' ? { output } : {}),\n        }\n    }\n    return map\n}\n\nconst DEFAULT_WORKFLOW_CONCURRENCY: WorkflowConcurrency = {\n    analysis: DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY,\n    image: DEFAULT_IMAGE_WORKFLOW_CONCURRENCY,\n    video: DEFAULT_VIDEO_WORKFLOW_CONCURRENCY,\n}\n\nfunction parseWorkflowConcurrency(raw: unknown): WorkflowConcurrency {\n    if (!isRecord(raw)) return DEFAULT_WORKFLOW_CONCURRENCY\n    return {\n        analysis: normalizeWorkflowConcurrencyValue(\n            raw.analysis,\n            DEFAULT_WORKFLOW_CONCURRENCY.analysis,\n        ),\n        image: normalizeWorkflowConcurrencyValue(\n            raw.image,\n            DEFAULT_WORKFLOW_CONCURRENCY.image,\n        ),\n        video: normalizeWorkflowConcurrencyValue(\n            raw.video,\n            DEFAULT_WORKFLOW_CONCURRENCY.video,\n        ),\n    }\n}\n\n/**\n * Provider keys that share pricing display with a canonical provider.\n */\nconst PRICING_DISPLAY_ALIASES: Readonly<Record<string, string>> = {\n    'gemini-compatible': 'google',\n}\n\nfunction resolvePricingDisplay(\n    map: PricingDisplayMap,\n    type: CustomModel['type'],\n    provider: string,\n    modelId: string,\n): PricingDisplayItem | null {\n    const exact = map[composePricingDisplayKey(type, provider, modelId)]\n    if (exact) return exact\n\n    const providerKey = getProviderKey(provider)\n    if (providerKey !== provider) {\n        const fallback = map[composePricingDisplayKey(type, providerKey, modelId)]\n        if (fallback) return fallback\n    }\n\n    // Fallback: check canonical provider alias (e.g. gemini-compatible → google)\n    const aliasTarget = PRICING_DISPLAY_ALIASES[providerKey]\n    if (aliasTarget) {\n        const aliasFallback = map[composePricingDisplayKey(type, aliasTarget, modelId)]\n        if (aliasFallback) return aliasFallback\n    }\n    return null\n}\n\nfunction applyPricingDisplay(model: CustomModel, map: PricingDisplayMap): CustomModel {\n    const pricing = resolvePricingDisplay(map, model.type, model.provider, model.modelId)\n    if (!pricing) {\n        // Preserve existing server-provided pricing fields (e.g. from customPricing)\n        if (model.priceLabel && model.priceLabel !== '--') {\n            return model\n        }\n        return {\n            ...model,\n            price: 0,\n            priceLabel: '--',\n            priceMin: undefined,\n            priceMax: undefined,\n            priceInput: undefined,\n            priceOutput: undefined,\n        }\n    }\n\n    return {\n        ...model,\n        price: pricing.min,\n        priceMin: pricing.min,\n        priceMax: pricing.max,\n        priceLabel: pricing.label,\n        ...(typeof pricing.input === 'number' ? { priceInput: pricing.input } : {}),\n        ...(typeof pricing.output === 'number' ? { priceOutput: pricing.output } : {}),\n    }\n}\n\nexport function useProviders(): UseProvidersReturn {\n    const locale = useLocale()\n    const t = useTranslations('apiConfig')\n    const presetProviders = PRESET_PROVIDERS.map((provider) => ({\n        ...provider,\n        name: resolvePresetProviderName(provider.id, provider.name, locale),\n    }))\n    const [providers, setProviders] = useState<Provider[]>(\n        presetProviders.map((provider) => ({ ...provider, apiKey: '', hasApiKey: false })),\n    )\n    const [models, setModels] = useState<CustomModel[]>(\n        PRESET_MODELS.map((model) => {\n            const modelKey = encodeModelKey(model.provider, model.modelId)\n            return {\n                ...model,\n                modelKey,\n                price: 0,\n                priceLabel: '--',\n                enabled: !isPresetComingSoonModelKey(modelKey),\n            }\n        }),\n    )\n    const [defaultModels, setDefaultModels] = useState<DefaultModels>({})\n    const [workflowConcurrency, setWorkflowConcurrency] = useState<WorkflowConcurrency>(DEFAULT_WORKFLOW_CONCURRENCY)\n    const [capabilityDefaults, setCapabilityDefaults] = useState<CapabilitySelections>({})\n    const [loading, setLoading] = useState(true)\n    const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')\n    const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)\n    const initializedRef = useRef(false)\n\n    // 始终持有最新值的 refs，用于避免异步保存时读到旧的闭包值\n    const latestModelsRef = useRef(models)\n    const latestProvidersRef = useRef(providers)\n    const latestDefaultModelsRef = useRef(defaultModels)\n    const latestWorkflowConcurrencyRef = useRef(workflowConcurrency)\n    const latestCapabilityDefaultsRef = useRef(capabilityDefaults)\n    useEffect(() => { latestModelsRef.current = models }, [models])\n    useEffect(() => { latestProvidersRef.current = providers }, [providers])\n    useEffect(() => { latestDefaultModelsRef.current = defaultModels }, [defaultModels])\n    useEffect(() => { latestWorkflowConcurrencyRef.current = workflowConcurrency }, [workflowConcurrency])\n    useEffect(() => { latestCapabilityDefaultsRef.current = capabilityDefaults }, [capabilityDefaults])\n\n    // 加载配置\n    useEffect(() => {\n        fetchConfig()\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [])\n\n    async function fetchConfig() {\n        initializedRef.current = false\n        let loadedSuccessfully = false\n        try {\n            const res = await apiFetch('/api/user/api-config')\n            if (!res.ok) {\n                throw new Error(`api-config load failed: HTTP ${res.status}`)\n            }\n\n            const data = await res.json()\n            const pricingDisplay = parsePricingDisplayMap((data as { pricingDisplay?: unknown }).pricingDisplay)\n\n            // 合并预设和已保存的提供商，保持 savedProviders 的顺序不变（拖拽排序依赖）\n            const savedProviders: Provider[] = data.providers || []\n            setProviders(mergeProvidersForDisplay(savedProviders, presetProviders))\n\n            // 合并预设和已保存的模型\n            const savedModelsRaw = data.models || []\n            const savedModelsNormalized = savedModelsRaw.map((m: CustomModel) => ({\n                ...m,\n                modelKey: m.modelKey || encodeModelKey(m.provider, m.modelId),\n            }))\n            const savedModels: CustomModel[] = []\n            const seen = new Set<string>()\n            for (const model of savedModelsNormalized) {\n                const key = model.modelKey\n                if (seen.has(key)) continue\n                seen.add(key)\n                savedModels.push(model)\n            }\n            const hasSavedModels = savedModels.length > 0\n            const allModels = PRESET_MODELS.map(preset => {\n                const presetModelKey = encodeModelKey(preset.provider, preset.modelId)\n                const saved = savedModels.find((m: CustomModel) =>\n                    m.modelKey === presetModelKey\n                )\n                const alwaysEnabledPreset = preset.type === 'lipsync'\n                const mergedPreset: CustomModel = {\n                    ...preset,\n                    modelKey: presetModelKey,\n                    enabled: isPresetComingSoonModelKey(presetModelKey)\n                        ? false\n                        : (hasSavedModels ? (alwaysEnabledPreset || !!saved) : false),\n                    price: 0,\n                    capabilities: saved?.capabilities ?? preset.capabilities,\n                }\n                return applyPricingDisplay(mergedPreset, pricingDisplay)\n            })\n            const customModels = savedModels.filter((m: CustomModel) =>\n                !PRESET_MODELS.find((preset) => encodeModelKey(preset.provider, preset.modelId) === m.modelKey)\n            ).map((m: CustomModel) => ({\n                ...applyPricingDisplay(m, pricingDisplay),\n                // 尊重服务端返回的 enabled 字段（后端对 disabled presets 会明确返回 enabled: false）\n                enabled: (m as CustomModel & { enabled?: boolean }).enabled !== false,\n            }))\n\n            setModels([...allModels, ...customModels])\n\n            // 加载默认模型配置\n            if (data.defaultModels) {\n                setDefaultModels(data.defaultModels)\n            }\n            setWorkflowConcurrency(parseWorkflowConcurrency((data as { workflowConcurrency?: unknown }).workflowConcurrency))\n            if (data.capabilityDefaults && typeof data.capabilityDefaults === 'object') {\n                setCapabilityDefaults(data.capabilityDefaults as CapabilitySelections)\n            }\n            loadedSuccessfully = true\n        } catch (error) {\n            _ulogError('获取配置失败:', error)\n            setSaveStatus('error')\n        } finally {\n            setLoading(false)\n            if (loadedSuccessfully) {\n                // 延迟设置 initialized，确保所有状态更新完成后才开始监听\n                setTimeout(() => {\n                    initializedRef.current = true\n                }, 100)\n            }\n        }\n    }\n\n    /**\n     * 核心保存函数：始终从 ref 读取最新值，支持传入覆盖值（解决异步闭包旧值问题）。\n     * 状态展示遵循真实保存进度：请求发起后显示「保存中」，成功后显示「已保存」。\n     */\n    const performSave = useCallback(async (\n        overrides?: {\n        defaultModels?: DefaultModels\n        workflowConcurrency?: WorkflowConcurrency\n        capabilityDefaults?: CapabilitySelections\n    },\n        optimistic = false,\n        silent = false,\n    ): Promise<boolean> => {\n        void optimistic\n        if (saveTimeoutRef.current) {\n            clearTimeout(saveTimeoutRef.current)\n            saveTimeoutRef.current = null\n        }\n        if (!silent) {\n            setSaveStatus('saving')\n        }\n        try {\n            const currentModels = latestModelsRef.current\n            const currentProviders = latestProvidersRef.current\n            const currentDefaultModels = overrides?.defaultModels ?? latestDefaultModelsRef.current\n            const currentWorkflowConcurrency = overrides?.workflowConcurrency ?? latestWorkflowConcurrencyRef.current\n            const currentCapabilityDefaults = overrides?.capabilityDefaults ?? latestCapabilityDefaultsRef.current\n            const enabledModels = currentModels.filter(m => m.enabled)\n            const res = await apiFetch('/api/user/api-config', {\n                method: 'PUT',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    models: enabledModels,\n                    providers: currentProviders,\n                    defaultModels: currentDefaultModels,\n                    workflowConcurrency: currentWorkflowConcurrency,\n                    capabilityDefaults: currentCapabilityDefaults,\n                }),\n            })\n            if (res.ok) {\n                if (!silent) {\n                    setSaveStatus('saved')\n                    setTimeout(() => setSaveStatus('idle'), 3000)\n                }\n                return true\n            } else {\n                if (!silent) setSaveStatus('error')\n                return false\n            }\n        } catch (error) {\n            _ulogError('保存失败:', error)\n            if (!silent) setSaveStatus('error')\n            return false\n        }\n    }, []) // 无依赖，所有值均从 ref 读取\n\n    const flushConfig = useCallback(async () => {\n        const success = await performSave(undefined, false, true)\n        if (!success) {\n            throw new Error('API_CONFIG_FLUSH_FAILED')\n        }\n    }, [performSave])\n\n    // 默认模型操作：选中即立刻显示已保存（与项目设置一致）\n    // capabilityFieldsToDefault：切换模型时自动将第一个 option 写入 capabilityDefaults（只填未配置字段）\n    const updateDefaultModel = useCallback((\n        field: string,\n        modelKey: string,\n        capabilityFieldsToDefault?: Array<{ field: string; options: CapabilityValue[] }>,\n    ) => {\n        setDefaultModels(prev => {\n            const next = { ...prev, [field]: modelKey }\n            latestDefaultModelsRef.current = next\n\n            if (capabilityFieldsToDefault && capabilityFieldsToDefault.length > 0) {\n                setCapabilityDefaults(prevCap => {\n                    const nextCap: CapabilitySelections = { ...prevCap }\n                    const existing = { ...(nextCap[modelKey] || {}) }\n                    let changed = false\n                    for (const def of capabilityFieldsToDefault) {\n                        if (existing[def.field] === undefined && def.options.length > 0) {\n                            existing[def.field] = def.options[0]\n                            changed = true\n                        }\n                    }\n                    if (changed) {\n                        nextCap[modelKey] = existing\n                        latestCapabilityDefaultsRef.current = nextCap\n                        void performSave({ defaultModels: next, capabilityDefaults: nextCap }, true)\n                        return nextCap\n                    }\n                    void performSave({ defaultModels: next }, true) // optimistic=true\n                    return prevCap\n                })\n            } else {\n                void performSave({ defaultModels: next }, true) // optimistic=true\n            }\n            return next\n        })\n    }, [performSave])\n\n    /** Batch-update multiple default model fields to the same model key, saving only once */\n    const batchUpdateDefaultModels = useCallback((\n        fields: string[],\n        modelKey: string,\n        capabilityFieldsToDefault?: Array<{ field: string; options: CapabilityValue[] }>,\n    ) => {\n        setDefaultModels(prev => {\n            const next = { ...prev }\n            for (const field of fields) {\n                (next as Record<string, string | undefined>)[field] = modelKey\n            }\n            latestDefaultModelsRef.current = next\n\n            if (capabilityFieldsToDefault && capabilityFieldsToDefault.length > 0) {\n                setCapabilityDefaults(prevCap => {\n                    const nextCap: CapabilitySelections = { ...prevCap }\n                    const existing = { ...(nextCap[modelKey] || {}) }\n                    let changed = false\n                    for (const def of capabilityFieldsToDefault) {\n                        if (existing[def.field] === undefined && def.options.length > 0) {\n                            existing[def.field] = def.options[0]\n                            changed = true\n                        }\n                    }\n                    if (changed) {\n                        nextCap[modelKey] = existing\n                        latestCapabilityDefaultsRef.current = nextCap\n                        void performSave({ defaultModels: next, capabilityDefaults: nextCap }, true)\n                        return nextCap\n                    }\n                    void performSave({ defaultModels: next }, true)\n                    return prevCap\n                })\n            } else {\n                void performSave({ defaultModels: next }, true)\n            }\n            return next\n        })\n    }, [performSave])\n\n    const updateCapabilityDefault = useCallback((modelKey: string, field: string, value: string | number | boolean | null) => {\n        setCapabilityDefaults((previous) => {\n            const next: CapabilitySelections = { ...previous }\n            const current = { ...(next[modelKey] || {}) }\n            if (value === null) {\n                delete current[field]\n            } else {\n                current[field] = value\n            }\n\n            if (Object.keys(current).length === 0) {\n                delete next[modelKey]\n            } else {\n                next[modelKey] = current\n            }\n            latestCapabilityDefaultsRef.current = next\n            void performSave({ capabilityDefaults: next }, true) // optimistic=true\n            return next\n        })\n    }, [performSave])\n\n    const updateWorkflowConcurrency = useCallback((field: keyof WorkflowConcurrency, value: number) => {\n        const nextValue = normalizeWorkflowConcurrencyValue(value, DEFAULT_WORKFLOW_CONCURRENCY[field])\n        setWorkflowConcurrency((previous) => {\n            const next = { ...previous, [field]: nextValue }\n            latestWorkflowConcurrencyRef.current = next\n            void performSave({ workflowConcurrency: next }, true)\n            return next\n        })\n    }, [performSave])\n\n    // 提供商操作\n    const updateProviderApiKey = useCallback((providerId: string, apiKey: string) => {\n        setProviders(prev => {\n            const next = prev.map(p =>\n                p.id === providerId ? { ...p, apiKey, hasApiKey: !!apiKey } : p\n            )\n            latestProvidersRef.current = next\n            void performSave(undefined, true)\n            return next\n        })\n    }, [performSave])\n\n    const updateProviderHidden = useCallback((providerId: string, hidden: boolean) => {\n        setProviders((previous) => {\n            const next = previous.map((provider) =>\n                provider.id === providerId ? { ...provider, hidden } : provider,\n            )\n            latestProvidersRef.current = next\n            void performSave(undefined, true)\n            return next\n        })\n    }, [performSave])\n\n    const reorderProviders = useCallback((activeProviderId: string, overProviderId: string) => {\n        if (activeProviderId === overProviderId) return\n        setProviders((previous) => {\n            const oldIndex = previous.findIndex((provider) => provider.id === activeProviderId)\n            const newIndex = previous.findIndex((provider) => provider.id === overProviderId)\n            if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) {\n                return previous\n            }\n\n            const next = [...previous]\n            const moved = next[oldIndex]\n            if (!moved) return previous\n            next.splice(oldIndex, 1)\n            next.splice(newIndex, 0, moved)\n            latestProvidersRef.current = next\n            void performSave(undefined, true)\n            return next\n        })\n    }, [performSave])\n\n    const addProvider = useCallback((provider: Omit<Provider, 'hasApiKey'>) => {\n        setProviders(prev => {\n            const normalizedProviderId = provider.id.toLowerCase()\n            if (prev.some((p) => p.id.toLowerCase() === normalizedProviderId)) {\n                alert(t('providerIdExists'))\n                return prev\n            }\n            const newProvider: Provider = { ...provider, hasApiKey: !!provider.apiKey }\n            const next = [...prev, newProvider]\n            latestProvidersRef.current = next\n\n            const providerKey = getProviderKey(provider.id)\n            if (providerKey === 'gemini-compatible') {\n                // 保存后直接 refetch：后端注入带完整 capabilities 的 Google 预设模型（disabled）\n                void performSave(undefined, true).then(() => void fetchConfig())\n            } else {\n                void performSave(undefined, true)\n            }\n            return next\n        })\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [t, performSave])\n\n    const deleteProvider = useCallback((providerId: string) => {\n        if (PRESET_PROVIDERS.find(p => p.id === providerId)) {\n            alert(t('presetProviderCannotDelete'))\n            return\n        }\n        if (confirm(t('confirmDeleteProvider'))) {\n            setProviders(prev => {\n                const next = prev.filter(p => p.id !== providerId)\n                latestProvidersRef.current = next\n                return next\n            })\n            setModels(prev => {\n                const nextModels = prev.filter(m => m.provider !== providerId)\n                setDefaultModels(prevDefaults => {\n                    const updates: DefaultModels = { ...prevDefaults }\n                    const remainingModelKeys = new Set(nextModels.map(m => m.modelKey))\n                        ; (['analysisModel', 'characterModel', 'locationModel', 'storyboardModel', 'editModel', 'videoModel', 'audioModel', 'lipSyncModel', 'voiceDesignModel'] as const)\n                            .forEach(field => {\n                                const current = updates[field]\n                                if (current && !remainingModelKeys.has(current)) {\n                                    updates[field] = ''\n                                }\n                            })\n                    latestDefaultModelsRef.current = updates\n                    return updates\n                })\n                latestModelsRef.current = nextModels\n                void performSave(undefined, true) // 删除提供商：立刻保存\n                return nextModels\n            })\n        }\n    }, [t, performSave])\n\n    const updateProviderInfo = useCallback((providerId: string, name: string, baseUrl?: string) => {\n        setProviders(prev => {\n            const next = prev.map(p =>\n                p.id === providerId ? { ...p, name, baseUrl } : p\n            )\n            latestProvidersRef.current = next\n            void performSave(undefined, true)\n            return next\n        })\n    }, [performSave])\n\n    const updateProviderBaseUrl = useCallback((providerId: string, baseUrl: string) => {\n        setProviders(prev => {\n            const next = prev.map(p =>\n                p.id === providerId ? { ...p, baseUrl } : p\n            )\n            latestProvidersRef.current = next\n            void performSave(undefined, true)\n            return next\n        })\n    }, [performSave])\n\n    // 模型操作\n    const toggleModel = useCallback((modelKey: string, providerId?: string) => {\n        if (isPresetComingSoonModelKey(modelKey)) {\n            return\n        }\n        setModels(prev => {\n            const next = prev.map(m =>\n                m.modelKey === modelKey && (providerId ? m.provider === providerId : true)\n                    ? { ...m, enabled: !m.enabled }\n                    : m\n            )\n            latestModelsRef.current = next\n            void performSave(undefined, true) // 开关操作：立刻保存\n            return next\n        })\n    }, [performSave])\n\n    const updateModel = useCallback((modelKey: string, updates: Partial<CustomModel>, providerId?: string) => {\n        let nextModelKey = ''\n        setModels(prev => {\n            const next = prev.map(m => {\n                if (m.modelKey !== modelKey || (providerId ? m.provider !== providerId : false)) return m\n                const mergedProvider = updates.provider ?? m.provider\n                const mergedModelId = updates.modelId ?? m.modelId\n                nextModelKey = encodeModelKey(mergedProvider, mergedModelId)\n                return {\n                    ...m,\n                    ...updates,\n                    provider: mergedProvider,\n                    modelId: mergedModelId,\n                    modelKey: nextModelKey,\n                    name: updates.name ?? m.name,\n                    price: updates.price ?? m.price,\n                }\n            })\n            latestModelsRef.current = next\n            return next\n        })\n        if (nextModelKey && nextModelKey !== modelKey) {\n            setDefaultModels(prev => {\n                const next = { ...prev }\n                    ; (['analysisModel', 'characterModel', 'locationModel', 'storyboardModel', 'editModel', 'videoModel', 'audioModel', 'lipSyncModel', 'voiceDesignModel'] as const)\n                        .forEach(field => {\n                            if (next[field] === modelKey) next[field] = nextModelKey\n                        })\n                latestDefaultModelsRef.current = next\n                return next\n            })\n        }\n        void performSave(undefined, false)\n    }, [performSave])\n\n    const addModel = useCallback((model: Omit<CustomModel, 'enabled'>) => {\n        setModels(prev => {\n            const next = [\n                ...prev,\n                {\n                    ...model,\n                    modelKey: model.modelKey || encodeModelKey(model.provider, model.modelId),\n                    price: 0,\n                    priceLabel: '--',\n                    enabled: true,\n                },\n            ]\n            latestModelsRef.current = next\n            void performSave(undefined, false)\n            return next\n        })\n    }, [performSave])\n\n    const deleteModel = useCallback((modelKey: string, providerId?: string) => {\n        if (PRESET_MODELS.find((model) => {\n            const presetModelKey = encodeModelKey(model.provider, model.modelId)\n            return presetModelKey === modelKey && (providerId ? model.provider === providerId : true)\n        })) {\n            alert(t('presetModelCannotDelete'))\n            return\n        }\n        if (confirm(t('confirmDeleteModel'))) {\n            setModels(prev => {\n                const nextModels = prev.filter(m =>\n                    !(m.modelKey === modelKey && (providerId ? m.provider === providerId : true))\n                )\n                setDefaultModels(prevDefaults => {\n                    const nextDefaults = { ...prevDefaults }\n                    const remainingModelKeys = new Set(nextModels.map(m => m.modelKey))\n                        ; (['analysisModel', 'characterModel', 'locationModel', 'storyboardModel', 'editModel', 'videoModel', 'audioModel', 'lipSyncModel', 'voiceDesignModel'] as const)\n                            .forEach(field => {\n                                const current = nextDefaults[field]\n                                if (current && !remainingModelKeys.has(current)) {\n                                    nextDefaults[field] = ''\n                                }\n                            })\n                    latestDefaultModelsRef.current = nextDefaults\n                    return nextDefaults\n                })\n                latestModelsRef.current = nextModels\n                void performSave(undefined, true) // 删除模型：立刻保存\n                return nextModels\n            })\n        }\n    }, [t, performSave])\n\n    // 过滤器\n    const getModelsByType = useCallback((type: CustomModel['type']) => {\n        return models.filter(m => m.type === type)\n    }, [models])\n\n    return {\n        providers,\n        models,\n        defaultModels,\n        workflowConcurrency,\n        capabilityDefaults,\n        loading,\n        saveStatus,\n        flushConfig,\n        updateProviderHidden,\n        updateProviderApiKey,\n        updateProviderBaseUrl,\n        reorderProviders,\n        addProvider,\n        deleteProvider,\n        updateProviderInfo,\n        toggleModel,\n        updateModel,\n        addModel,\n        deleteModel,\n        updateDefaultModel,\n        batchUpdateDefaultModels,\n        updateWorkflowConcurrency,\n        updateCapabilityDefault,\n        getModelsByType\n    }\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/index.ts",
    "content": "export { ProviderSection } from './ProviderSection'\nexport { DefaultModelSection } from './DefaultModelSection'\nexport { ProviderCard } from './ProviderCard'\nexport { useProviders } from './hooks'\nexport type { CustomModel, Provider } from './types'\nexport { getProviderDisplayName, getProviderKey, PRESET_PROVIDERS, encodeModelKey, parseModelKey, matchesModelKey } from './types'\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/provider-card/ModelTemplateAssistantModal.tsx",
    "content": "'use client'\n\nimport { AssistantChatModal } from '@/components/assistant/AssistantChatModal'\nimport type { ProviderCardTranslator } from './types'\nimport {\n  getAssistantSavedModelLabel,\n  type UseProviderCardStateResult,\n} from './hooks/useProviderCardState'\n\ninterface ModelTemplateAssistantModalProps {\n  t: ProviderCardTranslator\n  state: UseProviderCardStateResult\n}\n\nexport function ModelTemplateAssistantModal({ t, state }: ModelTemplateAssistantModalProps) {\n  const savedEvent = state.assistantSavedEvent\n  const completed = savedEvent !== null\n  const savedModelLabel = savedEvent ? getAssistantSavedModelLabel(savedEvent) : ''\n\n  return (\n    <AssistantChatModal\n      open={state.isAssistantOpen}\n      title={t('assistantTitle')}\n      subtitle={t('assistantSubtitle')}\n      closeLabel={t('close')}\n      userLabel={t('you')}\n      assistantLabel=\"AI\"\n      reasoningTitle={t('assistantReasoningTitle')}\n      reasoningExpandLabel={t('assistantReasoningExpand')}\n      reasoningCollapseLabel={t('assistantReasoningCollapse')}\n      emptyAssistantMessage={t('assistantWelcome')}\n      inputPlaceholder={t('assistantInputPlaceholder')}\n      sendLabel={t('assistantSend')}\n      pendingLabel={t('thinking')}\n      messages={state.assistantChat.messages}\n      input={state.assistantChat.input}\n      pending={state.assistantChat.pending}\n      completed={completed}\n      completedTitle={t('assistantCompletedTitle')}\n      completedMessage={t('assistantCompletedMessage', { model: savedModelLabel })}\n      errorMessage={state.assistantChat.error?.message}\n      onClose={state.closeAssistant}\n      onInputChange={state.assistantChat.setInput}\n      onSend={() => void state.handleAssistantSend()}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/provider-card/ProviderAdvancedFields.tsx",
    "content": "'use client'\n\nimport { useEffect, useMemo, useState } from 'react'\nimport { AppIcon } from '@/components/ui/icons'\nimport { SegmentedControl } from '@/components/ui/SegmentedControl'\nimport { getProviderKey, isPresetComingSoonModel, type CustomModel } from '../types'\nimport type { UseProviderCardStateResult } from './hooks/useProviderCardState'\nimport type {\n  ProviderCardModelType,\n  ProviderCardProps,\n  ProviderCardTranslator,\n} from './types'\n\ninterface ProviderAdvancedFieldsProps {\n  provider: ProviderCardProps['provider']\n  onToggleModel: ProviderCardProps['onToggleModel']\n  onDeleteModel: ProviderCardProps['onDeleteModel']\n  onUpdateModel: ProviderCardProps['onUpdateModel']\n  t: ProviderCardTranslator\n  state: UseProviderCardStateResult\n}\n\nconst TypeIcon = ({\n  type,\n  className = 'w-4 h-4',\n}: {\n  type: ProviderCardModelType\n  className?: string\n}) => {\n  switch (type) {\n    case 'llm':\n      return (\n        <AppIcon name=\"menu\" className={className} />\n      )\n    case 'image':\n      return (\n        <AppIcon name=\"image\" className={className} />\n      )\n    case 'video':\n      return (\n        <AppIcon name=\"video\" className={className} />\n      )\n    case 'audio':\n      return (\n        <AppIcon name=\"audioWave\" className={className} />\n      )\n  }\n}\n\nconst typeLabel = (type: ProviderCardModelType, t: ProviderCardTranslator) => {\n  switch (type) {\n    case 'llm':\n      return t('typeText')\n    case 'image':\n      return t('typeImage')\n    case 'video':\n      return t('typeVideo')\n    case 'audio':\n      return t('typeAudio')\n  }\n}\n\nconst MODEL_TYPES: readonly ProviderCardModelType[] = ['llm', 'image', 'video', 'audio']\n\nexport function getAddableModelTypesForProvider(providerId: string): ProviderCardModelType[] {\n  const providerKey = getProviderKey(providerId)\n  if (providerKey === 'openai-compatible') return ['llm', 'image', 'video']\n  return ['llm', 'image', 'video', 'audio']\n}\n\nexport function shouldShowOpenAICompatVideoHint(\n  providerId: string,\n  type: ProviderCardModelType | null,\n): boolean {\n  return getProviderKey(providerId) === 'openai-compatible' && type === 'video'\n}\n\nfunction shouldShowDefaultTabs(providerId: string): boolean {\n  const providerKey = getProviderKey(providerId)\n  return providerKey === 'openai-compatible' || providerKey === 'gemini-compatible'\n}\n\nexport function getVisibleModelTypesForProvider(\n  providerId: string,\n  groupedModels: Partial<Record<ProviderCardModelType, CustomModel[]>>,\n): ProviderCardModelType[] {\n  const shouldShowAllTabs = shouldShowDefaultTabs(providerId)\n  if (shouldShowAllTabs) {\n    return getAddableModelTypesForProvider(providerId)\n  }\n\n  return MODEL_TYPES.filter((type) => {\n    const modelsOfType = groupedModels[type]\n    return Array.isArray(modelsOfType) && modelsOfType.length > 0\n  })\n}\n\nfunction formatPriceAmount(amount: number): string {\n  const fixed = amount.toFixed(4)\n  const normalized = fixed.replace(/\\.?0+$/, '')\n  return normalized || '0'\n}\n\nfunction getModelPriceTexts(model: CustomModel, t: ProviderCardTranslator): string[] {\n  if (\n    model.type === 'llm'\n    && typeof model.priceInput === 'number'\n    && Number.isFinite(model.priceInput)\n    && typeof model.priceOutput === 'number'\n    && Number.isFinite(model.priceOutput)\n  ) {\n    return [\n      t('priceInput', { amount: `¥${formatPriceAmount(model.priceInput)}` }),\n      t('priceOutput', { amount: `¥${formatPriceAmount(model.priceOutput)}` }),\n    ]\n  }\n\n  const label = typeof model.priceLabel === 'string' ? model.priceLabel.trim() : ''\n  if (label) {\n    return label === '--' ? [] : [`¥${label}`]\n  }\n  if (typeof model.price === 'number' && Number.isFinite(model.price) && model.price > 0) {\n    return [`¥${formatPriceAmount(model.price)}`]\n  }\n  return []\n}\n\nexport function ProviderAdvancedFields({\n  provider,\n  onToggleModel,\n  onDeleteModel,\n  onUpdateModel,\n  t,\n  state,\n}: ProviderAdvancedFieldsProps) {\n  const providerKey = getProviderKey(provider.id)\n  const addableModelTypes = new Set<ProviderCardModelType>(getAddableModelTypesForProvider(provider.id))\n  const visibleTypes = useMemo(\n    () => getVisibleModelTypesForProvider(provider.id, state.groupedModels),\n    [provider.id, state.groupedModels],\n  )\n  const [activeType, setActiveType] = useState<ProviderCardModelType | null>(\n    visibleTypes[0] ?? null,\n  )\n  const activeTypeSignature = visibleTypes.join('|')\n\n  useEffect(() => {\n    if (visibleTypes.length === 0) {\n      setActiveType(null)\n      return\n    }\n    if (!activeType || !visibleTypes.includes(activeType)) {\n      setActiveType(visibleTypes[0])\n    }\n  }, [activeType, activeTypeSignature, visibleTypes])\n\n  const currentType = activeType ?? visibleTypes[0] ?? null\n  const currentModels = currentType ? (state.groupedModels[currentType] ?? []) : []\n  const shouldShowAddButton =\n    !!currentType\n    && addableModelTypes.has(currentType)\n    && state.showAddForm !== currentType\n  const defaultAddType: ProviderCardModelType = providerKey === 'openrouter' ? 'llm' : 'image'\n  const useTabbedLayout = state.hasModels || shouldShowDefaultTabs(provider.id)\n  const shouldShowVideoHint = shouldShowOpenAICompatVideoHint(provider.id, currentType)\n\n  return useTabbedLayout ? (\n    <div className=\"space-y-2.5 p-3\">\n      <SegmentedControl\n        options={visibleTypes.map((type) => ({\n          value: type,\n          label: <><TypeIcon type={type} className=\"h-3 w-3\" /><span>{typeLabel(type, t)}</span></>,\n        }))}\n        value={currentType ?? visibleTypes[0]}\n        onChange={(val) => setActiveType(val as ProviderCardModelType)}\n      />\n\n      {currentType && (\n        <div className=\"flex items-center justify-between px-1\">\n          <div className=\"flex items-center gap-2 text-[12px] font-semibold text-[var(--glass-text-primary)]\">\n            <TypeIcon type={currentType} className=\"h-3 w-3\" />\n            <span>{typeLabel(currentType, t)}</span>\n            <span className=\"rounded-full bg-[var(--glass-tone-neutral-bg)] px-1.5 py-0.5 text-[11px] font-semibold text-[var(--glass-tone-neutral-fg)]\">\n              {currentModels.length}\n            </span>\n          </div>\n          {shouldShowAddButton && (\n            <button\n              onClick={() => state.setShowAddForm(currentType)}\n              className=\"glass-btn-base glass-btn-soft px-2 py-1 text-[12px] font-medium\"\n            >\n              <AppIcon name=\"plus\" className=\"h-3.5 w-3.5\" />\n              {t('add')}\n            </button>\n          )}\n        </div>\n      )}\n\n      {currentType && state.showAddForm === currentType && addableModelTypes.has(currentType) && (\n        <div className=\"glass-surface-soft rounded-xl p-3\">\n          <div className=\"mb-2.5 flex items-center gap-2\">\n            <input\n              type=\"text\"\n              value={state.newModel.name}\n              onChange={(event) =>\n                state.setNewModel({ ...state.newModel, name: event.target.value })\n              }\n              placeholder={t('modelDisplayName')}\n              className=\"glass-input-base px-3 py-1.5 text-[12px]\"\n              autoFocus\n            />\n            <button onClick={state.handleCancelAdd} className=\"glass-icon-btn-sm\">\n              <AppIcon name=\"close\" className=\"h-4 w-4\" />\n            </button>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <input\n              type=\"text\"\n              value={state.newModel.modelId}\n              onChange={(event) =>\n                state.setNewModel({ ...state.newModel, modelId: event.target.value })\n              }\n              placeholder={t('modelActualId')}\n              className={`glass-input-base flex-1 px-3 py-1.5 text-[12px] font-mono ${currentType === 'video' && state.batchMode && provider.id === 'ark' ? 'rounded-r-none' : ''}`}\n            />\n            {currentType === 'video' && state.batchMode && provider.id === 'ark' && (\n              <span className=\"rounded-r-lg bg-[var(--glass-bg-muted)] px-2 py-1.5 font-mono text-[12px] text-[var(--glass-text-secondary)]\">\n                -batch\n              </span>\n            )}\n            <button\n              onClick={() => state.handleAddModel(currentType)}\n              disabled={state.isModelSavePending}\n              className=\"glass-btn-base glass-btn-primary px-3 py-1.5 text-[12px] font-medium\"\n            >\n              {state.isModelSavePending ? t('saving') : t('save')}\n            </button>\n          </div>\n          {shouldShowVideoHint && (\n            <p className=\"mt-2 text-xs text-[var(--glass-text-tertiary)]\">\n              {t('openaiCompatVideoOnlyHint')}\n            </p>\n          )}\n          {currentType === 'video' && provider.id === 'ark' && (\n            <div className=\"mt-2.5 flex items-center gap-2 rounded-lg bg-[var(--glass-bg-muted)] px-2 py-2\">\n              <button\n                onClick={() => state.setBatchMode(!state.batchMode)}\n                className=\"glass-check-mini\"\n                data-active={state.batchMode}\n              >\n                {state.batchMode && (\n                  <AppIcon name=\"checkSm\" className=\"h-2.5 w-2.5 text-white\" />\n                )}\n              </button>\n              <span className=\"text-xs font-medium text-[var(--glass-text-secondary)]\">\n                {t('batchModeHalfPrice')}\n              </span>\n            </div>\n          )}\n        </div>\n      )}\n\n      <div className=\"glass-surface-soft rounded-xl p-2\">\n        <div\n          className=\"glass-provider-model-scroll h-[280px] overflow-y-auto pr-1\"\n          style={{ scrollbarGutter: 'stable' }}\n        >\n          <div className=\"space-y-2\">\n            {currentModels.map((model, index) => (\n              <ModelRow\n                key={`${model.modelKey}-${index}`}\n                model={model}\n                t={t}\n                state={state}\n                onToggleModel={onToggleModel}\n                onDeleteModel={onDeleteModel}\n                onUpdateModel={onUpdateModel}\n                hasApiKey={!!provider.hasApiKey}\n              />\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  ) : (\n    <div className=\"p-3\">\n      {state.showAddForm === null ? (\n        <div className=\"text-center\">\n          <p className=\"mb-3 text-[12px] text-[var(--glass-text-tertiary)]\">{t('noModelsForProvider')}</p>\n          <div className=\"flex items-center justify-center\">\n            <button\n              onClick={() => state.setShowAddForm(defaultAddType)}\n              className=\"glass-btn-base glass-btn-soft px-3 py-1.5 text-[12px]\"\n            >\n              <AppIcon name=\"plus\" className=\"h-3.5 w-3.5\" />\n              {t('addModel')}\n            </button>\n          </div>\n        </div>\n      ) : (\n        <div className=\"glass-surface-soft rounded-xl p-3\">\n          <div className=\"mb-2.5 flex items-center gap-2\">\n            <input\n              type=\"text\"\n              value={state.newModel.name}\n              onChange={(event) =>\n                state.setNewModel({ ...state.newModel, name: event.target.value })\n              }\n              placeholder={t('modelDisplayName')}\n              className=\"glass-input-base px-3 py-1.5 text-[12px]\"\n              autoFocus\n            />\n            <button onClick={state.handleCancelAdd} className=\"glass-icon-btn-sm\">\n              <AppIcon name=\"close\" className=\"h-4 w-4\" />\n            </button>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <input\n              type=\"text\"\n              value={state.newModel.modelId}\n              onChange={(event) =>\n                state.setNewModel({ ...state.newModel, modelId: event.target.value })\n              }\n              placeholder={t('modelActualId')}\n              className=\"glass-input-base flex-1 px-3 py-1.5 text-[12px] font-mono\"\n            />\n            <button\n              onClick={() => state.showAddForm && state.handleAddModel(state.showAddForm)}\n              disabled={state.isModelSavePending}\n              className=\"glass-btn-base glass-btn-primary px-3 py-1.5 text-[12px] font-medium\"\n            >\n              {state.isModelSavePending ? t('saving') : t('save')}\n            </button>\n          </div>\n          {shouldShowOpenAICompatVideoHint(provider.id, state.showAddForm) && (\n            <p className=\"mt-2 text-xs text-[var(--glass-text-tertiary)]\">\n              {t('openaiCompatVideoOnlyHint')}\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n\ninterface ModelRowProps {\n  model: CustomModel\n  t: ProviderCardTranslator\n  state: UseProviderCardStateResult\n  onToggleModel: ProviderCardProps['onToggleModel']\n  onDeleteModel: ProviderCardProps['onDeleteModel']\n  onUpdateModel: ProviderCardProps['onUpdateModel']\n  hasApiKey: boolean\n}\n\nfunction ModelRow({\n  model,\n  t,\n  state,\n  onToggleModel,\n  onDeleteModel,\n  onUpdateModel,\n  hasApiKey,\n}: ModelRowProps) {\n  const priceTexts = getModelPriceTexts(model, t)\n  const priceText = priceTexts.join(' / ')\n  const hasPriceText = priceText.length > 0\n  const isComingSoonModel = isPresetComingSoonModel(model.provider, model.modelId)\n  const toggleDisabled = isComingSoonModel || !hasApiKey\n  const rowDisabledClass = model.enabled ? '' : 'opacity-50'\n\n  return (\n    <div className={`group flex items-center justify-between gap-2 rounded-xl bg-[var(--glass-bg-surface)] px-3 py-2 transition-colors hover:bg-[var(--glass-bg-surface-strong)] ${rowDisabledClass}`}>\n      {state.editingModelId === model.modelKey ? (\n        <>\n          <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n            <input\n              type=\"text\"\n              value={state.editModel.name}\n              onChange={(event) =>\n                state.setEditModel({ ...state.editModel, name: event.target.value })\n              }\n              className=\"glass-input-base w-full px-3 py-1.5 text-[12px]\"\n              placeholder={t('modelDisplayName')}\n            />\n            <input\n              type=\"text\"\n              value={state.editModel.modelId}\n              onChange={(event) =>\n                state.setEditModel({ ...state.editModel, modelId: event.target.value })\n              }\n              className=\"glass-input-base w-full px-3 py-1.5 text-[12px] font-mono\"\n              placeholder={t('modelActualId')}\n            />\n            {hasPriceText && (\n              <div className=\"text-xs text-[var(--glass-text-tertiary)]\">{priceText}</div>\n            )}\n          </div>\n          <div className=\"flex items-center gap-1.5\">\n            <button\n              onClick={() => state.handleSaveModel(model.modelKey)}\n              disabled={state.isModelSavePending}\n              className=\"glass-icon-btn-sm\"\n              title={t('save')}\n            >\n              {state.isModelSavePending\n                ? <span className=\"h-3.5 w-3.5 animate-spin rounded-full border-2 border-[var(--glass-text-secondary)] border-t-transparent\" />\n                : <AppIcon name=\"check\" className=\"h-4 w-4\" />}\n            </button>\n            <button\n              onClick={state.handleCancelEditModel}\n              className=\"glass-icon-btn-sm\"\n              title={t('cancel')}\n            >\n              <AppIcon name=\"close\" className=\"w-3.5 h-3.5\" />\n            </button>\n          </div>\n        </>\n      ) : (\n        <>\n          <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n            <div className=\"flex flex-wrap items-center gap-2\">\n              <span className={`text-[12px] font-semibold ${model.enabled ? 'text-[var(--glass-text-primary)]' : 'text-[var(--glass-text-secondary)]'}`}>\n                {model.name}\n              </span>\n              {state.isDefaultModel(model) && model.enabled && (\n                <span className=\"shrink-0 rounded-md bg-[var(--glass-text-primary)] px-1.5 py-0.5 text-[10px] leading-none text-white\">\n                  {t('default')}\n                </span>\n              )}\n              {hasPriceText && (\n                <span className=\"shrink-0 text-[11px] text-[var(--glass-text-tertiary)]\">{priceText}</span>\n              )}\n            </div>\n            <span className=\"break-all text-[11px] text-[var(--glass-text-tertiary)]\">{model.modelId}</span>\n          </div>\n\n          <div className=\"flex items-center gap-1.5\">\n            {!state.isPresetModel(model.modelKey) && onUpdateModel && (\n              <button\n                onClick={() => state.handleEditModel(model)}\n                className=\"glass-icon-btn-sm opacity-0 transition-opacity group-hover:opacity-100\"\n                title={t('configure')}\n              >\n                <AppIcon name=\"edit\" className=\"h-3.5 w-3.5\" />\n              </button>\n            )}\n            <button\n              onClick={() => onDeleteModel(model.modelKey)}\n              className=\"glass-icon-btn-sm opacity-0 transition-opacity hover:text-[var(--glass-tone-danger-fg)] group-hover:opacity-100\"\n            >\n              <AppIcon name=\"trash\" className=\"h-3.5 w-3.5\" />\n            </button>\n\n            <button\n              onClick={() => {\n                if (toggleDisabled) return\n                onToggleModel(model.modelKey)\n              }}\n              className={`glass-toggle ${toggleDisabled ? 'cursor-not-allowed opacity-60' : ''}`}\n              data-active={model.enabled}\n              disabled={toggleDisabled}\n              title={isComingSoonModel ? t('comingSoon') : !hasApiKey ? t('configureApiKey') : undefined}\n            >\n              <div className=\"glass-toggle-thumb\"></div>\n            </button>\n          </div>\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/provider-card/ProviderBaseFields.tsx",
    "content": "'use client'\n\nimport type { ProviderCardProps, ProviderCardTranslator } from './types'\nimport type { UseProviderCardStateResult } from './hooks/useProviderCardState'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ProviderBaseFieldsProps {\n  provider: ProviderCardProps['provider']\n  t: ProviderCardTranslator\n  state: UseProviderCardStateResult\n}\n\nexport function ProviderBaseFields({ provider, t, state }: ProviderBaseFieldsProps) {\n  const baseUrlPlaceholder = (() => {\n    switch (state.providerKey) {\n      case 'gemini-compatible':\n        return 'https://your-api-domain.com'\n      case 'openai-compatible':\n        return 'https://api.openai.com/v1'\n      default:\n        return 'http://localhost:8000'\n    }\n  })()\n\n  return (\n    <>\n      <div className=\"px-3.5 pt-2.5\">\n        <div className=\"glass-surface-soft flex items-center gap-2.5 rounded-xl px-3 py-2\">\n          <span className=\"w-[64px] shrink-0 whitespace-nowrap text-[12px] font-semibold text-[var(--glass-text-primary)]\">\n            {t('apiKeyLabel')}\n          </span>\n          {state.isEditing ? (\n            <div className=\"flex flex-1 items-center gap-2\">\n              <input\n                type=\"text\"\n                value={state.tempKey}\n                onChange={(event) => state.setTempKey(event.target.value)}\n                placeholder={t('enterApiKey')}\n                className=\"glass-input-base flex-1 px-3 py-1.5 text-[12px]\"\n                disabled={state.keyTestStatus === 'testing'}\n                autoFocus\n              />\n              <button\n                onClick={state.handleSaveKey}\n                disabled={state.keyTestStatus === 'testing'}\n                className=\"glass-icon-btn-sm disabled:opacity-50\"\n                title={state.keyTestStatus === 'failed' ? t('testRetry') : t('save')}\n              >\n                {state.keyTestStatus === 'testing' ? (\n                  <span className=\"inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent\" />\n                ) : (\n                  <AppIcon name=\"check\" className=\"h-4 w-4\" />\n                )}\n              </button>\n              <button\n                onClick={state.handleCancelEdit}\n                disabled={state.keyTestStatus === 'testing'}\n                className=\"glass-icon-btn-sm disabled:opacity-50\"\n                title={t('cancel')}\n              >\n                <AppIcon name=\"close\" className=\"h-4 w-4\" />\n              </button>\n            </div>\n          ) : (\n            <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n              {provider.hasApiKey ? (\n                <>\n                  <span className=\"min-w-0 flex-1 overflow-hidden whitespace-nowrap rounded-lg bg-[var(--glass-bg-surface)] px-3 py-1.5 font-mono text-[12px] text-[var(--glass-text-secondary)]\">\n                    {state.showKey ? provider.apiKey : state.maskedKey}\n                  </span>\n                  <div className=\"flex shrink-0 items-center gap-1\">\n                    <button\n                      onClick={() => state.setShowKey(!state.showKey)}\n                      className=\"glass-icon-btn-sm\"\n                      title={state.showKey ? t('hide') : t('show')}\n                    >\n                      {state.showKey ? (\n                        <AppIcon name=\"eye\" className=\"h-4 w-4\" />\n                      ) : (\n                        <AppIcon name=\"eyeOff\" className=\"h-4 w-4\" />\n                      )}\n                    </button>\n                    <button\n                      onClick={state.startEditKey}\n                      className=\"glass-icon-btn-sm\"\n                      title={t('configure')}\n                    >\n                      <AppIcon name=\"edit\" className=\"h-4 w-4\" />\n                    </button>\n                  </div>\n                </>\n              ) : (\n                <button\n                  onClick={state.startEditKey}\n                  className=\"glass-btn-base glass-btn-tone-info h-7 px-2.5 text-[12px] font-semibold\"\n                >\n                  <AppIcon name=\"plus\" className=\"h-3.5 w-3.5\" />\n                  <span>{t('connect')}</span>\n                </button>\n              )}\n            </div>\n          )}\n        </div>\n      </div>\n\n      {state.keyTestStatus !== 'idle' && (\n        <div className=\"px-3.5 pt-2\">\n          <div className={`space-y-2 rounded-xl border-2 p-3 ${state.keyTestStatus === 'passed'\n            ? 'border-green-500/40 bg-green-500/5'\n            : state.keyTestStatus === 'failed'\n              ? 'border-red-500/40 bg-red-500/5'\n              : 'border-[var(--glass-border)] bg-[var(--glass-bg-surface)]'\n            }`}>\n            {/* Header */}\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2 text-xs font-semibold text-[var(--glass-text-primary)]\">\n                {state.keyTestStatus === 'testing' && (\n                  <span className=\"inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent\" />\n                )}\n                {state.keyTestStatus === 'passed' && (\n                  <span className=\"text-green-500\">\n                    <AppIcon name=\"check\" className=\"h-4 w-4\" />\n                  </span>\n                )}\n                {state.keyTestStatus === 'failed' && (\n                  <span className=\"text-red-500\">\n                    <AppIcon name=\"close\" className=\"h-4 w-4\" />\n                  </span>\n                )}\n                {t('testConnection')}\n              </div>\n              {(state.keyTestStatus === 'passed' || state.keyTestStatus === 'failed') && (\n                <div className=\"flex items-center gap-1\">\n                  {/* 重新测试 */}\n                  <button\n                    onClick={state.handleTestOnly}\n                    className=\"rounded p-1 text-[var(--glass-text-tertiary)] hover:bg-[var(--glass-bg-muted)] hover:text-[var(--glass-text-primary)] transition-colors\"\n                    title={t('testRetry')}\n                  >\n                    <AppIcon name=\"refresh\" className=\"h-3 w-3\" />\n                  </button>\n                  {/* 关闭结果 */}\n                  <button\n                    onClick={state.handleDismissTest}\n                    className=\"rounded p-1 text-[var(--glass-text-tertiary)] hover:bg-[var(--glass-bg-muted)] hover:text-[var(--glass-text-primary)] transition-colors\"\n                    title={t('close')}\n                  >\n                    <AppIcon name=\"close\" className=\"h-3 w-3\" />\n                  </button>\n                </div>\n              )}\n            </div>\n\n            {/* Testing spinner when no steps yet */}\n            {state.keyTestStatus === 'testing' && state.keyTestSteps.length === 0 && (\n              <div className=\"flex items-center gap-2 text-xs text-[var(--glass-text-secondary)]\">\n                <span className=\"inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent\" />\n                {t('testing')}\n              </div>\n            )}\n\n            {/* Step results */}\n            {state.keyTestSteps.map((step) => {\n              const stepLabel = t(`testStep.${step.name}`)\n              return (\n                <div key={step.name} className=\"space-y-0.5\">\n                  <div className=\"flex items-center gap-2 text-xs\">\n                    {step.status === 'pass' && (\n                      <span className=\"text-green-500\">\n                        <AppIcon name=\"check\" className=\"h-3.5 w-3.5\" />\n                      </span>\n                    )}\n                    {step.status === 'fail' && (\n                      <span className=\"text-red-500\">\n                        <AppIcon name=\"close\" className=\"h-3.5 w-3.5\" />\n                      </span>\n                    )}\n                    {step.status === 'skip' && (\n                      <span className=\"text-[var(--glass-text-tertiary)]\">–</span>\n                    )}\n                    <span className=\"font-medium text-[var(--glass-text-primary)]\">\n                      {stepLabel}\n                    </span>\n                    {step.model && (\n                      <span className=\"rounded bg-[var(--glass-bg-surface)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--glass-text-secondary)]\">\n                        {step.model}\n                      </span>\n                    )}\n                  </div>\n                  <p className={`pl-6 text-[11px] ${step.status === 'fail' ? 'text-red-400' : 'text-[var(--glass-text-secondary)]'}`}>\n                    {step.message}\n                  </p>\n                  {step.detail && (\n                    <p className=\"pl-6 text-[10px] text-[var(--glass-text-tertiary)] break-all line-clamp-3\">\n                      {step.detail}\n                    </p>\n                  )}\n                </div>\n              )\n            })}\n\n            {/* Success banner */}\n            {state.keyTestStatus === 'passed' && (\n              <div className=\"flex items-center gap-2 rounded-lg bg-green-500/10 px-3 py-2 text-xs font-medium text-green-600 dark:text-green-400\">\n                <AppIcon name=\"check\" className=\"h-4 w-4 shrink-0\" />\n                {t('testPassed')}\n              </div>\n            )}\n\n            {/* Failure warning */}\n            {state.keyTestStatus === 'failed' && (\n              <div className=\"flex items-start gap-2 rounded-lg bg-yellow-500/10 px-3 py-2 text-[11px] text-[var(--glass-text-primary)]\">\n                <span className=\"mt-0.5 shrink-0 text-sm\">&#9888;</span>\n                <span>{t('testWarning')}</span>\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n\n      {state.showBaseUrlEdit && (\n        <div className=\"px-3.5 pb-2.5 pt-2\">\n          <div className=\"glass-surface-soft flex items-center gap-2.5 rounded-xl px-3 py-2\">\n            <span className=\"w-[64px] shrink-0 whitespace-nowrap text-[12px] font-semibold text-[var(--glass-text-tertiary)]\">\n              {t('baseUrl')}\n            </span>\n            {state.isEditingUrl ? (\n              <div className=\"flex flex-1 items-center gap-2\">\n                <input\n                  type=\"text\"\n                  value={state.tempUrl}\n                  onChange={(event) => state.setTempUrl(event.target.value)}\n                  placeholder={baseUrlPlaceholder}\n                  className=\"glass-input-base flex-1 px-3 py-1.5 text-[12px] font-mono\"\n                  autoFocus\n                />\n                <button\n                  onClick={state.handleSaveUrl}\n                  className=\"glass-icon-btn-sm\"\n                  title={t('save')}\n                >\n                  <AppIcon name=\"check\" className=\"h-4 w-4\" />\n                </button>\n                <button\n                  onClick={state.handleCancelUrlEdit}\n                  className=\"glass-icon-btn-sm\"\n                  title={t('cancel')}\n                >\n                  <AppIcon name=\"close\" className=\"h-4 w-4\" />\n                </button>\n              </div>\n            ) : (\n              <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n                {provider.baseUrl ? (\n                  <>\n                    <span className=\"min-w-0 flex-1 truncate rounded-lg bg-[var(--glass-bg-surface)] px-3 py-1.5 font-mono text-[12px] text-[var(--glass-text-secondary)]\">\n                      {provider.baseUrl}\n                    </span>\n                    <div className=\"flex shrink-0 items-center gap-1\">\n                      <button\n                        onClick={state.startEditUrl}\n                        className=\"glass-icon-btn-sm\"\n                        title={t('configure')}\n                      >\n                        <AppIcon name=\"edit\" className=\"h-4 w-4\" />\n                      </button>\n                    </div>\n                  </>\n                ) : (\n                  <button\n                    onClick={state.startEditUrl}\n                    className=\"glass-btn-base glass-btn-tone-info h-7 px-2.5 text-[12px] font-semibold\"\n                  >\n                    <AppIcon name=\"plus\" className=\"h-3.5 w-3.5\" />\n                    <span>{t('configureBaseUrl')}</span>\n                  </button>\n                )}\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell.tsx",
    "content": "'use client'\n\nimport type { ReactNode } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { ProviderCardProps, ProviderCardTranslator } from './types'\nimport { VERIFIABLE_PROVIDER_KEYS } from './types'\nimport type { UseProviderCardStateResult } from './hooks/useProviderCardState'\nimport { AppIcon } from '@/components/ui/icons'\nimport { getProviderKey } from '../types'\n\ninterface ProviderCardShellProps {\n  provider: ProviderCardProps['provider']\n  dragHandle?: ProviderCardProps['dragHandle']\n  onDeleteProvider: ProviderCardProps['onDeleteProvider']\n  onToggleProviderHidden?: ProviderCardProps['onToggleProviderHidden']\n  hideProviderLabel?: ProviderCardProps['hideProviderLabel']\n  showProviderLabel?: ProviderCardProps['showProviderLabel']\n  t: ProviderCardTranslator\n  state: UseProviderCardStateResult\n  children: ReactNode\n}\n\nexport function getCompatibilityLayerBadgeLabel(\n  providerId: string,\n  t: ProviderCardTranslator,\n): string | null {\n  const providerKey = getProviderKey(providerId)\n  if (providerKey === 'openai-compatible') return t('compatibilityLayerOpenAI')\n  if (providerKey === 'gemini-compatible') return t('compatibilityLayerGemini')\n  return null\n}\n\n// 连接状态图标\nfunction StatusIcon({ connected }: { connected: boolean }) {\n  if (connected) {\n    return <AppIcon name=\"bolt\" className=\"h-3.5 w-3.5 text-green-500\" />\n  }\n  return <AppIcon name=\"unplug\" className=\"h-3.5 w-3.5 text-red-400\" />\n}\n\n// 使用统一的 VERIFIABLE_PROVIDER_KEYS（从 types 导入）\n\nexport function ProviderCardShell({\n  provider,\n  dragHandle,\n  onDeleteProvider,\n  onToggleProviderHidden,\n  hideProviderLabel,\n  showProviderLabel,\n  t,\n  state,\n  children,\n}: ProviderCardShellProps) {\n  const compatibilityLayerLabel = getCompatibilityLayerBadgeLabel(provider.id, t)\n  const providerKey = getProviderKey(provider.id)\n  const isVerifiable = VERIFIABLE_PROVIDER_KEYS.has(providerKey)\n  const canTest = isVerifiable && !!provider.hasApiKey\n  const isHidden = provider.hidden === true\n  const hiddenToggleLabel = isHidden\n    ? (showProviderLabel || t('showProvider'))\n    : (hideProviderLabel || t('hideProvider'))\n\n  return (\n    <div className=\"glass-surface overflow-hidden rounded-2xl\">\n\n      {/* ── 头部：logo + 名称 + 心电图 + 右侧操作 ── */}\n      <div className=\"flex items-center justify-between px-3.5 py-2.5\">\n        <div className=\"flex items-center gap-2\">\n          {dragHandle}\n          {onToggleProviderHidden && (\n            <button\n              type=\"button\"\n              title={hiddenToggleLabel}\n              aria-label={hiddenToggleLabel}\n              onClick={() => {\n                if (isHidden) {\n                  // Restoring — no confirmation needed\n                  onToggleProviderHidden(provider.id, false)\n                } else {\n                  // Hiding — confirm first\n                  if (window.confirm(t('hideProviderConfirm'))) {\n                    onToggleProviderHidden(provider.id, true)\n                  }\n                }\n              }}\n              className=\"inline-flex h-6 w-6 items-center justify-center rounded-md text-[var(--glass-text-tertiary)] transition-colors hover:text-[var(--glass-text-secondary)]\"\n            >\n              <AppIcon name={isHidden ? 'plus' : 'minus'} className=\"h-3.5 w-3.5\" />\n            </button>\n          )}\n          <h3 className=\"text-[15px] font-bold text-[var(--glass-text-primary)]\">{provider.name}</h3>\n          {compatibilityLayerLabel && (\n            <span className=\"rounded-full border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)] px-2 py-0.5 text-[10px] font-semibold text-[var(--glass-text-secondary)]\">\n              {compatibilityLayerLabel}\n            </span>\n          )}\n          {/* 连接状态图标 */}\n          <span title={provider.hasApiKey ? t('connected') : t('notConfigured')}>\n            <StatusIcon connected={!!provider.hasApiKey} />\n          </span>\n        </div>\n        <div className=\"flex items-center gap-1.5\">\n          {/* 测试连接按钮（内联在标题行） */}\n          {isVerifiable && !state.isEditing && state.keyTestStatus === 'idle' && (\n            <button\n              onClick={state.handleTestOnly}\n              disabled={!canTest}\n              className={[\n                'flex items-center gap-1 rounded-lg border px-2 py-1 text-[11px] font-medium transition-all',\n                canTest\n                  ? 'border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] hover:border-[var(--glass-stroke-strong)] hover:bg-[var(--glass-bg-muted)] hover:text-[var(--glass-text-primary)] cursor-pointer'\n                  : 'border-[var(--glass-stroke-base)] cursor-not-allowed text-[var(--glass-text-tertiary)] opacity-40',\n              ].join(' ')}\n            >\n              <AppIcon name=\"refresh\" className=\"h-3 w-3\" />\n              {t('testConnection')}\n            </button>\n          )}\n          {!state.isPresetProvider && onDeleteProvider && (\n            <button\n              onClick={() => onDeleteProvider(provider.id)}\n              className=\"rounded p-1 text-[var(--glass-text-tertiary)] transition-colors hover:bg-[var(--glass-bg-muted)] hover:text-[var(--glass-tone-danger-fg)]\"\n              title={t('delete')}\n            >\n              <AppIcon name=\"trash\" className=\"w-3.5 h-3.5\" />\n            </button>\n          )}\n          {state.tutorial && (\n            <button\n              onClick={() => state.setShowTutorial(true)}\n              className=\"glass-btn-base cursor-pointer flex items-center gap-1 rounded-lg border border-[var(--glass-stroke-base)] bg-transparent px-2 py-1 text-[12px] font-medium text-[var(--glass-text-primary)] hover:border-[var(--glass-stroke-strong)] hover:bg-[var(--glass-bg-muted)] hover:text-[var(--glass-text-primary)]\"\n            >\n              <AppIcon name=\"bookOpen\" className=\"h-3 w-3\" />\n              {t('tutorial.button')}\n            </button>\n          )}\n        </div>\n      </div>\n\n      {/* ── 教程弹窗 ── */}\n      {state.showTutorial && state.tutorial && typeof document !== 'undefined'\n        ? createPortal(\n          <div\n            className=\"fixed inset-0 z-50 flex items-center justify-center glass-overlay\"\n            onClick={() => state.setShowTutorial(false)}\n          >\n            <div\n              className=\"glass-surface-modal mx-4 w-full max-w-lg overflow-hidden rounded-xl\"\n              onClick={(event) => event.stopPropagation()}\n            >\n              <div className=\"flex items-center justify-between border-b border-[var(--glass-stroke-base)] px-5 py-4\">\n                <div className=\"flex items-center gap-3\">\n                  <div className=\"glass-btn-base glass-btn-primary flex h-8 w-8 items-center justify-center rounded-lg text-white\">\n                    <AppIcon name=\"bookOpen\" className=\"w-4 h-4\" />\n                  </div>\n                  <div>\n                    <h3 className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">\n                      {provider.name} {t('tutorial.title')}\n                    </h3>\n                    <p className=\"text-xs text-[var(--glass-text-secondary)]\">{t('tutorial.subtitle')}</p>\n                  </div>\n                </div>\n                <button\n                  onClick={() => state.setShowTutorial(false)}\n                  className=\"glass-btn-base glass-btn-soft rounded-lg p-1.5\"\n                >\n                  <AppIcon name=\"close\" className=\"w-5 h-5\" />\n                </button>\n              </div>\n              <div className=\"space-y-4 p-5\">\n                {state.tutorial.steps.map((step, index) => (\n                  <div key={index} className=\"flex gap-3\">\n                    <div className=\"glass-surface-soft flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[var(--glass-stroke-base)] text-xs font-bold text-[var(--glass-text-secondary)]\">\n                      {index + 1}\n                    </div>\n                    <div className=\"flex-1 pt-0.5\">\n                      <p className=\"text-sm leading-relaxed text-[var(--glass-text-secondary)]\">\n                        {t(`tutorial.steps.${step.text}`)}\n                      </p>\n                      {step.url && (\n                        <a\n                          href={step.url}\n                          target=\"_blank\"\n                          rel=\"noopener noreferrer\"\n                          className=\"mt-2 inline-flex items-center gap-1 text-xs text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] hover:underline\"\n                        >\n                          <AppIcon name=\"externalLink\" className=\"w-3 h-3\" />\n                          {t('tutorial.openLink')}\n                        </a>\n                      )}\n                    </div>\n                  </div>\n                ))}\n              </div>\n              <div className=\"flex justify-end border-t border-[var(--glass-stroke-base)] px-5 py-3\">\n                <button\n                  onClick={() => state.setShowTutorial(false)}\n                  className=\"glass-btn-base glass-btn-secondary rounded-lg px-4 py-2 text-sm font-medium\"\n                >\n                  {t('tutorial.close')}\n                </button>\n              </div>\n            </div>\n          </div>,\n          document.body,\n        )\n        : null}\n\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState.ts",
    "content": "'use client'\n\nimport { useState, useCallback } from 'react'\nimport {\n  encodeModelKey,\n  PRESET_MODELS,\n  PRESET_PROVIDERS,\n  getProviderKey,\n  getProviderTutorial,\n  matchesModelKey,\n} from '../../types'\nimport type {\n  ModelFormState,\n  ProviderCardGroupedModels,\n  ProviderCardModelType,\n  ProviderCardProps,\n  ProviderCardTranslator,\n} from '../types'\nimport { VERIFIABLE_PROVIDER_KEYS } from '../types'\nimport type { CustomModel } from '../../types'\nimport { apiFetch } from '@/lib/api-fetch'\nimport {\n  useAssistantChat,\n  type AssistantDraftModel,\n  type AssistantSavedEvent,\n  type UseAssistantChatResult,\n} from '@/components/assistant/useAssistantChat'\n\ntype KeyTestStepStatus = 'pass' | 'fail' | 'skip'\ninterface KeyTestStep {\n  name: string\n  status: KeyTestStepStatus\n  message: string\n  model?: string\n  detail?: string\n}\ntype KeyTestStatus = 'idle' | 'testing' | 'passed' | 'failed'\n\n\n\ninterface UseProviderCardStateParams {\n  provider: ProviderCardProps['provider']\n  models: ProviderCardProps['models']\n  allModels?: ProviderCardProps['allModels']\n  defaultModels: ProviderCardProps['defaultModels']\n  onUpdateApiKey: ProviderCardProps['onUpdateApiKey']\n  onUpdateBaseUrl: ProviderCardProps['onUpdateBaseUrl']\n  onUpdateModel: ProviderCardProps['onUpdateModel']\n  onAddModel: ProviderCardProps['onAddModel']\n  onFlushConfig: ProviderCardProps['onFlushConfig']\n  t: ProviderCardTranslator\n}\n\nconst EMPTY_MODEL_FORM: ModelFormState = {\n  name: '',\n  modelId: '',\n  enableCustomPricing: false,\n  priceInput: '',\n  priceOutput: '',\n  basePrice: '',\n  optionPricesJson: '',\n}\n\n/**\n * Provider keys that require user-defined pricing when adding custom models\n * (they are not in the built-in pricing catalog).\n */\ntype AddModelCustomPricing = {\n  llm?: { inputPerMillion?: number; outputPerMillion?: number }\n  image?: { basePrice?: number; optionPrices?: Record<string, Record<string, number>> }\n  video?: { basePrice?: number; optionPrices?: Record<string, Record<string, number>> }\n}\n\ntype BuildCustomPricingResult =\n  | { ok: true; customPricing?: AddModelCustomPricing }\n  | { ok: false; reason: 'invalid' }\n\ninterface ProviderConnectionPayload {\n  apiType: string\n  apiKey: string\n  baseUrl?: string\n  llmModel?: string\n}\n\ntype LlmProtocolType = 'responses' | 'chat-completions'\n\ntype ProbeModelLlmProtocolSuccessResponse = {\n  success: true\n  protocol: LlmProtocolType\n  checkedAt: string\n}\n\ntype ProbeModelLlmProtocolFailureResponse = {\n  success: false\n  code?: string\n}\n\nfunction isLlmProtocol(value: unknown): value is LlmProtocolType {\n  return value === 'responses' || value === 'chat-completions'\n}\n\nfunction readProbeFailureCode(value: unknown): string {\n  return typeof value === 'string' ? value : 'PROBE_INCONCLUSIVE'\n}\n\nexport function shouldProbeModelLlmProtocol(params: {\n  providerId: string\n  modelType: ProviderCardModelType\n}): boolean {\n  return getProviderKey(params.providerId) === 'openai-compatible' && params.modelType === 'llm'\n}\n\nexport function shouldReprobeModelLlmProtocol(params: {\n  providerId: string\n  originalModel: CustomModel\n  nextModelId: string\n}): boolean {\n  if (!shouldProbeModelLlmProtocol({ providerId: params.providerId, modelType: 'llm' })) return false\n  if (params.originalModel.type !== 'llm') return false\n  if (getProviderKey(params.originalModel.provider) !== 'openai-compatible') return false\n  return params.originalModel.modelId !== params.nextModelId || params.originalModel.provider !== params.providerId\n}\n\nexport async function probeModelLlmProtocolViaApi(params: {\n  providerId: string\n  modelId: string\n}): Promise<{ llmProtocol: LlmProtocolType; llmProtocolCheckedAt: string }> {\n  const response = await apiFetch('/api/user/api-config/probe-model-llm-protocol', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({\n      providerId: params.providerId,\n      modelId: params.modelId,\n    }),\n  })\n  if (!response.ok) {\n    throw new Error('MODEL_LLM_PROTOCOL_PROBE_REQUEST_FAILED')\n  }\n\n  const payload = await response.json() as ProbeModelLlmProtocolSuccessResponse | ProbeModelLlmProtocolFailureResponse\n  if (!payload.success) {\n    throw new Error(readProbeFailureCode(payload.code))\n  }\n\n  if (!isLlmProtocol(payload.protocol)) {\n    throw new Error('MODEL_LLM_PROTOCOL_PROBE_INVALID_PROTOCOL')\n  }\n\n  const checkedAt = typeof payload.checkedAt === 'string' && payload.checkedAt.trim().length > 0\n    ? payload.checkedAt.trim()\n    : new Date().toISOString()\n\n  return {\n    llmProtocol: payload.protocol,\n    llmProtocolCheckedAt: checkedAt,\n  }\n}\n\nfunction pickConfiguredLlmModel(params: {\n  models: CustomModel[]\n  defaultAnalysisModel?: string\n}): string | undefined {\n  const enabledLlmModels = params.models.filter((model) => model.type === 'llm' && model.enabled)\n  if (enabledLlmModels.length === 0) return undefined\n  const preferredModel = enabledLlmModels.find((model) => model.modelKey === params.defaultAnalysisModel)\n  return (preferredModel ?? enabledLlmModels[0])?.modelId\n}\n\nexport function buildProviderConnectionPayload(params: {\n  providerKey: string\n  apiKey: string\n  baseUrl?: string\n  llmModel?: string\n}): ProviderConnectionPayload {\n  const apiKey = params.apiKey.trim()\n  const compatibleBaseUrl = params.baseUrl?.trim()\n  const llmModel = params.llmModel?.trim()\n  const isCompatibleProvider =\n    params.providerKey === 'openai-compatible' || params.providerKey === 'gemini-compatible'\n\n  if (isCompatibleProvider && compatibleBaseUrl) {\n    return {\n      apiType: params.providerKey,\n      apiKey,\n      baseUrl: compatibleBaseUrl,\n      ...(llmModel ? { llmModel } : {}),\n    }\n  }\n\n  return {\n    apiType: params.providerKey,\n    apiKey,\n    ...(llmModel ? { llmModel } : {}),\n  }\n}\n\nexport function buildCustomPricingFromModelForm(\n  modelType: ProviderCardModelType,\n  form: ModelFormState,\n  options: { needsCustomPricing: boolean },\n): BuildCustomPricingResult {\n  if (!options.needsCustomPricing || form.enableCustomPricing !== true) {\n    return { ok: true }\n  }\n\n  if (modelType === 'llm') {\n    const inputVal = parseFloat(form.priceInput || '')\n    const outputVal = parseFloat(form.priceOutput || '')\n    if (!Number.isFinite(inputVal) || inputVal < 0 || !Number.isFinite(outputVal) || outputVal < 0) {\n      return { ok: false, reason: 'invalid' }\n    }\n    return {\n      ok: true,\n      customPricing: {\n        llm: {\n          inputPerMillion: inputVal,\n          outputPerMillion: outputVal,\n        },\n      },\n    }\n  }\n\n  if (modelType === 'image' || modelType === 'video') {\n    const basePriceRaw = parseFloat(form.basePrice || '')\n    const hasBasePrice = Number.isFinite(basePriceRaw) && basePriceRaw >= 0\n    if (form.basePrice && !hasBasePrice) {\n      return { ok: false, reason: 'invalid' }\n    }\n\n    let optionPrices: Record<string, Record<string, number>> | undefined\n    if (form.optionPricesJson && form.optionPricesJson.trim().length > 0) {\n      try {\n        const parsed = JSON.parse(form.optionPricesJson) as unknown\n        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n          throw new Error('invalid option prices object')\n        }\n        optionPrices = {}\n        for (const [field, rawOptionMap] of Object.entries(parsed as Record<string, unknown>)) {\n          if (!rawOptionMap || typeof rawOptionMap !== 'object' || Array.isArray(rawOptionMap)) continue\n          const normalizedOptions: Record<string, number> = {}\n          for (const [optionKey, rawAmount] of Object.entries(rawOptionMap as Record<string, unknown>)) {\n            if (typeof rawAmount !== 'number' || !Number.isFinite(rawAmount) || rawAmount < 0) {\n              throw new Error('invalid option price amount')\n            }\n            normalizedOptions[optionKey] = rawAmount\n          }\n          if (Object.keys(normalizedOptions).length > 0) {\n            optionPrices[field] = normalizedOptions\n          }\n        }\n        if (Object.keys(optionPrices).length === 0) {\n          optionPrices = undefined\n        }\n      } catch {\n        return { ok: false, reason: 'invalid' }\n      }\n    }\n\n    if (!hasBasePrice && !optionPrices) {\n      return { ok: false, reason: 'invalid' }\n    }\n\n    return {\n      ok: true,\n      customPricing: modelType === 'image'\n        ? {\n          image: {\n            ...(hasBasePrice ? { basePrice: basePriceRaw } : {}),\n            ...(optionPrices ? { optionPrices } : {}),\n          },\n        }\n        : {\n          video: {\n            ...(hasBasePrice ? { basePrice: basePriceRaw } : {}),\n            ...(optionPrices ? { optionPrices } : {}),\n          },\n        },\n    }\n  }\n\n  return { ok: true }\n}\n\nfunction toProviderCardModelType(type: CustomModel['type']): ProviderCardModelType | null {\n  if (type === 'llm' || type === 'image' || type === 'video' || type === 'audio') return type\n  if (type === 'lipsync') return 'audio'\n  return null\n}\n\nexport interface UseProviderCardStateResult {\n  providerKey: string\n  isPresetProvider: boolean\n  showBaseUrlEdit: boolean\n  tutorial: ReturnType<typeof getProviderTutorial>\n  groupedModels: ProviderCardGroupedModels\n  hasModels: boolean\n  isEditing: boolean\n  isEditingUrl: boolean\n  showKey: boolean\n  tempKey: string\n  tempUrl: string\n  showTutorial: boolean\n  showAddForm: ProviderCardModelType | null\n  newModel: ModelFormState\n  batchMode: boolean\n  editingModelId: string | null\n  editModel: ModelFormState\n  maskedKey: string\n  isPresetModel: (modelKey: string) => boolean\n  isDefaultModel: (model: CustomModel) => boolean\n  setShowKey: (value: boolean) => void\n  setShowTutorial: (value: boolean) => void\n  setShowAddForm: (value: ProviderCardModelType | null) => void\n  setBatchMode: (value: boolean) => void\n  setNewModel: (value: ModelFormState) => void\n  setEditModel: (value: ModelFormState) => void\n  setTempKey: (value: string) => void\n  setTempUrl: (value: string) => void\n  startEditKey: () => void\n  startEditUrl: () => void\n  handleSaveKey: () => void\n  handleCancelEdit: () => void\n  handleSaveUrl: () => void\n  handleCancelUrlEdit: () => void\n  handleEditModel: (model: CustomModel) => void\n  handleCancelEditModel: () => void\n  handleSaveModel: (originalModelKey: string) => Promise<void>\n  handleAddModel: (type: ProviderCardModelType) => Promise<void>\n  handleCancelAdd: () => void\n  needsCustomPricing: boolean\n  keyTestStatus: KeyTestStatus\n  keyTestSteps: KeyTestStep[]\n  handleForceSaveKey: () => void\n  handleTestOnly: () => void\n  handleDismissTest: () => void\n  isModelSavePending: boolean\n  assistantEnabled: boolean\n  isAssistantOpen: boolean\n  assistantSavedEvent: AssistantSavedEvent | null\n  assistantChat: UseAssistantChatResult\n  openAssistant: () => void\n  closeAssistant: () => void\n  handleAssistantSend: (content?: string) => Promise<void>\n}\n\nexport function getAssistantSavedModelLabel(event: AssistantSavedEvent): string {\n  const draftName = event.draftModel?.name?.trim()\n  if (draftName) return draftName\n  const tail = event.savedModelKey.split('::').pop()\n  const modelId = typeof tail === 'string' ? tail.trim() : ''\n  return modelId || event.savedModelKey\n}\n\nexport function useProviderCardState({\n  provider,\n  models,\n  allModels,\n  defaultModels,\n  onUpdateApiKey,\n  onUpdateBaseUrl,\n  onUpdateModel,\n  onAddModel,\n  onFlushConfig,\n  t,\n}: UseProviderCardStateParams): UseProviderCardStateResult {\n  const [isEditing, setIsEditing] = useState(false)\n  const [isEditingUrl, setIsEditingUrl] = useState(false)\n  const [showKey, setShowKey] = useState(false)\n  const [tempKey, setTempKey] = useState(provider.apiKey || '')\n  const [tempUrl, setTempUrl] = useState(provider.baseUrl || '')\n  const [showTutorial, setShowTutorial] = useState(false)\n  const [showAddForm, setShowAddForm] = useState<ProviderCardModelType | null>(null)\n  const [newModel, setNewModel] = useState<ModelFormState>(EMPTY_MODEL_FORM)\n  const [batchMode, setBatchMode] = useState(false)\n  const [editingModelId, setEditingModelId] = useState<string | null>(null)\n  const [editModel, setEditModel] = useState<ModelFormState>(EMPTY_MODEL_FORM)\n  const [keyTestStatus, setKeyTestStatus] = useState<KeyTestStatus>('idle')\n  const [keyTestSteps, setKeyTestSteps] = useState<KeyTestStep[]>([])\n  const [isModelSavePending, setIsModelSavePending] = useState(false)\n  const [isAssistantOpen, setIsAssistantOpen] = useState(false)\n  const [assistantSavedEvent, setAssistantSavedEvent] = useState<AssistantSavedEvent | null>(null)\n\n  const providerKey = getProviderKey(provider.id)\n  const assistantEnabled = providerKey === 'openai-compatible'\n  const isPresetProvider = PRESET_PROVIDERS.some(\n    (presetProvider) => presetProvider.id === provider.id,\n  )\n  const showBaseUrlEdit =\n    ['gemini-compatible', 'openai-compatible'].includes(providerKey) &&\n    Boolean(onUpdateBaseUrl)\n  const tutorial = getProviderTutorial(provider.id)\n\n  const groupedModels: ProviderCardGroupedModels = {}\n  for (const model of models) {\n    const groupedType = toProviderCardModelType(model.type)\n    if (!groupedType) continue\n    if (!groupedModels[groupedType]) {\n      groupedModels[groupedType] = []\n    }\n    groupedModels[groupedType]!.push(model)\n  }\n\n  const hasModels = Object.keys(groupedModels).length > 0\n  const isPresetModel = (modelKey: string) =>\n    PRESET_MODELS.some((model) => encodeModelKey(model.provider, model.modelId) === modelKey)\n\n  const isDefaultModel = (model: CustomModel) => {\n    if (model.type === 'llm' && matchesModelKey(defaultModels.analysisModel, model.provider, model.modelId)) {\n      return true\n    }\n\n    if (model.type === 'image') {\n      if (matchesModelKey(defaultModels.characterModel, model.provider, model.modelId)) return true\n      if (matchesModelKey(defaultModels.locationModel, model.provider, model.modelId)) return true\n      if (matchesModelKey(defaultModels.storyboardModel, model.provider, model.modelId)) return true\n      if (matchesModelKey(defaultModels.editModel, model.provider, model.modelId)) return true\n    }\n\n    if (model.type === 'video' && matchesModelKey(defaultModels.videoModel, model.provider, model.modelId)) {\n      return true\n    }\n\n    if (model.type === 'audio' && matchesModelKey(defaultModels.audioModel, model.provider, model.modelId)) {\n      return true\n    }\n\n    if (model.type === 'lipsync' && matchesModelKey(defaultModels.lipSyncModel, model.provider, model.modelId)) {\n      return true\n    }\n\n    return false\n  }\n\n  const startEditKey = () => {\n    setTempKey(provider.apiKey || '')\n    setIsEditing(true)\n  }\n\n  const startEditUrl = () => {\n    setTempUrl(provider.baseUrl || '')\n    setIsEditingUrl(true)\n  }\n\n  const doSaveKey = useCallback(() => {\n    onUpdateApiKey(provider.id, tempKey)\n    setIsEditing(false)\n    setKeyTestStatus('idle')\n    setKeyTestSteps([])\n  }, [onUpdateApiKey, provider.id, tempKey])\n\n  const handleSaveKey = useCallback(async () => {\n    if (!VERIFIABLE_PROVIDER_KEYS.has(providerKey)) {\n      doSaveKey()\n      return\n    }\n\n    setKeyTestStatus('testing')\n    setKeyTestSteps([])\n\n    try {\n      const fallbackLlmModel = pickConfiguredLlmModel({\n        models,\n        defaultAnalysisModel: defaultModels.analysisModel,\n      })\n      const payload = buildProviderConnectionPayload({\n        providerKey,\n        apiKey: tempKey,\n        baseUrl: provider.baseUrl,\n        llmModel: fallbackLlmModel,\n      })\n      const res = await apiFetch('/api/user/api-config/test-provider', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(payload),\n      })\n\n      const data = await res.json()\n      const steps: KeyTestStep[] = data.steps || []\n      setKeyTestSteps(steps)\n\n      if (data.success) {\n        setKeyTestStatus('passed')\n        // Show success for 1.5s before saving\n        setTimeout(() => doSaveKey(), 1500)\n      } else {\n        setKeyTestStatus('failed')\n      }\n    } catch {\n      setKeyTestSteps([{ name: 'models', status: 'fail', message: 'Network error' }])\n      setKeyTestStatus('failed')\n    }\n  }, [defaultModels.analysisModel, doSaveKey, models, provider.baseUrl, providerKey, tempKey])\n\n  const handleForceSaveKey = useCallback(() => {\n    doSaveKey()\n  }, [doSaveKey])\n\n  // 纯测试：不保存，结果持久展示直到用户手动关闭\n  const handleTestOnly = useCallback(async () => {\n    setKeyTestStatus('testing')\n    setKeyTestSteps([])\n    try {\n      const fallbackLlmModel = pickConfiguredLlmModel({\n        models,\n        defaultAnalysisModel: defaultModels.analysisModel,\n      })\n      const payload = buildProviderConnectionPayload({\n        providerKey,\n        apiKey: provider.apiKey || '',\n        baseUrl: provider.baseUrl,\n        llmModel: fallbackLlmModel,\n      })\n      const res = await apiFetch('/api/user/api-config/test-provider', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(payload),\n      })\n      const data = await res.json()\n      setKeyTestSteps(data.steps || [])\n      setKeyTestStatus(data.success ? 'passed' : 'failed')\n    } catch {\n      setKeyTestSteps([{ name: 'models', status: 'fail', message: 'Network error' }])\n      setKeyTestStatus('failed')\n    }\n  }, [defaultModels.analysisModel, models, provider.apiKey, provider.baseUrl, providerKey])\n\n  const handleDismissTest = useCallback(() => {\n    setKeyTestStatus('idle')\n    setKeyTestSteps([])\n  }, [])\n\n  const handleCancelEdit = () => {\n    setTempKey(provider.apiKey || '')\n    setIsEditing(false)\n    setKeyTestStatus('idle')\n    setKeyTestSteps([])\n  }\n\n  const handleSaveUrl = () => {\n    onUpdateBaseUrl?.(provider.id, tempUrl)\n    setIsEditingUrl(false)\n  }\n\n  const handleCancelUrlEdit = () => {\n    setTempUrl(provider.baseUrl || '')\n    setIsEditingUrl(false)\n  }\n\n  const handleEditModel = (model: CustomModel) => {\n    setEditingModelId(model.modelKey)\n    setEditModel({\n      name: model.name,\n      modelId: model.modelId,\n    })\n  }\n\n  const handleCancelEditModel = () => {\n    setEditingModelId(null)\n    setEditModel(EMPTY_MODEL_FORM)\n  }\n\n  const resolveProbeFailureMessage = (error: unknown): string => {\n    const code = error instanceof Error ? error.message : ''\n    if (code === 'PROBE_AUTH_FAILED') return t('probeAuthFailed')\n    if (code === 'PROBE_INCONCLUSIVE') return t('probeInconclusive')\n    if (code === 'MODEL_LLM_PROTOCOL_PROBE_REQUEST_FAILED') return t('probeRequestFailed')\n    return t('probeLlmProtocolFailed')\n  }\n\n  const flushConfigBeforeProbe = useCallback(async (): Promise<boolean> => {\n    if (!onFlushConfig) return true\n    try {\n      await onFlushConfig()\n      return true\n    } catch {\n      alert(t('flushConfigFailed'))\n      return false\n    }\n  }, [onFlushConfig, t])\n\n  const handleSaveModel = async (originalModelKey: string): Promise<void> => {\n    if (isModelSavePending) return\n    if (!editModel.name || !editModel.modelId) {\n      alert(t('fillComplete'))\n      return\n    }\n\n    const nextModelKey = encodeModelKey(provider.id, editModel.modelId)\n    const all = allModels || models\n    const duplicate = all.some(\n      (model) =>\n        model.modelKey === nextModelKey &&\n        model.modelKey !== originalModelKey,\n    )\n\n    if (duplicate) {\n      alert(t('modelIdExists'))\n      return\n    }\n\n    setIsModelSavePending(true)\n    try {\n      const originalModel = all.find((model) => model.modelKey === originalModelKey)\n      let protocolUpdates: Pick<CustomModel, 'llmProtocol' | 'llmProtocolCheckedAt'> | null = null\n      if (originalModel && shouldReprobeModelLlmProtocol({\n        providerId: provider.id,\n        originalModel,\n        nextModelId: editModel.modelId,\n      })) {\n        const flushed = await flushConfigBeforeProbe()\n        if (!flushed) return\n\n        try {\n          protocolUpdates = await probeModelLlmProtocolViaApi({\n            providerId: provider.id,\n            modelId: editModel.modelId,\n          })\n        } catch (error) {\n          alert(resolveProbeFailureMessage(error))\n          return\n        }\n      }\n\n      onUpdateModel?.(originalModelKey, {\n        name: editModel.name,\n        modelId: editModel.modelId,\n        ...(protocolUpdates ? protocolUpdates : {}),\n      })\n\n      handleCancelEditModel()\n    } finally {\n      setIsModelSavePending(false)\n    }\n  }\n\n  const handleAddModel = async (type: ProviderCardModelType): Promise<void> => {\n    if (isModelSavePending) return\n    if (!newModel.name || !newModel.modelId) {\n      alert(t('fillComplete'))\n      return\n    }\n\n    const finalModelId =\n      type === 'video' && batchMode && provider.id === 'ark'\n        ? `${newModel.modelId}-batch`\n        : newModel.modelId\n    const finalModelKey = encodeModelKey(provider.id, finalModelId)\n\n    const all = allModels || models\n    if (all.some((model) => model.modelKey === finalModelKey)) {\n      alert(t('modelIdExists'))\n      return\n    }\n\n    const finalName =\n      type === 'video' && batchMode && provider.id === 'ark'\n        ? `${newModel.name} (Batch)`\n        : newModel.name\n\n    setIsModelSavePending(true)\n    try {\n      let protocolFields: Pick<CustomModel, 'llmProtocol' | 'llmProtocolCheckedAt'> | null = null\n      if (shouldProbeModelLlmProtocol({ providerId: provider.id, modelType: type })) {\n        const flushed = await flushConfigBeforeProbe()\n        if (!flushed) return\n\n        try {\n          protocolFields = await probeModelLlmProtocolViaApi({\n            providerId: provider.id,\n            modelId: finalModelId,\n          })\n        } catch (error) {\n          alert(resolveProbeFailureMessage(error))\n          return\n        }\n      }\n\n      onAddModel({\n        modelId: finalModelId,\n        modelKey: finalModelKey,\n        name: finalName,\n        type,\n        provider: provider.id,\n        price: 0,\n        ...(protocolFields ? protocolFields : {}),\n      })\n\n      setNewModel(EMPTY_MODEL_FORM)\n      setBatchMode(false)\n      setShowAddForm(null)\n    } finally {\n      setIsModelSavePending(false)\n    }\n  }\n\n  const handleCancelAdd = () => {\n    setShowAddForm(null)\n    setNewModel(EMPTY_MODEL_FORM)\n    setBatchMode(false)\n  }\n\n  const upsertModelFromAssistantDraft = useCallback((draft: AssistantDraftModel) => {\n    const modelKey = encodeModelKey(draft.provider, draft.modelId)\n    const checkedAt = new Date().toISOString()\n    const currentModels = allModels || models\n    const existed = currentModels.find((item) => item.modelKey === modelKey)\n    if (existed) {\n      onUpdateModel?.(modelKey, {\n        name: draft.name,\n        modelId: draft.modelId,\n        provider: draft.provider,\n        compatMediaTemplate: draft.compatMediaTemplate,\n        compatMediaTemplateCheckedAt: checkedAt,\n        compatMediaTemplateSource: 'ai',\n      })\n      return\n    }\n    onAddModel({\n      modelId: draft.modelId,\n      modelKey,\n      name: draft.name,\n      type: draft.type,\n      provider: draft.provider,\n      price: 0,\n      compatMediaTemplate: draft.compatMediaTemplate,\n      compatMediaTemplateCheckedAt: checkedAt,\n      compatMediaTemplateSource: 'ai',\n    })\n  }, [allModels, models, onAddModel, onUpdateModel])\n\n  const assistantChat = useAssistantChat({\n    assistantId: 'api-config-template',\n    context: { providerId: provider.id },\n    enabled: assistantEnabled,\n    onSaved: (event) => {\n      setAssistantSavedEvent(event)\n      if (event.draftModel) {\n        upsertModelFromAssistantDraft(event.draftModel)\n        return\n      }\n      onUpdateModel?.(event.savedModelKey, {\n        compatMediaTemplateSource: 'ai',\n      })\n    },\n  })\n\n  const openAssistant = useCallback(() => {\n    if (!assistantEnabled) return\n    setAssistantSavedEvent(null)\n    setIsAssistantOpen(true)\n  }, [assistantEnabled])\n\n  const closeAssistant = useCallback(() => {\n    setIsAssistantOpen(false)\n    setAssistantSavedEvent(null)\n    assistantChat.clear()\n  }, [assistantChat])\n\n  const handleAssistantSend = useCallback(async (content?: string): Promise<void> => {\n    if (!assistantEnabled || assistantChat.pending || assistantSavedEvent !== null) return\n    const flushed = await flushConfigBeforeProbe()\n    if (!flushed) return\n    await assistantChat.send(content)\n  }, [\n    assistantEnabled,\n    assistantChat,\n    assistantSavedEvent,\n    flushConfigBeforeProbe,\n  ])\n\n  const maskedKey = (() => {\n    const key = provider.apiKey || ''\n    if (key.length <= 8) return '•'.repeat(key.length)\n    return `${key.slice(0, 4)}${'•'.repeat(50)}`\n  })()\n\n  return {\n    providerKey,\n    isPresetProvider,\n    showBaseUrlEdit,\n    tutorial,\n    groupedModels,\n    hasModels,\n    isEditing,\n    isEditingUrl,\n    showKey,\n    tempKey,\n    tempUrl,\n    showTutorial,\n    showAddForm,\n    newModel,\n    batchMode,\n    editingModelId,\n    editModel,\n    maskedKey,\n    isPresetModel,\n    isDefaultModel,\n    setShowKey,\n    setShowTutorial,\n    setShowAddForm,\n    setBatchMode,\n    setNewModel,\n    setEditModel,\n    setTempKey,\n    setTempUrl,\n    startEditKey,\n    startEditUrl,\n    handleSaveKey,\n    handleCancelEdit,\n    handleSaveUrl,\n    handleCancelUrlEdit,\n    handleEditModel,\n    handleCancelEditModel,\n    handleSaveModel,\n    handleAddModel,\n    handleCancelAdd,\n    needsCustomPricing: false,\n    keyTestStatus,\n    keyTestSteps,\n    handleForceSaveKey,\n    handleTestOnly,\n    handleDismissTest,\n    isModelSavePending,\n    assistantEnabled,\n    isAssistantOpen,\n    assistantSavedEvent,\n    assistantChat,\n    openAssistant,\n    closeAssistant,\n    handleAssistantSend,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/provider-card/types.ts",
    "content": "import type { ReactNode } from 'react'\nimport type { CustomModel, Provider } from '../types'\n\nexport interface ProviderCardDefaultModels {\n  analysisModel?: string\n  characterModel?: string\n  locationModel?: string\n  storyboardModel?: string\n  editModel?: string\n  videoModel?: string\n  audioModel?: string\n  lipSyncModel?: string\n  voiceDesignModel?: string\n}\n\nexport interface ProviderCardProps {\n  provider: Provider\n  dragHandle?: ReactNode\n  models: CustomModel[]\n  allModels?: CustomModel[]\n  defaultModels: ProviderCardDefaultModels\n  onToggleModel: (modelKey: string) => void\n  onUpdateApiKey: (providerId: string, apiKey: string) => void\n  onUpdateBaseUrl?: (providerId: string, baseUrl: string) => void\n  onDeleteModel: (modelKey: string) => void\n  onUpdateModel?: (modelKey: string, updates: Partial<CustomModel>) => void\n  onDeleteProvider?: (providerId: string) => void\n  onToggleProviderHidden?: (providerId: string, hidden: boolean) => void\n  onAddModel: (model: Omit<CustomModel, 'enabled'>) => void\n  onFlushConfig?: () => Promise<void>\n  hideProviderLabel?: string\n  showProviderLabel?: string\n}\n\nexport interface ModelFormState {\n  name: string\n  modelId: string\n  enableCustomPricing?: boolean\n  priceInput?: string\n  priceOutput?: string\n  basePrice?: string\n  optionPricesJson?: string\n}\n\nexport type ProviderCardModelType = 'llm' | 'image' | 'video' | 'audio'\n\nexport type ProviderCardGroupedModels = Partial<Record<ProviderCardModelType, CustomModel[]>>\n\nexport type ProviderCardTranslator = (\n  key: string,\n  values?: Record<string, string | number>,\n) => string\n\n/**\n * 支持在线连通性测试的 provider key 集合（单一源）\n * UI 层（是否显示\"测试连接\"按钮）和 逻辑层（保存时是否自动测试）共享此列表\n */\nexport const VERIFIABLE_PROVIDER_KEYS = new Set([\n  'ark', 'google', 'openrouter', 'minimax', 'fal', 'vidu',\n  'bailian', 'siliconflow',\n  'openai-compatible', 'gemini-compatible',\n])\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config/types.ts",
    "content": "/**\n * API 配置类型定义和预设常量\n */\nimport {\n    composeModelKey,\n    parseModelKeyStrict,\n    type ModelCapabilities,\n    type UnifiedModelType,\n} from '@/lib/model-config-contract'\nimport type {\n    OpenAICompatMediaTemplate,\n    OpenAICompatMediaTemplateSource,\n} from '@/lib/openai-compat-media-template'\n\n// 统一提供商接口\nexport interface Provider {\n    id: string\n    name: string\n    baseUrl?: string\n    apiKey?: string\n    hasApiKey?: boolean\n    hidden?: boolean\n    apiMode?: 'gemini-sdk' | 'openai-official'\n    gatewayRoute?: 'official' | 'openai-compat'\n}\n\nexport interface LlmCustomPricing {\n    inputPerMillion?: number\n    outputPerMillion?: number\n}\n\nexport interface MediaCustomPricing {\n    basePrice?: number\n    optionPrices?: Record<string, Record<string, number>>\n}\n\n// 用户自定义定价 V2（能力参数可定价）\nexport interface CustomModelPricing {\n    llm?: LlmCustomPricing\n    image?: MediaCustomPricing\n    video?: MediaCustomPricing\n}\n\n// 模型接口\nexport interface CustomModel {\n    modelId: string       // 唯一标识符（如 anthropic/claude-sonnet-4.5）\n    modelKey: string      // 唯一主键（provider::modelId）\n    name: string          // 显示名称\n    type: UnifiedModelType\n    provider: string\n    llmProtocol?: 'responses' | 'chat-completions'\n    llmProtocolCheckedAt?: string\n    compatMediaTemplate?: OpenAICompatMediaTemplate\n    compatMediaTemplateCheckedAt?: string\n    compatMediaTemplateSource?: OpenAICompatMediaTemplateSource\n    price: number\n    priceMin?: number\n    priceMax?: number\n    priceLabel?: string\n    priceInput?: number\n    priceOutput?: number\n    enabled: boolean\n    capabilities?: ModelCapabilities\n    customPricing?: CustomModelPricing\n}\n\nexport interface PricingDisplayItem {\n    min: number\n    max: number\n    label: string\n    input?: number\n    output?: number\n}\n\nexport type PricingDisplayMap = Record<string, PricingDisplayItem>\n\n// API 配置响应\nexport interface ApiConfig {\n    models: CustomModel[]\n    providers: Provider[]\n    workflowConcurrency?: {\n        analysis: number\n        image: number\n        video: number\n    }\n    pricingDisplay?: PricingDisplayMap\n}\n\ntype PresetModel = Omit<CustomModel, 'enabled' | 'modelKey' | 'price'>\n\n// 预设模型\nexport const PRESET_MODELS: PresetModel[] = [\n    // 文本模型\n    { modelId: 'google/gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', type: 'llm', provider: 'openrouter' },\n    { modelId: 'google/gemini-3-pro-preview', name: 'Gemini 3 Pro', type: 'llm', provider: 'openrouter' },\n    { modelId: 'google/gemini-3-flash-preview', name: 'Gemini 3 Flash', type: 'llm', provider: 'openrouter' },\n    { modelId: 'anthropic/claude-sonnet-4.5', name: 'Claude Sonnet 4.5', type: 'llm', provider: 'openrouter' },\n    { modelId: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4', type: 'llm', provider: 'openrouter' },\n    { modelId: 'openai/gpt-5.4', name: 'GPT-5.4', type: 'llm', provider: 'openrouter' },\n    { modelId: 'google/gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash Lite', type: 'llm', provider: 'openrouter' },\n    // Google AI Studio 文本模型\n    { modelId: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', type: 'llm', provider: 'google' },\n    { modelId: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', type: 'llm', provider: 'google' },\n    { modelId: 'gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash-Lite', type: 'llm', provider: 'google' },\n    // 火山引擎 Doubao 文本模型\n    { modelId: 'doubao-seed-1-8-251228', name: 'Doubao Seed 1.8', type: 'llm', provider: 'ark' },\n    { modelId: 'doubao-seed-2-0-pro-260215', name: 'Doubao Seed 2.0 Pro', type: 'llm', provider: 'ark' },\n    { modelId: 'doubao-seed-2-0-lite-260215', name: 'Doubao Seed 2.0 Lite', type: 'llm', provider: 'ark' },\n    { modelId: 'doubao-seed-2-0-mini-260215', name: 'Doubao Seed 2.0 Mini', type: 'llm', provider: 'ark' },\n    { modelId: 'doubao-seed-1-6-251015', name: 'Doubao Seed 1.6', type: 'llm', provider: 'ark' },\n    { modelId: 'doubao-seed-1-6-lite-251015', name: 'Doubao Seed 1.6 Lite', type: 'llm', provider: 'ark' },\n    // 阿里云百炼文本模型\n    { modelId: 'qwen3.5-plus', name: 'Qwen 3.5 Plus', type: 'llm', provider: 'bailian' },\n    { modelId: 'qwen3.5-flash', name: 'Qwen 3.5 Flash', type: 'llm', provider: 'bailian' },\n    // MiniMax 官方文本模型\n    { modelId: 'MiniMax-M2.5', name: 'MiniMax M2.5', type: 'llm', provider: 'minimax' },\n    { modelId: 'MiniMax-M2.5-highspeed', name: 'MiniMax M2.5 Highspeed', type: 'llm', provider: 'minimax' },\n    { modelId: 'MiniMax-M2.1', name: 'MiniMax M2.1', type: 'llm', provider: 'minimax' },\n    { modelId: 'MiniMax-M2.1-highspeed', name: 'MiniMax M2.1 Highspeed', type: 'llm', provider: 'minimax' },\n    { modelId: 'MiniMax-M2', name: 'MiniMax M2', type: 'llm', provider: 'minimax' },\n\n    // 图像模型\n    { modelId: 'banana', name: 'Banana Pro', type: 'image', provider: 'fal' },\n    { modelId: 'banana-2', name: 'Banana 2', type: 'image', provider: 'fal' },\n    { modelId: 'doubao-seedream-4-5-251128', name: 'Seedream 4.5', type: 'image', provider: 'ark' },\n    { modelId: 'doubao-seedream-4-0-250828', name: 'Seedream 4.0', type: 'image', provider: 'ark' },\n    { modelId: 'doubao-seedream-5-0-260128', name: 'Seedream 5.0 Lite', type: 'image', provider: 'ark' },\n    { modelId: 'gemini-3-pro-image-preview', name: 'Banana Pro', type: 'image', provider: 'google' },\n    { modelId: 'gemini-3.1-flash-image-preview', name: 'Nano Banana 2', type: 'image', provider: 'google' },\n    { modelId: 'gemini-3-pro-image-preview-batch', name: 'Banana Pro (Batch)', type: 'image', provider: 'google' },\n    { modelId: 'gemini-2.5-flash-image', name: 'Gemini 2.5 Flash Image', type: 'image', provider: 'google' },\n    { modelId: 'imagen-4.0-generate-001', name: 'Imagen 4', type: 'image', provider: 'google' },\n    { modelId: 'imagen-4.0-ultra-generate-001', name: 'Imagen 4 Ultra', type: 'image', provider: 'google' },\n    { modelId: 'imagen-4.0-fast-generate-001', name: 'Imagen 4 Fast', type: 'image', provider: 'google' },\n    // 视频模型\n    { modelId: 'doubao-seedance-1-0-pro-fast-251015', name: 'Seedance 1.0 Pro Fast', type: 'video', provider: 'ark' },\n    { modelId: 'doubao-seedance-1-0-lite-i2v-250428', name: 'Seedance 1.0 Lite', type: 'video', provider: 'ark' },\n    { modelId: 'doubao-seedance-1-5-pro-251215', name: 'Seedance 1.5 Pro', type: 'video', provider: 'ark' },\n    { modelId: 'doubao-seedance-2-0-260128', name: 'Seedance 2.0（待上线）', type: 'video', provider: 'ark' },\n    { modelId: 'doubao-seedance-1-0-pro-250528', name: 'Seedance 1.0 Pro', type: 'video', provider: 'ark' },\n    // Google Veo\n    { modelId: 'veo-3.1-generate-preview', name: 'Veo 3.1', type: 'video', provider: 'google' },\n    { modelId: 'veo-3.1-fast-generate-preview', name: 'Veo 3.1 Fast', type: 'video', provider: 'google' },\n    { modelId: 'veo-3.0-generate-001', name: 'Veo 3.0', type: 'video', provider: 'google' },\n    { modelId: 'veo-3.0-fast-generate-001', name: 'Veo 3.0 Fast', type: 'video', provider: 'google' },\n    { modelId: 'veo-2.0-generate-001', name: 'Veo 2.0', type: 'video', provider: 'google' },\n    // 阿里云百炼图生视频模型\n    { modelId: 'wan2.6-i2v-flash', name: 'Wan2.6 I2V Flash', type: 'video', provider: 'bailian' },\n    { modelId: 'wan2.6-i2v', name: 'Wan2.6 I2V', type: 'video', provider: 'bailian' },\n    { modelId: 'wan2.5-i2v-preview', name: 'Wan2.5 I2V Preview', type: 'video', provider: 'bailian' },\n    { modelId: 'wan2.2-i2v-plus', name: 'Wan2.2 I2V Plus', type: 'video', provider: 'bailian' },\n    { modelId: 'wan2.2-kf2v-flash', name: 'Wan2.2 KF2V Flash', type: 'video', provider: 'bailian' },\n    { modelId: 'wanx2.1-kf2v-plus', name: 'WanX2.1 KF2V Plus', type: 'video', provider: 'bailian' },\n    { modelId: 'fal-wan25', name: 'Wan 2.6', type: 'video', provider: 'fal' },\n    { modelId: 'fal-veo31', name: 'Veo 3.1', type: 'video', provider: 'fal' },\n    { modelId: 'fal-sora2', name: 'Sora 2', type: 'video', provider: 'fal' },\n    { modelId: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video', name: 'Kling 2.5 Turbo Pro', type: 'video', provider: 'fal' },\n    { modelId: 'fal-ai/kling-video/v3/standard/image-to-video', name: 'Kling 3 Standard', type: 'video', provider: 'fal' },\n    { modelId: 'fal-ai/kling-video/v3/pro/image-to-video', name: 'Kling 3 Pro', type: 'video', provider: 'fal' },\n\n    // 音频模型\n    { modelId: 'fal-ai/index-tts-2/text-to-speech', name: 'IndexTTS 2', type: 'audio', provider: 'fal' },\n    { modelId: 'qwen3-tts-vd-2026-01-26', name: 'Qwen3 TTS', type: 'audio', provider: 'bailian' },\n    { modelId: 'qwen-voice-design', name: 'Qwen Voice Design', type: 'audio', provider: 'bailian' },\n    // 口型同步模型\n    { modelId: 'fal-ai/kling-video/lipsync/audio-to-video', name: 'Kling Lip Sync', type: 'lipsync', provider: 'fal' },\n    { modelId: 'vidu-lipsync', name: 'Vidu Lip Sync', type: 'lipsync', provider: 'vidu' },\n    { modelId: 'videoretalk', name: 'VideoRetalk Lip Sync', type: 'lipsync', provider: 'bailian' },\n\n    // MiniMax 视频模型\n    { modelId: 'minimax-hailuo-2.3', name: 'Hailuo 2.3', type: 'video', provider: 'minimax' },\n    { modelId: 'minimax-hailuo-2.3-fast', name: 'Hailuo 2.3 Fast', type: 'video', provider: 'minimax' },\n    { modelId: 'minimax-hailuo-02', name: 'Hailuo 02', type: 'video', provider: 'minimax' },\n    { modelId: 't2v-01', name: 'T2V-01', type: 'video', provider: 'minimax' },\n    { modelId: 't2v-01-director', name: 'T2V-01 Director', type: 'video', provider: 'minimax' },\n\n    // Vidu 视频模型\n    { modelId: 'viduq3-pro', name: 'Vidu Q3 Pro', type: 'video', provider: 'vidu' },\n    { modelId: 'viduq2-pro-fast', name: 'Vidu Q2 Pro Fast', type: 'video', provider: 'vidu' },\n    { modelId: 'viduq2-pro', name: 'Vidu Q2 Pro', type: 'video', provider: 'vidu' },\n    { modelId: 'viduq2-turbo', name: 'Vidu Q2 Turbo', type: 'video', provider: 'vidu' },\n    { modelId: 'viduq1', name: 'Vidu Q1', type: 'video', provider: 'vidu' },\n    { modelId: 'viduq1-classic', name: 'Vidu Q1 Classic', type: 'video', provider: 'vidu' },\n    { modelId: 'vidu2.0', name: 'Vidu 2.0', type: 'video', provider: 'vidu' },\n]\n\nconst PRESET_COMING_SOON_MODEL_KEYS = new Set<string>([\n    encodeModelKey('ark', 'doubao-seedance-2-0-260128'),\n])\n\nexport function isPresetComingSoonModel(provider: string, modelId: string): boolean {\n    return PRESET_COMING_SOON_MODEL_KEYS.has(encodeModelKey(provider, modelId))\n}\n\nexport function isPresetComingSoonModelKey(modelKey: string): boolean {\n    return PRESET_COMING_SOON_MODEL_KEYS.has(modelKey)\n}\n\n// 预设提供商（API Key 唯一归属于 provider id）\nexport const PRESET_PROVIDERS: Omit<Provider, 'apiKey' | 'hasApiKey'>[] = [\n    { id: 'ark', name: 'Volcengine Ark' },\n    { id: 'google', name: 'Google AI Studio' },\n    { id: 'bailian', name: 'Alibaba Bailian' },\n    { id: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1' },\n    { id: 'minimax', name: 'MiniMax Hailuo', baseUrl: 'https://api.minimaxi.com/v1' },\n    { id: 'vidu', name: 'Vidu' },\n    { id: 'fal', name: 'FAL' },\n]\n\nconst ZH_PROVIDER_NAME_MAP: Record<string, string> = {\n    ark: '火山引擎 Ark',\n    minimax: '海螺 MiniMax',\n    vidu: '生数科技 Vidu',\n    bailian: '阿里云百炼',\n    siliconflow: '硅基流动',\n}\n\nfunction isZhLocale(locale?: string): boolean {\n    return typeof locale === 'string' && locale.toLowerCase().startsWith('zh')\n}\n\nexport function resolvePresetProviderName(providerId: string, fallbackName: string, locale?: string): string {\n    if (!isZhLocale(locale)) return fallbackName\n    return ZH_PROVIDER_NAME_MAP[providerId] ?? fallbackName\n}\n\n/**\n * 提取提供商主键（用于多实例场景，如 gemini-compatible:uuid）\n */\nexport function getProviderKey(providerId?: string): string {\n    if (!providerId) return ''\n    const colonIndex = providerId.indexOf(':')\n    return colonIndex === -1 ? providerId : providerId.slice(0, colonIndex)\n}\n\n/**\n * 获取厂商的友好显示名称\n * @param providerId - 厂商ID（如 'ark', 'google'）\n * @returns 友好名称（如 '火山引擎(方舟)', 'Google AI Studio'）\n */\nexport function getProviderDisplayName(providerId?: string, locale?: string): string {\n    if (!providerId) return ''\n    const providerKey = getProviderKey(providerId)\n    const provider = PRESET_PROVIDERS.find(p => p.id === providerKey)\n    if (!provider) return providerId\n    return resolvePresetProviderName(provider.id, provider.name, locale)\n}\n\n/**\n * 编码模型复合 Key（用于区分同名模型）\n * @param provider - 厂商 ID\n * @param modelId - 模型 ID\n * @returns 复合 Key，格式为 `provider::modelId`（使用双冒号避免与 provider ID 中的冒号冲突）\n */\nexport function encodeModelKey(provider: string, modelId: string): string {\n    return composeModelKey(provider, modelId)\n}\n\n/**\n * 解析模型复合 Key\n * @param key - 复合 Key（provider::modelId）\n * @returns 解析后的 { provider, modelId }，如果无法解析返回 null\n */\nexport function parseModelKey(key: string | undefined | null): { provider: string, modelId: string } | null {\n    const parsed = parseModelKeyStrict(key)\n    if (!parsed) return null\n    return {\n        provider: parsed.provider,\n        modelId: parsed.modelId,\n    }\n}\n\n/**\n * 检查一个复合 Key 是否匹配指定的模型\n * @param key - 复合 Key（provider::modelId）\n * @param provider - 目标厂商 ID\n * @param modelId - 目标模型 ID\n * @returns 是否匹配\n */\nexport function matchesModelKey(key: string | undefined | null, provider: string, modelId: string): boolean {\n    const parsed = parseModelKeyStrict(key)\n    if (!parsed) return false\n    return parsed.provider === provider && parsed.modelId === modelId\n}\n\n// 教程步骤接口\nexport interface TutorialStep {\n    text: string           // 步骤描述 (i18n key)\n    url?: string           // 可选的链接地址\n}\n\n// 厂商教程接口\nexport interface ProviderTutorial {\n    providerId: string\n    steps: TutorialStep[]\n}\n\n// 厂商开通教程配置\n// 注意: text 字段使用 i18n key, 翻译在 apiConfig.tutorials 下\nexport const PROVIDER_TUTORIALS: ProviderTutorial[] = [\n    {\n        providerId: 'ark',\n        steps: [\n            {\n                text: 'ark_step1',\n                url: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D'\n            },\n            {\n                text: 'ark_step2',\n                url: 'https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model'\n            }\n        ]\n    },\n    {\n        providerId: 'openrouter',\n        steps: [\n            {\n                text: 'openrouter_step1',\n                url: 'https://openrouter.ai/settings/keys'\n            }\n        ]\n    },\n    {\n        providerId: 'fal',\n        steps: [\n            {\n                text: 'fal_step1',\n                url: 'https://fal.ai/dashboard/keys'\n            }\n        ]\n    },\n    {\n        providerId: 'google',\n        steps: [\n            {\n                text: 'google_step1',\n                url: 'https://aistudio.google.com/api-keys'\n            }\n        ]\n    },\n    {\n        providerId: 'minimax',\n        steps: [\n            {\n                text: 'minimax_step1',\n                url: 'https://platform.minimaxi.com/user-center/basic-information/interface-key'\n            }\n        ]\n    },\n    {\n        providerId: 'vidu',\n        steps: [\n            {\n                text: 'vidu_step1',\n                url: 'https://platform.vidu.cn/api-keys'\n            }\n        ]\n    },\n    {\n        providerId: 'gemini-compatible',\n        steps: [\n            {\n                text: 'gemini_compatible_step1'\n            }\n        ]\n    },\n    {\n        providerId: 'openai-compatible',\n        steps: [\n            {\n                text: 'openai_compatible_step1'\n            }\n        ]\n    },\n    {\n        providerId: 'bailian',\n        steps: [\n            {\n                text: 'bailian_step1',\n                url: 'https://bailian.console.aliyun.com/cn-beijing/?tab=model#/api-key'\n            }\n        ]\n    },\n    {\n        providerId: 'siliconflow',\n        steps: [\n            {\n                text: 'siliconflow_step1',\n                url: 'https://cloud.siliconflow.cn/account/ak'\n            }\n        ]\n    },\n]\n\n/**\n * 根据厂商ID获取教程配置\n * @param providerId - 厂商ID\n * @returns 教程配置，如果不存在则返回 undefined\n */\nexport function getProviderTutorial(providerId: string): ProviderTutorial | undefined {\n    const providerKey = getProviderKey(providerId)\n    return PROVIDER_TUTORIALS.find(t => t.providerId === providerKey)\n}\n\n/**\n * 获取 Google 官方模型列表的克隆副本，provider 替换为指定 ID。\n * 用于 gemini-compatible 新增时自动预设模型。\n * 排除 batch 模型（Google 特有的异步批量处理）。\n */\nexport function getGoogleCompatiblePresetModels(providerId: string): PresetModel[] {\n    return PRESET_MODELS\n        .filter((m) => m.provider === 'google' && !m.modelId.endsWith('-batch'))\n        .map((m) => ({ ...m, provider: providerId }))\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config-tab/ApiConfigProviderList.tsx",
    "content": "'use client'\n\nimport type { CSSProperties, ReactNode } from 'react'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n  DndContext,\n  KeyboardSensor,\n  PointerSensor,\n  closestCenter,\n  type DragEndEvent,\n  useSensor,\n  useSensors,\n} from '@dnd-kit/core'\nimport {\n  SortableContext,\n  rectSortingStrategy,\n  sortableKeyboardCoordinates,\n  useSortable,\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\nimport type { CustomModel, Provider } from '../api-config'\nimport { ProviderCard } from '../api-config'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface DefaultModels {\n  analysisModel?: string\n  characterModel?: string\n  locationModel?: string\n  storyboardModel?: string\n  editModel?: string\n  videoModel?: string\n  audioModel?: string\n  lipSyncModel?: string\n}\n\ninterface ApiConfigProviderListProps {\n  modelProviders: Provider[]\n  allModels: CustomModel[]\n  defaultModels: DefaultModels\n  getModelsForProvider: (providerId: string) => CustomModel[]\n  onAddGeminiProvider: () => void\n  onToggleModel: (modelKey: string, providerId: string) => void\n  onUpdateApiKey: (providerId: string, apiKey: string) => void\n  onUpdateBaseUrl: (providerId: string, baseUrl: string) => void\n  onReorderProviders: (activeProviderId: string, overProviderId: string) => void\n  onDeleteModel: (modelKey: string, providerId: string) => void\n  onUpdateModel: (modelKey: string, updates: Partial<CustomModel>, providerId: string) => void\n  onDeleteProvider: (providerId: string) => void\n  onAddModel: (model: Omit<CustomModel, 'enabled'>) => void\n  onFlushConfig: () => Promise<void>\n  onToggleProviderHidden: (providerId: string, hidden: boolean) => void\n  labels: {\n    providerPool: string\n    providerPoolDesc: string\n    dragToSort: string\n    dragToSortHint: string\n    hideProvider: string\n    showProvider: string\n    showHiddenProviders: string\n    hideHiddenProviders: string\n    hiddenProvidersPrefix: string\n    addGeminiProvider: string\n  }\n}\n\nexport function ApiConfigProviderList({\n  modelProviders,\n  allModels,\n  defaultModels,\n  getModelsForProvider,\n  onAddGeminiProvider,\n  onToggleModel,\n  onUpdateApiKey,\n  onUpdateBaseUrl,\n  onReorderProviders,\n  onDeleteModel,\n  onUpdateModel,\n  onDeleteProvider,\n  onAddModel,\n  onFlushConfig,\n  onToggleProviderHidden,\n  labels,\n}: ApiConfigProviderListProps) {\n  const [showHiddenProviders, setShowHiddenProviders] = useState(false)\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8,\n      },\n    }),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    }),\n  )\n\n  const handleDragEnd = useCallback(\n    (event: DragEndEvent) => {\n      const { active, over } = event\n      if (!over || active.id === over.id) return\n      onReorderProviders(String(active.id), String(over.id))\n    },\n    [onReorderProviders],\n  )\n\n  const providerModelsById = useMemo(() => {\n    const map = new Map<string, CustomModel[]>()\n    for (const provider of modelProviders) {\n      map.set(provider.id, getModelsForProvider(provider.id))\n    }\n    return map\n  }, [getModelsForProvider, modelProviders])\n\n  const hiddenProviders = useMemo(() => {\n    return modelProviders.filter((provider) => provider.hidden === true)\n  }, [modelProviders])\n\n  const visibleProviders = useMemo(() => {\n    const hiddenIds = new Set(hiddenProviders.map((provider) => provider.id))\n    return modelProviders.filter((provider) => !hiddenIds.has(provider.id))\n  }, [hiddenProviders, modelProviders])\n\n  const hiddenProviderNames = hiddenProviders.map((provider) => provider.name).join(' / ')\n\n  return (\n    <>\n      <div className=\"space-y-4\">\n        <div className=\"flex items-start justify-between\">\n          <div className=\"flex items-center gap-2.5\">\n            <span className=\"glass-surface-soft inline-flex h-7 w-7 items-center justify-center rounded-lg text-[var(--glass-text-secondary)]\">\n              <AppIcon name=\"cube\" className=\"w-4 h-4\" />\n            </span>\n            <div>\n              <h2 className=\"text-xl font-bold text-[var(--glass-text-primary)]\">{labels.providerPool}</h2>\n              <p className=\"text-[13px] text-[var(--glass-text-secondary)]\">{labels.providerPoolDesc}</p>\n              <p className=\"text-[12px] text-[var(--glass-text-tertiary)]\">{labels.dragToSortHint}</p>\n            </div>\n          </div>\n          <button\n            onClick={onAddGeminiProvider}\n            className=\"glass-btn-base glass-btn-primary cursor-pointer px-3 py-1.5 text-sm font-semibold\"\n          >\n            {labels.addGeminiProvider}\n          </button>\n        </div>\n        <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>\n          <SortableContext items={visibleProviders.map((provider) => provider.id)} strategy={rectSortingStrategy}>\n            <div className=\"grid grid-cols-1 gap-3 md:grid-cols-2\">\n              {visibleProviders.map((provider) => (\n                <SortableProviderCardItem key={provider.id} providerId={provider.id} dragLabel={labels.dragToSort}>\n                  {({ dragHandle }) => (\n                    <ProviderCard\n                      provider={provider}\n                      dragHandle={dragHandle}\n                      models={providerModelsById.get(provider.id) || []}\n                      allModels={allModels}\n                      defaultModels={defaultModels}\n                      onToggleModel={(modelKey) => onToggleModel(modelKey, provider.id)}\n                      onUpdateApiKey={onUpdateApiKey}\n                      onUpdateBaseUrl={onUpdateBaseUrl}\n                      onDeleteModel={(modelKey) => onDeleteModel(modelKey, provider.id)}\n                      onUpdateModel={(modelKey, updates) => onUpdateModel(modelKey, updates, provider.id)}\n                      onDeleteProvider={onDeleteProvider}\n                      onAddModel={onAddModel}\n                      onFlushConfig={onFlushConfig}\n                      onToggleProviderHidden={onToggleProviderHidden}\n                      hideProviderLabel={labels.hideProvider}\n                      showProviderLabel={labels.showProvider}\n                    />\n                  )}\n                </SortableProviderCardItem>\n              ))}\n            </div>\n          </SortableContext>\n        </DndContext>\n        {hiddenProviders.length > 0 && (\n          <>\n            <button\n              type=\"button\"\n              onClick={() => setShowHiddenProviders((prev) => !prev)}\n              className=\"glass-btn-base glass-btn-secondary flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left\"\n            >\n              <div className=\"min-w-0\">\n                <p className=\"truncate text-sm font-medium text-[var(--glass-text-primary)]\">\n                  {showHiddenProviders\n                    ? labels.hideHiddenProviders\n                    : `${labels.showHiddenProviders} (${hiddenProviders.length})`}\n                </p>\n                <p className=\"truncate text-xs text-[var(--glass-text-tertiary)]\">\n                  {labels.hiddenProvidersPrefix}: {hiddenProviderNames}\n                </p>\n              </div>\n              <AppIcon\n                name={showHiddenProviders ? 'chevronUp' : 'chevronDown'}\n                className=\"h-4 w-4 shrink-0 text-[var(--glass-text-secondary)]\"\n              />\n            </button>\n            {showHiddenProviders && (\n              <div className=\"grid grid-cols-1 gap-3 md:grid-cols-2\">\n                {hiddenProviders.map((provider) => (\n                  <ProviderCard\n                    key={`hidden-${provider.id}`}\n                    provider={provider}\n                    models={providerModelsById.get(provider.id) || []}\n                    allModels={allModels}\n                    defaultModels={defaultModels}\n                    onToggleModel={(modelKey) => onToggleModel(modelKey, provider.id)}\n                    onUpdateApiKey={onUpdateApiKey}\n                    onUpdateBaseUrl={onUpdateBaseUrl}\n                    onDeleteModel={(modelKey) => onDeleteModel(modelKey, provider.id)}\n                    onUpdateModel={(modelKey, updates) => onUpdateModel(modelKey, updates, provider.id)}\n                    onDeleteProvider={onDeleteProvider}\n                    onAddModel={onAddModel}\n                    onFlushConfig={onFlushConfig}\n                    onToggleProviderHidden={onToggleProviderHidden}\n                    hideProviderLabel={labels.hideProvider}\n                    showProviderLabel={labels.showProvider}\n                  />\n                ))}\n              </div>\n            )}\n          </>\n        )}\n      </div>\n    </>\n  )\n}\n\ninterface SortableProviderCardItemProps {\n  providerId: string\n  dragLabel: string\n  children: (props: { dragHandle: ReactNode }) => ReactNode\n}\n\nfunction SortableProviderCardItem({ providerId, dragLabel, children }: SortableProviderCardItemProps) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id: providerId })\n\n  const style: CSSProperties = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.9 : 1,\n    zIndex: isDragging ? 20 : 1,\n  }\n\n  return (\n    <div ref={setNodeRef} style={style}>\n      {children({\n        dragHandle: (\n          <button\n            type=\"button\"\n            aria-label={dragLabel}\n            title={dragLabel}\n            className=\"inline-flex cursor-grab items-center justify-center rounded-md p-1 text-[var(--glass-text-tertiary)] touch-none transition-colors hover:text-[var(--glass-text-secondary)] active:cursor-grabbing\"\n            {...attributes}\n            {...listeners}\n          >\n            <AppIcon name=\"gripVertical\" className=\"h-3.5 w-3.5\" />\n          </button>\n        ),\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config-tab/ApiConfigTabContainer.tsx",
    "content": "'use client'\n\nimport { useState, useCallback } from 'react'\nimport { useLocale, useTranslations } from 'next-intl'\nimport { GlassModalShell } from '@/components/ui/primitives'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport type { CapabilityValue } from '@/lib/model-config-contract'\nimport { apiFetch } from '@/lib/api-fetch'\nimport {\n  encodeModelKey,\n  getProviderDisplayName,\n  parseModelKey,\n  useProviders,\n} from '../api-config'\nimport { ApiConfigToolbar } from './ApiConfigToolbar'\nimport { ApiConfigProviderList } from './ApiConfigProviderList'\nimport { DefaultModelCards } from './DefaultModelCards'\nimport { useApiConfigFilters } from './hooks/useApiConfigFilters'\nimport { AppIcon } from '@/components/ui/icons'\n\ntype TestStepStatus = 'pass' | 'fail' | 'skip'\ninterface TestStep {\n  name: string\n  status: TestStepStatus\n  message: string\n  model?: string\n  detail?: string\n}\ntype TestStatus = 'idle' | 'testing' | 'passed' | 'failed'\n\ntype CustomProviderType = 'gemini-compatible' | 'openai-compatible'\n\nconst Icons = {\n  settings: () => (\n    <AppIcon name=\"settingsHex\" className=\"w-3.5 h-3.5\" />\n  ),\n  llm: () => (\n    <AppIcon name=\"menu\" className=\"w-3.5 h-3.5\" />\n  ),\n  image: () => (\n    <AppIcon name=\"image\" className=\"w-3.5 h-3.5\" />\n  ),\n  video: () => (\n    <AppIcon name=\"video\" className=\"w-3.5 h-3.5\" />\n  ),\n  audio: () => (\n    <AppIcon name=\"audioWave\" className=\"w-3.5 h-3.5\" />\n  ),\n  lipsync: () => (\n    <AppIcon name=\"audioWave\" className=\"w-3.5 h-3.5\" />\n  ),\n  chevronDown: () => (\n    <AppIcon name=\"chevronDown\" className=\"w-3 h-3\" />\n  ),\n}\n\n\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isCapabilityValue(value: unknown): value is CapabilityValue {\n  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction extractCapabilityFieldsFromModel(\n  capabilities: Record<string, unknown> | undefined,\n  modelType: string,\n): Array<{ field: string; options: CapabilityValue[] }> {\n  if (!capabilities) return []\n  const namespace = capabilities[modelType]\n  if (!isRecord(namespace)) return []\n  return Object.entries(namespace)\n    .filter(([key, value]) => key.endsWith('Options') && Array.isArray(value) && value.every(isCapabilityValue) && value.length > 0)\n    .map(([key, value]) => ({\n      field: key.slice(0, -'Options'.length),\n      options: value as CapabilityValue[],\n    }))\n}\n\nfunction parseBySample(input: string, sample: CapabilityValue): CapabilityValue {\n  if (typeof sample === 'number') return Number(input)\n  if (typeof sample === 'boolean') return input === 'true'\n  return input\n}\n\nfunction toCapabilityFieldLabel(field: string): string {\n  return field.replace(/([A-Z])/g, ' $1').replace(/^./, (char) => char.toUpperCase())\n}\n\nexport function ApiConfigTabContainer() {\n  const locale = useLocale()\n  const {\n    providers,\n    models,\n    defaultModels,\n    workflowConcurrency,\n    capabilityDefaults,\n    loading,\n    saveStatus,\n    flushConfig,\n    updateProviderHidden,\n    updateProviderApiKey,\n    updateProviderBaseUrl,\n    reorderProviders,\n    addProvider,\n    deleteProvider,\n    toggleModel,\n    deleteModel,\n    addModel,\n    updateModel,\n    updateDefaultModel,\n    batchUpdateDefaultModels,\n    updateWorkflowConcurrency,\n    updateCapabilityDefault,\n  } = useProviders()\n\n  const t = useTranslations('apiConfig')\n  const tc = useTranslations('common')\n  const tp = useTranslations('providerSection')\n\n  const savingState =\n    saveStatus === 'saving'\n      ? resolveTaskPresentationState({\n        phase: 'processing',\n        intent: 'modify',\n        resource: 'text',\n        hasOutput: true,\n      })\n      : null\n\n  const {\n    modelProviders,\n    getModelsForProvider,\n    getEnabledModelsByType,\n  } = useApiConfigFilters({\n    providers,\n    models,\n  })\n\n  const [showAddGeminiProvider, setShowAddGeminiProvider] = useState(false)\n  const [newGeminiProvider, setNewGeminiProvider] = useState<{\n    name: string\n    baseUrl: string\n    apiKey: string\n    apiType: CustomProviderType\n  }>({\n    name: '',\n    baseUrl: '',\n    apiKey: '',\n    apiType: 'gemini-compatible',\n  })\n  const [testStatus, setTestStatus] = useState<TestStatus>('idle')\n  const [testSteps, setTestSteps] = useState<TestStep[]>([])\n\n  const doAddProvider = useCallback(() => {\n    const uuid = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')\n      ? crypto.randomUUID()\n      : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`\n    const providerId = `${newGeminiProvider.apiType}:${uuid}`\n    const name = newGeminiProvider.name.trim()\n    const baseUrl = newGeminiProvider.baseUrl.trim()\n    const apiKey = newGeminiProvider.apiKey.trim()\n\n    addProvider({\n      id: providerId,\n      name,\n      baseUrl,\n      apiKey,\n      apiMode: newGeminiProvider.apiType === 'openai-compatible' ? 'openai-official' : 'gemini-sdk',\n    })\n\n    setNewGeminiProvider({ name: '', baseUrl: '', apiKey: '', apiType: 'gemini-compatible' })\n    setTestStatus('idle')\n    setTestSteps([])\n    setShowAddGeminiProvider(false)\n  }, [newGeminiProvider, addProvider])\n\n  const handleAddGeminiProvider = useCallback(async () => {\n    if (!newGeminiProvider.name || !newGeminiProvider.baseUrl) {\n      alert(tp('fillRequired'))\n      return\n    }\n\n    setTestStatus('testing')\n    setTestSteps([])\n\n    try {\n      const res = await apiFetch('/api/user/api-config/test-provider', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          apiType: newGeminiProvider.apiType,\n          baseUrl: newGeminiProvider.baseUrl.trim(),\n          apiKey: newGeminiProvider.apiKey.trim(),\n        }),\n      })\n\n      const data = await res.json()\n      const steps: TestStep[] = data.steps || []\n      setTestSteps(steps)\n\n      if (data.success) {\n        setTestStatus('passed')\n        // Auto-add on success\n        doAddProvider()\n      } else {\n        setTestStatus('failed')\n      }\n    } catch {\n      setTestSteps([{ name: 'models', status: 'fail', message: 'Network error' }])\n      setTestStatus('failed')\n    }\n  }, [newGeminiProvider, tp, doAddProvider])\n\n  const handleForceAdd = useCallback(() => {\n    doAddProvider()\n  }, [doAddProvider])\n\n  const handleCancelAddGeminiProvider = () => {\n    setNewGeminiProvider({ name: '', baseUrl: '', apiKey: '', apiType: 'gemini-compatible' })\n    setTestStatus('idle')\n    setTestSteps([])\n    setShowAddGeminiProvider(false)\n  }\n\n  const handleWorkflowConcurrencyChange = useCallback(\n    (field: 'analysis' | 'image' | 'video', rawValue: string) => {\n      const parsed = Number.parseInt(rawValue, 10)\n      if (!Number.isFinite(parsed) || parsed <= 0) return\n      updateWorkflowConcurrency(field, parsed)\n    },\n    [updateWorkflowConcurrency],\n  )\n\n  if (loading) {\n    return (\n      <div className=\"flex h-full items-center justify-center p-6 text-[var(--glass-text-tertiary)]\">\n        {tc('loading')}\n      </div>\n    )\n  }\n\n\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <ApiConfigToolbar\n        title={t('title')}\n        saveStatus={saveStatus}\n        savingState={savingState}\n        savingLabel={t('saving')}\n        savedLabel={t('saved')}\n        saveFailedLabel={t('saveFailed')}\n      />\n\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"space-y-6 p-6\">\n          <DefaultModelCards\n            t={t}\n            defaultModels={defaultModels}\n            getEnabledModelsByType={getEnabledModelsByType}\n            parseModelKey={parseModelKey}\n            encodeModelKey={encodeModelKey}\n            getProviderDisplayName={getProviderDisplayName}\n            locale={locale}\n            updateDefaultModel={updateDefaultModel}\n            batchUpdateDefaultModels={batchUpdateDefaultModels}\n            extractCapabilityFieldsFromModel={extractCapabilityFieldsFromModel}\n            toCapabilityFieldLabel={toCapabilityFieldLabel}\n            capabilityDefaults={capabilityDefaults}\n            updateCapabilityDefault={updateCapabilityDefault}\n            parseBySample={parseBySample}\n            workflowConcurrency={workflowConcurrency}\n            handleWorkflowConcurrencyChange={handleWorkflowConcurrencyChange}\n          />\n\n          <ApiConfigProviderList\n            modelProviders={modelProviders}\n            allModels={models}\n            defaultModels={defaultModels}\n            getModelsForProvider={getModelsForProvider}\n            onAddGeminiProvider={() => setShowAddGeminiProvider(true)}\n            onToggleModel={toggleModel}\n            onUpdateApiKey={updateProviderApiKey}\n            onUpdateBaseUrl={updateProviderBaseUrl}\n            onReorderProviders={reorderProviders}\n            onDeleteModel={deleteModel}\n            onUpdateModel={updateModel}\n            onDeleteProvider={deleteProvider}\n            onAddModel={addModel}\n            onFlushConfig={flushConfig}\n            onToggleProviderHidden={updateProviderHidden}\n            labels={{\n              providerPool: t('providerPool'),\n              providerPoolDesc: t('providerPoolDesc'),\n              dragToSort: t('dragToSort'),\n              dragToSortHint: t('dragToSortHint'),\n              hideProvider: t('hideProvider'),\n              showProvider: t('showProvider'),\n              showHiddenProviders: t('showHiddenProviders'),\n              hideHiddenProviders: t('hideHiddenProviders'),\n              hiddenProvidersPrefix: t('hiddenProvidersPrefix'),\n              addGeminiProvider: t('addGeminiProvider'),\n            }}\n          />\n        </div>\n      </div>\n\n      <GlassModalShell\n        open={showAddGeminiProvider}\n        onClose={handleCancelAddGeminiProvider}\n        title={t('addGeminiProvider')}\n        description={t('providerPool')}\n        size=\"md\"\n        footer={\n          <div className=\"flex justify-end gap-2\">\n            <button\n              onClick={handleCancelAddGeminiProvider}\n              className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 text-sm\"\n            >\n              {tc('cancel')}\n            </button>\n            {testStatus === 'failed' && (\n              <button\n                onClick={handleForceAdd}\n                className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 text-sm\"\n              >\n                {t('addAnyway')}\n              </button>\n            )}\n            {testStatus === 'failed' ? (\n              <button\n                onClick={handleAddGeminiProvider}\n                className=\"glass-btn-base glass-btn-primary px-3 py-1.5 text-sm\"\n              >\n                {t('testRetry')}\n              </button>\n            ) : (\n              <button\n                onClick={handleAddGeminiProvider}\n                disabled={testStatus === 'testing'}\n                className=\"glass-btn-base glass-btn-primary px-3 py-1.5 text-sm disabled:opacity-50\"\n              >\n                {testStatus === 'testing' ? t('testing') : tp('add')}\n              </button>\n            )}\n          </div>\n        }\n      >\n        <div className=\"space-y-3\">\n          <div className=\"flex items-start gap-2 px-3 py-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400\">\n            <AppIcon name=\"alert\" className=\"w-4 h-4 shrink-0 mt-0.5\" />\n            <span className=\"text-[12px] leading-relaxed\">{t('customProviderTip')}</span>\n          </div>\n          <div>\n            <label className=\"mb-1.5 block text-xs font-medium text-[var(--glass-text-primary)]\">\n              {t('apiType')}\n            </label>\n            <div className=\"relative\">\n              <select\n                value={newGeminiProvider.apiType}\n                onChange={(event) =>\n                  setNewGeminiProvider({\n                    ...newGeminiProvider,\n                    apiType: event.target.value as CustomProviderType,\n                  })\n                }\n                disabled={testStatus === 'testing'}\n                className=\"glass-select-base w-full cursor-pointer appearance-none px-3 py-2.5 pr-8 text-sm\"\n              >\n                <option value=\"gemini-compatible\">{t('apiTypeGeminiCompatible')}</option>\n                <option value=\"openai-compatible\">{t('apiTypeOpenAICompatible')}</option>\n              </select>\n              <div className=\"pointer-events-none absolute right-3 top-3 text-[var(--glass-text-tertiary)]\">\n                <Icons.chevronDown />\n              </div>\n            </div>\n          </div>\n\n          <div>\n            <label className=\"mb-1.5 block text-xs font-medium text-[var(--glass-text-primary)]\">\n              {tp('name')}\n            </label>\n            <input\n              type=\"text\"\n              value={newGeminiProvider.name}\n              onChange={(event) =>\n                setNewGeminiProvider({\n                  ...newGeminiProvider,\n                  name: event.target.value,\n                })\n              }\n              disabled={testStatus === 'testing'}\n              placeholder={tp('name')}\n              className=\"glass-input-base w-full px-3 py-2.5 text-sm\"\n            />\n          </div>\n\n          <div>\n            <label className=\"mb-1.5 block text-xs font-medium text-[var(--glass-text-primary)]\">\n              {t('baseUrl')}\n            </label>\n            <input\n              type=\"text\"\n              value={newGeminiProvider.baseUrl}\n              onChange={(event) =>\n                setNewGeminiProvider({\n                  ...newGeminiProvider,\n                  baseUrl: event.target.value,\n                })\n              }\n              disabled={testStatus === 'testing'}\n              placeholder={t('baseUrl')}\n              className=\"glass-input-base w-full px-3 py-2.5 text-sm font-mono\"\n            />\n          </div>\n\n          <div>\n            <label className=\"mb-1.5 block text-xs font-medium text-[var(--glass-text-primary)]\">\n              {t('apiKeyLabel')}\n            </label>\n            <input\n              type=\"password\"\n              value={newGeminiProvider.apiKey}\n              onChange={(event) =>\n                setNewGeminiProvider({\n                  ...newGeminiProvider,\n                  apiKey: event.target.value,\n                })\n              }\n              disabled={testStatus === 'testing'}\n              placeholder={t('apiKeyLabel')}\n              className=\"glass-input-base w-full px-3 py-2.5 text-sm\"\n            />\n          </div>\n\n          {/* Test Results */}\n          {testStatus !== 'idle' && (\n            <div className=\"space-y-2 rounded-xl border border-[var(--glass-border)] p-3\">\n              <div className=\"flex items-center gap-2 text-xs font-semibold text-[var(--glass-text-primary)]\">\n                <AppIcon name=\"settingsHex\" className=\"h-3.5 w-3.5\" />\n                {t('testConnection')}\n              </div>\n\n              {testStatus === 'testing' && testSteps.length === 0 && (\n                <div className=\"flex items-center gap-2 text-xs text-[var(--glass-text-secondary)]\">\n                  <span className=\"inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent\" />\n                  {t('testing')}\n                </div>\n              )}\n\n              {testSteps.map((step) => {\n                const stepLabel = t(`testStep.${step.name}` as Parameters<typeof t>[0])\n                return (\n                  <div key={step.name} className=\"space-y-0.5\">\n                    <div className=\"flex items-center gap-2 text-xs\">\n                      {step.status === 'pass' && (\n                        <span className=\"text-green-500\">\n                          <AppIcon name=\"check\" className=\"h-3.5 w-3.5\" />\n                        </span>\n                      )}\n                      {step.status === 'fail' && (\n                        <span className=\"text-red-500\">\n                          <AppIcon name=\"close\" className=\"h-3.5 w-3.5\" />\n                        </span>\n                      )}\n                      {step.status === 'skip' && (\n                        <span className=\"text-[var(--glass-text-tertiary)]\">–</span>\n                      )}\n                      <span className=\"font-medium text-[var(--glass-text-primary)]\">\n                        {stepLabel}\n                      </span>\n                      {step.model && (\n                        <span className=\"rounded bg-[var(--glass-bg-surface)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--glass-text-secondary)]\">\n                          {step.model}\n                        </span>\n                      )}\n                    </div>\n                    <p className={`pl-5 text-[11px] ${step.status === 'fail' ? 'text-red-400' : 'text-[var(--glass-text-secondary)]'}`}>\n                      {step.message}\n                    </p>\n                    {step.detail && (\n                      <p className=\"pl-5 text-[10px] text-[var(--glass-text-tertiary)] break-all line-clamp-3\">\n                        {step.detail}\n                      </p>\n                    )}\n                  </div>\n                )\n              })}\n\n              {testStatus === 'failed' && (\n                <div className=\"flex items-start gap-2 rounded-lg bg-yellow-500/10 px-2.5 py-2 text-[11px] text-yellow-600 dark:text-yellow-400\">\n                  <span className=\"mt-0.5 shrink-0\">⚠</span>\n                  <span>{t('testWarning')}</span>\n                </div>\n              )}\n\n              {testStatus === 'passed' && (\n                <div className=\"flex items-center gap-2 rounded-lg bg-green-500/10 px-2.5 py-2 text-[11px] text-green-600 dark:text-green-400\">\n                  <AppIcon name=\"check\" className=\"h-3.5 w-3.5\" />\n                  {t('testPassed')}\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n      </GlassModalShell>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config-tab/ApiConfigToolbar.tsx",
    "content": "'use client'\n\nimport type { ComponentProps } from 'react'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ApiConfigToolbarProps {\n  title: string\n  saveStatus: 'idle' | 'saving' | 'saved' | 'error'\n  savingState: ComponentProps<typeof TaskStatusInline>['state'] | null\n  savingLabel: string\n  savedLabel: string\n  saveFailedLabel: string\n}\n\nexport function ApiConfigToolbar({\n  title,\n  saveStatus,\n  savingState,\n  savingLabel,\n  savedLabel,\n  saveFailedLabel,\n}: ApiConfigToolbarProps) {\n  return (\n    <div className=\"flex items-center justify-between border-b border-[var(--glass-stroke-base)] px-6 py-4\">\n      <h2 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">{title}</h2>\n      <div className=\"flex items-center gap-2 text-sm\">\n        {saveStatus === 'saving' && (\n          <span className=\"glass-chip glass-chip-info flex items-center gap-1\">\n            <TaskStatusInline state={savingState} className=\"[&>span]:sr-only\" />\n            <span>{savingLabel}</span>\n          </span>\n        )}\n        {saveStatus === 'saved' && (\n          <span className=\"glass-chip glass-chip-success flex items-center gap-1\">\n            <AppIcon name=\"check\" className=\"w-4 h-4\" />\n            {savedLabel}\n          </span>\n        )}\n        {saveStatus === 'error' && (\n          <span className=\"glass-chip glass-chip-danger flex items-center gap-1\">\n            <AppIcon name=\"close\" className=\"w-4 h-4\" />\n            {saveFailedLabel}\n          </span>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config-tab/DefaultModelCards.tsx",
    "content": "'use client'\n\nimport React, { useState, useCallback } from 'react'\nimport { AppIcon } from '@/components/ui/icons'\nimport type { AppIconName } from '@/components/ui/icons'\nimport { ModelCapabilityDropdown } from '@/components/ui/config-modals/ModelCapabilityDropdown'\nimport type { CapabilityValue, ModelCapabilities } from '@/lib/model-config-contract'\n\n// ---------- types ----------\ntype ModelType = 'llm' | 'image' | 'video' | 'audio' | 'lipsync' | 'voicedesign'\n\ninterface ModelOption {\n    modelKey: string\n    name: string\n    provider: string\n    providerName?: string\n    capabilities?: ModelCapabilities\n}\n\ntype DefaultModelField =\n    | 'analysisModel'\n    | 'characterModel'\n    | 'locationModel'\n    | 'storyboardModel'\n    | 'editModel'\n    | 'videoModel'\n    | 'audioModel'\n    | 'lipSyncModel'\n    | 'voiceDesignModel'\n\ninterface DefaultModelCardsProps {\n    t: (key: string) => string\n    defaultModels: {\n        analysisModel?: string\n        characterModel?: string\n        locationModel?: string\n        storyboardModel?: string\n        editModel?: string\n        videoModel?: string\n        audioModel?: string\n        lipSyncModel?: string\n        voiceDesignModel?: string\n    }\n    getEnabledModelsByType: (type: ModelType) => ModelOption[]\n    parseModelKey: (key: string | undefined | null) => { provider: string; modelId: string } | null\n    encodeModelKey: (provider: string, modelId: string) => string\n    getProviderDisplayName: (providerId: string, locale: string) => string\n    locale: string\n    updateDefaultModel: (field: string, value: string, capFields?: Array<{ field: string; options: CapabilityValue[] }>) => void\n    batchUpdateDefaultModels: (fields: string[], value: string, capFields?: Array<{ field: string; options: CapabilityValue[] }>) => void\n    extractCapabilityFieldsFromModel: (\n        caps: Record<string, unknown> | undefined,\n        modelType: string,\n    ) => Array<{ field: string; options: CapabilityValue[] }>\n    toCapabilityFieldLabel: (field: string) => string\n    capabilityDefaults: Record<string, Record<string, CapabilityValue>>\n    updateCapabilityDefault: (modelKey: string, field: string, value: CapabilityValue | null) => void\n    parseBySample: (input: string, sample: CapabilityValue) => CapabilityValue\n    workflowConcurrency: { analysis: number; image: number; video: number }\n    handleWorkflowConcurrencyChange: (field: 'analysis' | 'image' | 'video', rawValue: string) => void\n}\n\n// ---------- helpers ----------\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n    return !!value && typeof value === 'object' && !Array.isArray(value)\n}\nfunction isCapabilityValue(value: unknown): value is CapabilityValue {\n    return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction resolveModel(\n    field: DefaultModelField,\n    modelType: ModelType,\n    defaultModels: DefaultModelCardsProps['defaultModels'],\n    getEnabledModelsByType: DefaultModelCardsProps['getEnabledModelsByType'],\n    parseModelKey: DefaultModelCardsProps['parseModelKey'],\n    encodeModelKey: DefaultModelCardsProps['encodeModelKey'],\n) {\n    const options = getEnabledModelsByType(modelType)\n    const currentKey = defaultModels[field]\n    const parsed = parseModelKey(currentKey)\n    const normalizedKey = parsed ? encodeModelKey(parsed.provider, parsed.modelId) : ''\n    const current = normalizedKey ? options.find((option) => option.modelKey === normalizedKey) ?? null : null\n    return { options, normalizedKey, current }\n}\n\nfunction computeCapabilityFields(current: ModelOption | null, modelType: keyof ModelCapabilities) {\n    if (!current || !current.capabilities) return [] as Array<{ field: string; options: CapabilityValue[] }>\n    const namespace = current.capabilities[modelType]\n    if (!isRecord(namespace)) return [] as Array<{ field: string; options: CapabilityValue[] }>\n    return Object.entries(namespace)\n        .filter(([key, value]) => key.endsWith('Options') && Array.isArray(value) && value.every(isCapabilityValue) && value.length > 0)\n        .map(([key, value]) => ({\n            field: key.slice(0, -'Options'.length),\n            options: value as CapabilityValue[],\n        }))\n}\n\n// ---------- sub-components ----------\n\n/** Smart model selector: ModelCapabilityDropdown for llm/image/video, native select for others */\nfunction SmartSelector({\n    field,\n    modelType,\n    options,\n    normalizedKey,\n    current,\n    placeholder,\n    locale,\n    t,\n    props,\n}: {\n    field: DefaultModelField\n    modelType: ModelType\n    options: ModelOption[]\n    normalizedKey: string\n    current: ModelOption | null\n    placeholder: string\n    locale: string\n    t: (key: string) => string\n    props: DefaultModelCardsProps\n}) {\n    const capabilityFields = computeCapabilityFields(current, modelType as keyof ModelCapabilities)\n\n    if (modelType === 'video' || modelType === 'image' || modelType === 'llm') {\n        return (\n            <ModelCapabilityDropdown\n                models={options.map((opt) => ({\n                    value: opt.modelKey,\n                    label: opt.name,\n                    provider: opt.provider,\n                    providerName: opt.providerName || props.getProviderDisplayName(opt.provider, locale),\n                }))}\n                value={normalizedKey || undefined}\n                onModelChange={(newModelKey) => {\n                    const newModel = options.find((opt) => opt.modelKey === newModelKey)\n                    const newCapFields = props.extractCapabilityFieldsFromModel(\n                        newModel?.capabilities as Record<string, unknown> | undefined,\n                        modelType,\n                    )\n                    props.updateDefaultModel(field, newModelKey, newCapFields)\n                }}\n                capabilityFields={capabilityFields.map((d) => ({\n                    ...d,\n                    label: props.toCapabilityFieldLabel(d.field),\n                }))}\n                capabilityOverrides={\n                    current\n                        ? Object.fromEntries(\n                            capabilityFields\n                                .filter((d) => props.capabilityDefaults[current.modelKey]?.[d.field] !== undefined)\n                                .map((d) => [d.field, props.capabilityDefaults[current.modelKey][d.field]])\n                        )\n                        : {}\n                }\n                onCapabilityChange={(capField, rawValue, sample) => {\n                    if (!current) return\n                    if (!rawValue) {\n                        props.updateCapabilityDefault(current.modelKey, capField, null)\n                        return\n                    }\n                    props.updateCapabilityDefault(current.modelKey, capField, props.parseBySample(rawValue, sample))\n                }}\n                placeholder={placeholder}\n            />\n        )\n    }\n\n    // Native select for audio / lipsync / voicedesign\n    return (\n        <div className=\"relative\">\n            <select\n                value={normalizedKey}\n                onChange={(event) => props.updateDefaultModel(field, event.target.value)}\n                className=\"glass-input-base w-full appearance-none px-3 py-2 text-[13px] rounded-xl outline-none transition-all text-[var(--glass-text-primary)]\"\n            >\n                <option value=\"\">{placeholder}</option>\n                {options.map((option, index) => (\n                    <option key={`${option.modelKey}-${index}`} value={option.modelKey}>\n                        {option.name} ({option.providerName || props.getProviderDisplayName(option.provider, locale)})\n                    </option>\n                ))}\n            </select>\n            <div className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-[var(--glass-text-tertiary)]\">\n                <AppIcon name=\"chevronDown\" className=\"h-4 w-4\" />\n            </div>\n        </div>\n    )\n}\n\n// ---------- main component ----------\n\nexport function DefaultModelCards(allProps: DefaultModelCardsProps) {\n    const {\n        t,\n        defaultModels,\n        getEnabledModelsByType,\n        parseModelKey,\n        encodeModelKey,\n        getProviderDisplayName,\n        locale,\n        updateDefaultModel,\n        extractCapabilityFieldsFromModel,\n        workflowConcurrency,\n        handleWorkflowConcurrencyChange,\n    } = allProps\n\n    // Pipeline unified override state\n    const [pipelineGlobalKey, setPipelineGlobalKey] = useState('')\n    const [pipelineGlobalCapOverrides, setPipelineGlobalCapOverrides] = useState<Record<string, CapabilityValue>>({})\n    const pipelineGlobalOptions = getEnabledModelsByType('image')\n    const pipelineGlobalCurrent = pipelineGlobalOptions.find((opt) => opt.modelKey === pipelineGlobalKey) ?? null\n    const pipelineGlobalCapFields = computeCapabilityFields(pipelineGlobalCurrent, 'image')\n\n    const handlePipelineGlobalChange = useCallback((newModelKey: string) => {\n        setPipelineGlobalKey(newModelKey)\n        setPipelineGlobalCapOverrides({})\n        if (newModelKey) {\n            const pipelineFields = ['characterModel', 'locationModel', 'storyboardModel', 'editModel']\n            const newModel = pipelineGlobalOptions.find((opt) => opt.modelKey === newModelKey)\n            const newCapFields = extractCapabilityFieldsFromModel(\n                newModel?.capabilities as Record<string, unknown> | undefined,\n                'image',\n            )\n            allProps.batchUpdateDefaultModels(pipelineFields, newModelKey, newCapFields)\n        }\n    }, [pipelineGlobalOptions, extractCapabilityFieldsFromModel, allProps])\n\n    const handlePipelineGlobalCapChange = useCallback((field: string, rawValue: string, sample: CapabilityValue) => {\n        if (!pipelineGlobalCurrent) return\n        const parsed = allProps.parseBySample(rawValue, sample)\n        setPipelineGlobalCapOverrides((prev) => ({ ...prev, [field]: parsed }))\n        // Batch update all 4 pipeline fields\n        const pipelineFields = ['characterModel', 'locationModel', 'storyboardModel', 'editModel']\n        for (const pField of pipelineFields) {\n            allProps.updateCapabilityDefault(pipelineGlobalCurrent.modelKey, field, parsed)\n            // Also update each individual pipeline model's capability if they share the same model\n            const resolvedField = defaultModels[pField as DefaultModelField]\n            if (resolvedField === pipelineGlobalCurrent.modelKey) {\n                allProps.updateCapabilityDefault(resolvedField, field, parsed)\n            }\n        }\n    }, [pipelineGlobalCurrent, allProps, defaultModels])\n\n    // Resolve all models\n    const textModel = resolveModel('analysisModel', 'llm', defaultModels, getEnabledModelsByType, parseModelKey, encodeModelKey)\n    const videoModel = resolveModel('videoModel', 'video', defaultModels, getEnabledModelsByType, parseModelKey, encodeModelKey)\n    const audioModel = resolveModel('audioModel', 'audio', defaultModels, getEnabledModelsByType, parseModelKey, encodeModelKey)\n    const lipsyncModel = resolveModel('lipSyncModel', 'lipsync', defaultModels, getEnabledModelsByType, parseModelKey, encodeModelKey)\n    const voiceDesignModel = resolveModel('voiceDesignModel', 'voicedesign', defaultModels, getEnabledModelsByType, parseModelKey, encodeModelKey)\n\n    const pipelineItems: Array<{\n        field: DefaultModelField\n        modelType: ModelType\n        titleKey: string\n        icon: AppIconName\n    }> = [\n            { field: 'characterModel', modelType: 'image', titleKey: 'defaultModelSection.pipelineCharacter', icon: 'user' },\n            { field: 'locationModel', modelType: 'image', titleKey: 'defaultModelSection.pipelineLocation', icon: 'image' },\n            { field: 'storyboardModel', modelType: 'image', titleKey: 'defaultModelSection.pipelineStoryboard', icon: 'film' },\n            { field: 'editModel', modelType: 'image', titleKey: 'defaultModelSection.pipelineEdit', icon: 'edit' },\n        ]\n\n    return (\n        <div className=\"p-8 rounded-3xl bg-[var(--glass-bg-base)] shadow-[0_8px_30px_rgb(0,0,0,0.04)] dark:shadow-[0_8px_30px_rgb(0,0,0,0.1)] relative overflow-hidden\">\n            {/* Background glow effects */}\n            <div className=\"absolute -top-40 -right-40 w-96 h-96 bg-blue-500/10 rounded-full blur-[100px] pointer-events-none\" />\n            <div className=\"absolute -bottom-40 -left-40 w-96 h-96 bg-purple-500/10 rounded-full blur-[100px] pointer-events-none\" />\n\n            <div className=\"relative z-10\">\n                {/* Header */}\n                <div className=\"mb-8\">\n                    <div className=\"flex items-center gap-2.5 mb-1\">\n                        <span className=\"glass-surface-soft inline-flex h-7 w-7 items-center justify-center rounded-lg text-[var(--glass-text-secondary)]\">\n                            <AppIcon name=\"settingsHex\" className=\"w-4 h-4\" />\n                        </span>\n                        <h2 className=\"text-xl font-bold text-[var(--glass-text-primary)]\">{t('defaultModels')}</h2>\n                    </div>\n                    <p className=\"text-[13px] text-[var(--glass-text-secondary)] ml-[38px]\">{t('defaultModel.hint')}</p>\n                </div>\n\n                {/* ===== Section 1: Core Foundation ===== */}\n                <h3 className=\"text-[17px] font-bold text-[var(--glass-text-primary)] mb-5 flex items-center gap-2\">\n                    <AppIcon name=\"bolt\" className=\"w-5 h-5 text-blue-500\" />\n                    {t('defaultModelSection.coreFoundation')}\n                </h3>\n                <div className=\"flex flex-col md:flex-row gap-4 mb-8\">\n                    {/* Text Model Card */}\n                    <div className=\"flex-1 glass-surface p-4 rounded-2xl border border-[var(--glass-stroke-base)] hover:border-blue-500/30 transition-colors shadow-sm bg-gradient-to-br from-[var(--glass-bg-surface)] to-transparent\">\n                        <div className=\"flex items-start justify-between mb-2\">\n                            <div className=\"w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0\">\n                                <AppIcon name=\"fileText\" className=\"w-4 h-4 text-blue-500\" />\n                            </div>\n                            <div className=\"flex items-center gap-1.5\">\n                                <span className=\"text-[10px] font-medium text-[var(--glass-text-secondary)] whitespace-nowrap\">\n                                    {t('workflowConcurrency.analysis')}\n                                </span>\n                                <input\n                                    type=\"number\"\n                                    min={1}\n                                    step={1}\n                                    value={workflowConcurrency.analysis}\n                                    onChange={(event) => handleWorkflowConcurrencyChange('analysis', event.target.value)}\n                                    className=\"glass-input-base h-6 w-12 px-1.5 py-0 text-[11px]\"\n                                />\n                            </div>\n                        </div>\n                        <h4 className=\"text-[14px] font-bold text-[var(--glass-text-primary)] mb-0.5\">{t('defaultModelSection.coreTextTitle')}</h4>\n                        <p className=\"text-[11px] text-[var(--glass-text-tertiary)] mb-3\">{t('defaultModelDesc.analysisModel')}</p>\n                        <SmartSelector\n                            field=\"analysisModel\" modelType=\"llm\"\n                            options={textModel.options} normalizedKey={textModel.normalizedKey} current={textModel.current}\n                            placeholder={t('defaultModelSection.corePlaceholder')}\n                            locale={locale} t={t} props={allProps}\n                        />\n                    </div>\n\n                    {/* Video Model Card */}\n                    <div className=\"flex-1 glass-surface p-4 rounded-2xl border border-[var(--glass-stroke-base)] hover:border-purple-500/30 transition-colors shadow-sm bg-gradient-to-br from-[var(--glass-bg-surface)] to-transparent\">\n                    <div className=\"flex items-start justify-between mb-2\">\n                        <div className=\"w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center\">\n                            <AppIcon name=\"clapperboard\" className=\"w-4 h-4 text-purple-500\" />\n                        </div>\n                        <div className=\"flex items-center gap-1.5\">\n                            <span className=\"text-[10px] font-medium text-[var(--glass-text-secondary)] whitespace-nowrap\">\n                                {t('workflowConcurrency.video')}\n                            </span>\n                            <input\n                                type=\"number\"\n                                min={1}\n                                step={1}\n                                value={workflowConcurrency.video}\n                                onChange={(event) => handleWorkflowConcurrencyChange('video', event.target.value)}\n                                className=\"glass-input-base h-6 w-12 px-1.5 py-0 text-[11px]\"\n                            />\n                        </div>\n                    </div>\n                        <h4 className=\"text-[14px] font-bold text-[var(--glass-text-primary)] mb-0.5\">{t('defaultModelSection.coreVideoTitle')}</h4>\n                        <p className=\"text-[11px] text-[var(--glass-text-tertiary)] mb-3\">{t('defaultModelDesc.videoModel')}</p>\n                        <SmartSelector\n                            field=\"videoModel\" modelType=\"video\"\n                            options={videoModel.options} normalizedKey={videoModel.normalizedKey} current={videoModel.current}\n                            placeholder={t('defaultModelSection.corePlaceholder')}\n                            locale={locale} t={t} props={allProps}\n                        />\n                    </div>\n                </div>\n\n\n                {/* ===== Section 2: Global Image Model Config ===== */}\n                <div className=\"mb-5 flex items-center justify-between gap-3\">\n                    <h3 className=\"text-[17px] font-bold text-[var(--glass-text-primary)] flex items-center gap-2\">\n                        <AppIcon name=\"sparklesAlt\" className=\"w-5 h-5 text-indigo-500\" />\n                        {t('defaultModelSection.creativePipeline')}\n                    </h3>\n                    <div className=\"flex items-center gap-1.5\">\n                        <span className=\"text-[10px] font-medium text-[var(--glass-text-secondary)] whitespace-nowrap\">\n                            {t('workflowConcurrency.image')}\n                        </span>\n                        <input\n                            type=\"number\"\n                            min={1}\n                            step={1}\n                            value={workflowConcurrency.image}\n                            onChange={(event) => handleWorkflowConcurrencyChange('image', event.target.value)}\n                            className=\"glass-input-base h-6 w-12 px-1.5 py-0 text-[11px]\"\n                        />\n                    </div>\n                </div>\n                <div className=\"glass-surface p-6 rounded-3xl border border-indigo-500/20 bg-indigo-500/[0.02] shadow-sm mb-8\">\n                    <div className=\"flex items-start gap-2 mb-4 px-3 py-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400\">\n                        <AppIcon name=\"alert\" className=\"w-4 h-4 shrink-0 mt-0.5\" />\n                        <span className=\"text-[12px] leading-relaxed\">{t('imageModelTip')}</span>\n                    </div>\n                    {/* Batch config header */}\n                    <div className=\"flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 pb-6 border-b border-indigo-500/10\">\n                        <div>\n                            <div className=\"text-[14px] font-semibold text-[var(--glass-text-primary)]\">{t('defaultModelSection.unifiedOverride')}</div>\n                            <div className=\"text-[12px] text-[var(--glass-text-tertiary)] mt-0.5\">{t('defaultModelSection.unifiedOverrideHint')}</div>\n                        </div>\n                        <div className=\"w-full sm:w-[280px]\">\n                            <ModelCapabilityDropdown\n                                models={pipelineGlobalOptions.map((opt) => ({\n                                    value: opt.modelKey,\n                                    label: opt.name,\n                                    provider: opt.provider,\n                                    providerName: opt.providerName || getProviderDisplayName(opt.provider, locale),\n                                }))}\n                                value={pipelineGlobalKey || undefined}\n                                onModelChange={handlePipelineGlobalChange}\n                                capabilityFields={pipelineGlobalCapFields.map((d) => ({\n                                    ...d,\n                                    label: allProps.toCapabilityFieldLabel(d.field),\n                                }))}\n                                capabilityOverrides={pipelineGlobalCapOverrides}\n                                onCapabilityChange={handlePipelineGlobalCapChange}\n                                placeholder={t('defaultModelSection.unifiedOverridePlaceholder')}\n                            />\n                        </div>\n                    </div>\n\n                    {/* 4 pipeline nodes */}\n                    <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4\">\n                        {pipelineItems.map((item) => {\n                            const resolved = resolveModel(item.field, item.modelType, defaultModels, getEnabledModelsByType, parseModelKey, encodeModelKey)\n                            return (\n                                <div key={item.field} className=\"glass-surface p-4 rounded-2xl shadow-sm bg-gradient-to-br from-[var(--glass-bg-surface)] to-transparent flex flex-col gap-3\">\n                                    <div className=\"flex items-center gap-2 mb-1\">\n                                        <AppIcon name={item.icon} className=\"w-4 h-4 text-[var(--glass-text-tertiary)]\" />\n                                        <span className=\"text-[13px] font-semibold text-[var(--glass-text-secondary)]\">{t(item.titleKey)}</span>\n                                    </div>\n                                    <SmartSelector\n                                        field={item.field} modelType={item.modelType}\n                                        options={resolved.options} normalizedKey={resolved.normalizedKey} current={resolved.current}\n                                        placeholder={t('defaultModelSection.followUnified')}\n                                        locale={locale} t={t} props={allProps}\n                                    />\n                                </div>\n                            )\n                        })}\n                    </div>\n                </div>\n\n                {/* ===== Section 3: Extensions ===== */}\n                <h3 className=\"text-[17px] font-bold text-[var(--glass-text-primary)] mb-5 flex items-center gap-2\">\n                    <AppIcon name=\"cube\" className=\"w-5 h-5 text-emerald-500\" />\n                    {t('defaultModelSection.extensions')}\n                </h3>\n                <div className=\"grid grid-cols-1 md:grid-cols-3 gap-5\">\n                    {/* Lip Sync */}\n                    <div className=\"glass-surface p-5 rounded-2xl shadow-sm bg-gradient-to-br from-[var(--glass-bg-surface)] to-transparent\">\n                        <h4 className=\"text-[13px] font-semibold text-[var(--glass-text-primary)] mb-4\">{t('defaultModelSection.extLipSync')}</h4>\n                        <SmartSelector\n                            field=\"lipSyncModel\" modelType=\"lipsync\"\n                            options={lipsyncModel.options} normalizedKey={lipsyncModel.normalizedKey} current={lipsyncModel.current}\n                            placeholder={t('defaultModelSection.extPlaceholder')}\n                            locale={locale} t={t} props={allProps}\n                        />\n                    </div>\n                    {/* TTS */}\n                    <div className=\"glass-surface p-5 rounded-2xl shadow-sm bg-gradient-to-br from-[var(--glass-bg-surface)] to-transparent\">\n                        <h4 className=\"text-[13px] font-semibold text-[var(--glass-text-primary)] mb-4\">{t('defaultModelSection.extTTS')}</h4>\n                        <SmartSelector\n                            field=\"audioModel\" modelType=\"audio\"\n                            options={audioModel.options} normalizedKey={audioModel.normalizedKey} current={audioModel.current}\n                            placeholder={t('defaultModelSection.extPlaceholder')}\n                            locale={locale} t={t} props={allProps}\n                        />\n                    </div>\n                    {/* Voice Design */}\n                    <div className=\"glass-surface p-5 rounded-2xl shadow-sm bg-gradient-to-br from-[var(--glass-bg-surface)] to-transparent\">\n                        <h4 className=\"text-[13px] font-semibold text-[var(--glass-text-primary)] mb-4\">{t('defaultModelSection.extVoiceDesign')}</h4>\n                        <SmartSelector\n                            field=\"voiceDesignModel\" modelType=\"voicedesign\"\n                            options={voiceDesignModel.options} normalizedKey={voiceDesignModel.normalizedKey} current={voiceDesignModel.current}\n                            placeholder={t('defaultModelSection.extPlaceholder')}\n                            locale={locale} t={t} props={allProps}\n                        />\n                    </div>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/components/api-config-tab/hooks/useApiConfigFilters.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport type { CustomModel, Provider } from '../../api-config'\nimport { PRESET_PROVIDERS, getProviderKey } from '../../api-config'\n\ninterface UseApiConfigFiltersParams {\n  providers: Provider[]\n  models: CustomModel[]\n}\n\ninterface EnabledModelOption extends CustomModel {\n  providerName: string\n}\n\nconst DYNAMIC_PROVIDER_PREFIXES = ['gemini-compatible', 'openai-compatible']\nconst ALWAYS_SHOW_PROVIDERS: string[] = []\n/** 完全不在 UI 中展示的 provider（既不在主列表，也不在折叠区） */\nconst HIDDEN_PROVIDER_KEYS = new Set(['siliconflow'])\nconst PROVIDER_MODEL_TYPES: Array<'llm' | 'image' | 'video' | 'audio' | 'lipsync'> = ['llm', 'image', 'video', 'audio', 'lipsync']\nconst DEFAULT_AUDIO_EXCLUDED_MODEL_IDS = new Set([\n  'qwen-voice-design',\n])\nconst MODEL_PROVIDER_KEYS = [\n  'ark',\n  'google',\n  'bailian',\n  'openrouter',\n  'minimax',\n  'vidu',\n  'fal',\n  'gemini-compatible',\n  'openai-compatible',\n]\n\nfunction isProviderModelType(type: CustomModel['type']): type is 'llm' | 'image' | 'video' | 'audio' | 'lipsync' {\n  return PROVIDER_MODEL_TYPES.includes(type as 'llm' | 'image' | 'video' | 'audio' | 'lipsync')\n}\n\nfunction isDefaultModelType(type: CustomModel['type']): type is 'llm' | 'image' | 'video' | 'audio' | 'lipsync' {\n  return type === 'llm' || type === 'image' || type === 'video' || type === 'audio' || type === 'lipsync'\n}\n\nfunction isAudioDefaultCandidate(model: CustomModel): boolean {\n  if (model.type !== 'audio') return true\n  return !DEFAULT_AUDIO_EXCLUDED_MODEL_IDS.has(model.modelId)\n}\n\nfunction hasProviderApiKey(provider: Provider | undefined): boolean {\n  if (!provider) return false\n  if (provider.hasApiKey === true) return true\n  const apiKey = typeof provider.apiKey === 'string' ? provider.apiKey.trim() : ''\n  return apiKey.length > 0\n}\n\nexport function useApiConfigFilters({\n  providers,\n  models,\n}: UseApiConfigFiltersParams) {\n  const modelProviderKeys = useMemo(() => {\n    const keys = new Set<string>(MODEL_PROVIDER_KEYS)\n    models.forEach((model) => {\n      if (!isProviderModelType(model.type)) return\n      keys.add(getProviderKey(model.provider))\n    })\n    return keys\n  }, [models])\n\n  const isPresetProvider = (providerId: string) => {\n    return PRESET_PROVIDERS.some(\n      (provider) => provider.id === getProviderKey(providerId),\n    )\n  }\n\n  const modelProviders = useMemo(() => {\n    return providers.filter((provider) => {\n      const providerKey = getProviderKey(provider.id)\n      if (HIDDEN_PROVIDER_KEYS.has(providerKey)) return false\n      const isCustomProvider = !isPresetProvider(provider.id)\n      const isDynamicProvider =\n        DYNAMIC_PROVIDER_PREFIXES.includes(providerKey) && provider.id.includes(':')\n\n      return (\n        (isCustomProvider && modelProviderKeys.has(providerKey)) ||\n        modelProviderKeys.has(providerKey) ||\n        ALWAYS_SHOW_PROVIDERS.includes(providerKey) ||\n        isDynamicProvider\n      )\n    })\n  }, [modelProviderKeys, providers])\n\n  const enabledModelsByType = useMemo(() => {\n    const grouped: Record<'llm' | 'image' | 'video' | 'audio' | 'lipsync' | 'voicedesign', EnabledModelOption[]> = {\n      llm: [],\n      image: [],\n      video: [],\n      audio: [],\n      lipsync: [],\n      voicedesign: [],\n    }\n\n    const providersById = new Map(providers.map((provider) => [provider.id, provider] as const))\n\n    for (const model of models) {\n      if (!model.enabled) continue\n      if (!isDefaultModelType(model.type)) continue\n      const provider = providersById.get(model.provider)\n      if (!hasProviderApiKey(provider)) continue\n\n      const option: EnabledModelOption = {\n        ...model,\n        providerName: provider?.name || model.provider,\n      }\n\n      // Voice design models (audio type but excluded from TTS)\n      if (model.type === 'audio' && DEFAULT_AUDIO_EXCLUDED_MODEL_IDS.has(model.modelId)) {\n        grouped.voicedesign.push(option)\n        continue\n      }\n\n      // Normal audio default candidate check\n      if (!isAudioDefaultCandidate(model)) continue\n\n      grouped[model.type].push(option)\n    }\n\n    return grouped\n  }, [models, providers])\n\n  return {\n    modelProviders,\n    getModelsForProvider: (providerId: string) =>\n      models.filter((model) => model.provider === providerId),\n    getEnabledModelsByType: (type: 'llm' | 'image' | 'video' | 'audio' | 'lipsync' | 'voicedesign') => enabledModelsByType[type],\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/profile/page.tsx",
    "content": "'use client'\nimport { useEffect, useState } from 'react'\nimport { useSession, signOut } from 'next-auth/react'\nimport { useTranslations } from 'next-intl'\nimport Navbar from '@/components/Navbar'\nimport ApiConfigTab from './components/ApiConfigTab'\nimport { AppIcon } from '@/components/ui/icons'\nimport { useRouter } from '@/i18n/navigation'\n\nexport default function ProfilePage() {\n  const { data: session, status } = useSession()\n  const router = useRouter()\n  const t = useTranslations('profile')\n  const tc = useTranslations('common')\n\n  // 主要分区：扣费记录 / API配置\n  const [activeSection, setActiveSection] = useState<'billing' | 'apiConfig'>('apiConfig')\n\n  useEffect(() => {\n    if (status === 'loading') return\n    if (!session) { router.push({ pathname: '/auth/signin' }); return }\n  }, [router, session, status])\n\n  if (status === 'loading' || !session) {\n    return (\n      <div className=\"glass-page flex min-h-screen items-center justify-center\">\n        <div className=\"text-[var(--glass-text-secondary)]\">{tc('loading')}</div>\n      </div>\n    )\n  }\n\n  const noBillingText = t('openSourceNoBilling')\n\n  return (\n    <div className=\"glass-page min-h-screen\">\n      <Navbar />\n\n      <main className=\"max-w-[1400px] mx-auto px-6 py-8\">\n        <div className=\"flex gap-6 h-[calc(100vh-140px)]\">\n\n          {/* 左侧侧边栏 */}\n          <div className=\"w-64 flex-shrink-0\">\n            <div className=\"glass-surface-elevated h-full flex flex-col p-5\">\n\n              {/* 用户信息 */}\n              <div className=\"mb-6\">\n                <div className=\"mb-4\">\n                  <h2 className=\"font-semibold text-[var(--glass-text-primary)]\">{session.user?.name || t('user')}</h2>\n                  <p className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('personalAccount')}</p>\n                </div>\n\n                {/* 余额卡片 */}\n                <div className=\"glass-surface-soft rounded-2xl border border-[var(--glass-stroke-base)] p-4\">\n                  <div className=\"text-xs font-medium text-[var(--glass-text-secondary)]\">{t('availableBalance')}</div>\n                  <div className=\"mt-2 text-base font-semibold text-[var(--glass-text-primary)]\">{noBillingText}</div>\n                </div>\n              </div>\n\n              {/* 导航菜单 */}\n              <nav className=\"flex-1 space-y-2\">\n                <button\n                  onClick={() => setActiveSection('apiConfig')}\n                  className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all cursor-pointer ${activeSection === 'apiConfig'\n                    ? 'glass-btn-base glass-btn-tone-info'\n                    : 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'\n                    }`}\n                >\n                  <AppIcon name=\"settingsHexAlt\" className=\"w-5 h-5\" />\n                  <span className=\"font-medium\">{t('apiConfig')}</span>\n                </button>\n\n                <button\n                  onClick={() => setActiveSection('billing')}\n                  className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all cursor-pointer ${activeSection === 'billing'\n                    ? 'glass-btn-base glass-btn-tone-info'\n                    : 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'\n                    }`}\n                >\n                  <AppIcon name=\"receipt\" className=\"w-5 h-5\" />\n                  <span className=\"font-medium\">{t('billingRecords')}</span>\n                </button>\n              </nav>\n              {/* 退出登录 */}\n              <button\n                onClick={() => signOut({ callbackUrl: '/' })}\n                className=\"glass-btn-base glass-btn-tone-danger mt-auto flex items-center gap-2 px-4 py-3 text-sm rounded-xl transition-all cursor-pointer\"\n              >\n                <AppIcon name=\"logout\" className=\"w-4 h-4\" />\n                {t('logout')}\n              </button>\n            </div>\n          </div>\n\n          {/* 右侧内容区 */}\n          <div className=\"flex-1 min-w-0\">\n            <div className=\"glass-surface-elevated h-full flex flex-col\">\n\n              {activeSection === 'apiConfig' ? (\n                <ApiConfigTab />\n              ) : (\n                <div className=\"flex h-full flex-col items-center justify-center px-6 text-center\">\n                  <AppIcon name=\"receipt\" className=\"mb-4 h-12 w-12 text-[var(--glass-text-tertiary)]\" />\n                  <p className=\"text-base font-semibold text-[var(--glass-text-primary)]\">{noBillingText}</p>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </main >\n    </div >\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/providers.tsx",
    "content": "'use client'\n\nimport { SessionProvider } from \"next-auth/react\"\nimport { ToastProvider } from \"@/contexts/ToastContext\"\nimport { QueryProvider } from \"@/components/providers/QueryProvider\"\n\nexport function Providers({ children }: { children: React.ReactNode }) {\n  return (\n    <SessionProvider\n      refetchOnWindowFocus={false}\n      refetchInterval={0}\n    >\n      <QueryProvider>\n        <ToastProvider>\n          {children}\n        </ToastProvider>\n      </QueryProvider>\n    </SessionProvider>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/components/Sidebar.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\nimport { useState, useRef, useEffect } from 'react'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface Episode {\n    id: string\n    episodeNumber: number\n    name: string\n    description?: string | null\n}\n\ninterface SidebarProps {\n    projectId: string\n    projectName: string\n    episodes: Episode[]\n    currentEpisodeId: string | null\n    onEpisodeSelect: (id: string) => void\n    onEpisodeCreate: (name: string, description?: string) => Promise<void>\n    onEpisodeDelete: (id: string) => Promise<void>\n    onEpisodeRename: (id: string, newName: string) => Promise<void>\n    onGlobalAssetsClick: () => void\n    isGlobalAssetsView: boolean\n}\n\nexport default function Sidebar({\n    projectId,\n    projectName,\n    episodes,\n    currentEpisodeId,\n    onEpisodeSelect,\n    onEpisodeCreate,\n    onEpisodeDelete,\n    onEpisodeRename,\n    onGlobalAssetsClick,\n    isGlobalAssetsView\n}: SidebarProps) {\n    const t = useTranslations('workspaceDetail')\n    const [isExpanded, setIsExpanded] = useState(false)\n    void projectId\n    const [isCreating, setIsCreating] = useState(false)\n    const [newEpisodeName, setNewEpisodeName] = useState('')\n    const [editingId, setEditingId] = useState<string | null>(null)\n    const [editingName, setEditingName] = useState('')\n    const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)\n\n    // 可拖动位置\n    const [position, setPosition] = useState({ y: 200 }) // 初始Y位置\n    const [isDragging, setIsDragging] = useState(false)\n    const dragStartY = useRef(0)\n    const dragStartPos = useRef(0)\n\n    // 拖动逻辑\n    const handleDragStart = (e: React.MouseEvent) => {\n        e.preventDefault()\n        setIsDragging(true)\n        dragStartY.current = e.clientY\n        dragStartPos.current = position.y\n    }\n\n    useEffect(() => {\n        const handleMouseMove = (e: MouseEvent) => {\n            if (!isDragging) return\n            const deltaY = e.clientY - dragStartY.current\n            const newY = Math.max(100, Math.min(window.innerHeight - 200, dragStartPos.current + deltaY))\n            setPosition({ y: newY })\n        }\n\n        const handleMouseUp = () => {\n            setIsDragging(false)\n        }\n\n        if (isDragging) {\n            document.addEventListener('mousemove', handleMouseMove)\n            document.addEventListener('mouseup', handleMouseUp)\n        }\n\n        return () => {\n            document.removeEventListener('mousemove', handleMouseMove)\n            document.removeEventListener('mouseup', handleMouseUp)\n        }\n    }, [isDragging])\n\n    // 创建剧集\n    const handleCreate = async () => {\n        if (!newEpisodeName.trim()) return\n        try {\n            await onEpisodeCreate(newEpisodeName.trim())\n            setNewEpisodeName('')\n            setIsCreating(false)\n        } catch (err) {\n            _ulogError('创建剧集失败:', err)\n        }\n    }\n\n    // 重命名剧集\n    const handleRename = async (id: string) => {\n        if (!editingName.trim()) return\n        try {\n            await onEpisodeRename(id, editingName.trim())\n            setEditingId(null)\n            setEditingName('')\n        } catch (err) {\n            _ulogError('重命名失败:', err)\n        }\n    }\n\n    // 删除剧集\n    const handleDelete = async (id: string) => {\n        try {\n            await onEpisodeDelete(id)\n            setDeleteConfirmId(null)\n        } catch (err) {\n            _ulogError('删除失败:', err)\n        }\n    }\n\n    return (\n        <>\n            {/* 触发条 - 固定在左侧，可拖动 */}\n            <div\n                className=\"fixed left-0 z-50\"\n                style={{ top: position.y }}\n            >\n                {/* 拖动手柄 + 触发按钮 */}\n                <div className=\"flex flex-col items-center\">\n                    {/* 拖动手柄 */}\n                    <div\n                        className=\"w-6 h-4 bg-[var(--glass-bg-muted)] rounded-t cursor-ns-resize flex items-center justify-center hover:bg-[var(--glass-bg-surface-strong)] transition-colors\"\n                        onMouseDown={handleDragStart}\n                        title={t('sidebar.dragToMove')}\n                    >\n                        <div className=\"flex gap-0.5\">\n                            <div className=\"w-0.5 h-1.5 bg-[var(--glass-text-tertiary)] rounded-full\" />\n                            <div className=\"w-0.5 h-1.5 bg-[var(--glass-text-tertiary)] rounded-full\" />\n                            <div className=\"w-0.5 h-1.5 bg-[var(--glass-text-tertiary)] rounded-full\" />\n                        </div>\n                    </div>\n\n                    {/* 展开按钮 */}\n                    <div\n                        className={`glass-surface rounded-r-xl cursor-pointer transition-all flex items-center gap-1 px-2 py-3 ${isExpanded ? 'bg-[var(--glass-tone-info-bg)] border-[var(--glass-stroke-focus)]' : ''\n                            }`}\n                        onClick={() => setIsExpanded(!isExpanded)}\n                    >\n                        <AppIcon name=\"chevronRight\" className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180 text-[var(--glass-tone-info-fg)]' : 'text-[var(--glass-text-tertiary)]'}`} />\n                        <span className={`text-xs font-medium whitespace-nowrap ${isExpanded ? 'text-[var(--glass-tone-info-fg)]' : 'text-[var(--glass-text-secondary)]'}`}>\n                            {t('episode')}\n                        </span>\n                    </div>\n                </div>\n            </div>\n\n            {/* 弹出面板 */}\n            {isExpanded && (\n                <>\n                    {/* 背景遮罩 */}\n                    <div\n                        className=\"fixed inset-0 glass-overlay z-40\"\n                        onClick={() => setIsExpanded(false)}\n                    />\n\n                    {/* 侧边面板 */}\n                    <div\n                        className=\"fixed left-12 glass-surface-modal rounded-r-xl z-50 w-64 max-h-[70vh] overflow-hidden flex flex-col\"\n                        style={{ top: position.y - 50 }}\n                    >\n                        {/* 标题栏 */}\n                        <div className=\"p-4 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]\">\n                            <div className=\"flex items-center justify-between\">\n                                <div>\n                                    <h3 className=\"font-bold text-[var(--glass-text-primary)] text-sm flex items-center gap-1.5\">\n                                        <AppIcon name=\"monitor\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n                                        <span>{t('sidebar.listTitle')}</span>\n                                    </h3>\n                                    <p className=\"text-xs text-[var(--glass-text-secondary)] mt-0.5 truncate\" title={projectName}>\n                                        {projectName}\n                                    </p>\n                                </div>\n                                <span className=\"text-xs text-[var(--glass-text-tertiary)] bg-[var(--glass-bg-muted)] px-2 py-0.5 rounded\">\n                                    {t('sidebar.episodeCount', { count: episodes.length })}\n                                </span>\n                            </div>\n                        </div>\n\n                        {/* 全局资产入口 */}\n                        <div className=\"px-3 py-2 border-b border-[var(--glass-stroke-base)]\">\n                            <button\n                                onClick={() => {\n                                    onGlobalAssetsClick()\n                                    setIsExpanded(false)\n                                }}\n                                className={`glass-btn-base w-full py-2 px-3 rounded-lg text-left text-sm transition-colors flex items-center justify-start gap-2 ${isGlobalAssetsView\n                                        ? 'glass-btn-tone-info'\n                                        : 'glass-btn-soft text-[var(--glass-text-secondary)]'\n                                    }`}\n                            >\n                                <AppIcon name=\"coins\" className=\"w-4 h-4\" />\n                                <span>{t('globalAssets')}</span>\n                            </button>\n                        </div>\n\n                        {/* 剧集列表 */}\n                        <div className=\"flex-1 overflow-y-auto p-3 space-y-1\">\n                            {episodes.length === 0 ? (\n                                <div className=\"text-center py-6 text-[var(--glass-text-tertiary)] text-sm\">\n                                    {t('sidebar.empty')}\n                                </div>\n                            ) : (\n                                episodes.map((ep) => (\n                                    <div key={ep.id} className=\"group relative\">\n                                        {editingId === ep.id ? (\n                                            // 编辑模式\n                                            <div className=\"flex gap-1\">\n                                                <input\n                                                    type=\"text\"\n                                                    value={editingName}\n                                                    onChange={(e) => setEditingName(e.target.value)}\n                                                    className=\"glass-input-base flex-1 px-2 py-1.5 text-sm\"\n                                                    autoFocus\n                                                    onKeyDown={(e) => {\n                                                        if (e.key === 'Enter') handleRename(ep.id)\n                                                        if (e.key === 'Escape') setEditingId(null)\n                                                    }}\n                                                />\n                                                <button\n                                                    onClick={() => handleRename(ep.id)}\n                                                    className=\"glass-btn-base glass-btn-tone-info px-2 py-1 text-xs rounded\"\n                                                >\n                                                    {t('sidebar.save')}\n                                                </button>\n                                            </div>\n                                        ) : deleteConfirmId === ep.id ? (\n                                            // 删除确认\n                                            <div className=\"bg-[var(--glass-tone-danger-bg)] p-2 rounded-lg\">\n                                                <p className=\"text-xs text-[var(--glass-tone-danger-fg)] mb-2\">{t('sidebar.deleteConfirm', { name: ep.name })}</p>\n                                                <div className=\"flex gap-1\">\n                                                    <button\n                                                        onClick={() => handleDelete(ep.id)}\n                                                        className=\"glass-btn-base glass-btn-tone-danger flex-1 py-1 text-xs rounded\"\n                                                    >\n                                                        {t('sidebar.delete')}\n                                                    </button>\n                                                    <button\n                                                        onClick={() => setDeleteConfirmId(null)}\n                                                        className=\"glass-btn-base glass-btn-secondary flex-1 py-1 text-xs rounded\"\n                                                    >\n                                                        {t('sidebar.cancel')}\n                                                    </button>\n                                                </div>\n                                            </div>\n                                        ) : (\n                                            // 正常显示\n                                            <button\n                                                onClick={() => {\n                                                    onEpisodeSelect(ep.id)\n                                                    setIsExpanded(false)\n                                                }}\n                                                className={`w-full py-2 px-3 rounded-lg text-left text-sm transition-colors flex items-center gap-2 ${currentEpisodeId === ep.id && !isGlobalAssetsView\n                                                        ? 'glass-btn-tone-info'\n                                                        : 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'\n                                                    }`}\n                                            >\n                                                <span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${currentEpisodeId === ep.id && !isGlobalAssetsView ? 'bg-[var(--glass-bg-surface)]/25' : 'bg-[var(--glass-bg-muted)]'\n                                                    }`}>\n                                                    {ep.episodeNumber}\n                                                </span>\n                                                <span className=\"truncate flex-1\">{ep.name}</span>\n\n                                                {/* 操作按钮 */}\n                                                <div className={`flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity ${currentEpisodeId === ep.id && !isGlobalAssetsView ? 'text-white/80' : 'text-[var(--glass-text-tertiary)]'\n                                                    }`}>\n                                                    <button\n                                                        type=\"button\"\n                                                        className=\"glass-btn-base glass-btn-ghost w-6 h-6 rounded-md p-0 hover:scale-110 transition-transform\"\n                                                        onClick={(e) => {\n                                                            e.stopPropagation()\n                                                            setEditingId(ep.id)\n                                                            setEditingName(ep.name)\n                                                        }}\n                                                        title={t('sidebar.rename')}\n                                                    >\n                                                        <AppIcon name=\"editSquare\" className=\"w-3.5 h-3.5\" />\n                                                    </button>\n                                                    <button\n                                                        type=\"button\"\n                                                        className=\"glass-btn-base glass-btn-ghost w-6 h-6 rounded-md p-0 hover:scale-110 transition-transform\"\n                                                        onClick={(e) => {\n                                                            e.stopPropagation()\n                                                            setDeleteConfirmId(ep.id)\n                                                        }}\n                                                        title={t('sidebar.delete')}\n                                                    >\n                                                        <AppIcon name=\"trash\" className=\"w-3.5 h-3.5\" />\n                                                    </button>\n                                                </div>\n                                            </button>\n                                        )}\n                                    </div>\n                                ))\n                            )}\n                        </div>\n\n                        {/* 添加剧集 */}\n                        <div className=\"p-3 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]\">\n                            {isCreating ? (\n                                <div className=\"space-y-2\">\n                                    <input\n                                        type=\"text\"\n                                        value={newEpisodeName}\n                                        onChange={(e) => setNewEpisodeName(e.target.value)}\n                                        placeholder={t('sidebar.newEpisodePlaceholder')}\n                                        className=\"glass-input-base w-full px-3 py-2 text-sm rounded-lg\"\n                                        autoFocus\n                                        onKeyDown={(e) => {\n                                            if (e.key === 'Enter') handleCreate()\n                                            if (e.key === 'Escape') {\n                                                setIsCreating(false)\n                                                setNewEpisodeName('')\n                                            }\n                                        }}\n                                    />\n                                    <div className=\"flex gap-2\">\n                                        <button\n                                            onClick={handleCreate}\n                                            disabled={!newEpisodeName.trim()}\n                                            className=\"glass-btn-base glass-btn-primary flex-1 py-1.5 text-sm rounded-lg disabled:opacity-50 disabled:cursor-not-allowed\"\n                                        >\n                                            {t('sidebar.create')}\n                                        </button>\n                                        <button\n                                            onClick={() => {\n                                                setIsCreating(false)\n                                                setNewEpisodeName('')\n                                            }}\n                                            className=\"glass-btn-base glass-btn-secondary flex-1 py-1.5 text-sm rounded-lg\"\n                                        >\n                                            {t('sidebar.cancel')}\n                                        </button>\n                                    </div>\n                                </div>\n                            ) : (\n                                <button\n                                    onClick={() => setIsCreating(true)}\n                                    className=\"glass-btn-base glass-btn-tone-success w-full py-2 px-3 rounded-lg text-sm transition-colors flex items-center justify-center gap-1\"\n                                >\n                                    <span>+</span>\n                                    <span>{t('sidebar.addEpisode')}</span>\n                                </button>\n                            )}\n                        </div>\n                    </div>\n                </>\n            )}\n        </>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/episode-selection.ts",
    "content": "export interface EpisodeLike {\n  id: string\n}\n\nexport function resolveSelectedEpisodeId(\n  episodes: ReadonlyArray<EpisodeLike>,\n  urlEpisodeId: string | null,\n): string | null {\n  if (episodes.length === 0) return null\n  if (urlEpisodeId && episodes.some((episode) => episode.id === urlEpisodeId)) {\n    return urlEpisodeId\n  }\n  return episodes[0].id\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/hooks/useProject.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { useState, useCallback } from 'react'\nimport { Project } from '@/types/project'\nimport { apiFetch } from '@/lib/api-fetch'\n\n/**\n * 刷新范围\n * - all: 刷新项目数据 + 资产数据\n * - project: 只刷新项目数据\n * - assets: 只刷新资产数据\n */\nexport type RefreshScope = 'all' | 'project' | 'assets'\n\n/**\n * 刷新模式\n * - full: 显示 loading 状态\n * - silent: 静默刷新，不显示 loading\n */\nexport type RefreshMode = 'full' | 'silent'\n\n/**\n * 刷新选项\n */\nexport interface RefreshOptions {\n  scope?: RefreshScope    // 默认 'all'\n  mode?: RefreshMode      // 默认 'silent'\n}\n\n/**\n * 通用项目数据管理Hook\n * \n * 🔥 V2: 统一刷新架构\n * - 单一 refresh(options) 函数，替代原有的 loadProject/loadAssets/silentRefresh/silentRefreshAssets\n * - 通过 scope 和 mode 参数控制刷新行为\n * - 消除刷新行为不一致问题\n */\nexport function useProject(projectId: string) {\n  const [project, setProject] = useState<Project | null>(null)\n  const [loading, setLoading] = useState(true)\n  const [error, setError] = useState<string | null>(null)\n  const [assetsLoaded, setAssetsLoaded] = useState(false)\n  const [assetsLoading, setAssetsLoading] = useState(false)\n\n  /**\n   * 🔥 统一刷新函数\n   * \n   * @param options.scope - 刷新范围：'all' | 'project' | 'assets'，默认 'all'\n   * @param options.mode - 刷新模式：'full' | 'silent'，默认 'silent'\n   * \n   * 调用示例：\n   * - refresh()                        → 静默刷新全部（最常用）\n   * - refresh({ scope: 'assets' })     → 只刷新资产\n   * - refresh({ scope: 'project' })    → 只刷新项目（不刷资产）\n   * - refresh({ mode: 'full' })        → 完整刷新带 loading\n   */\n  const refresh = useCallback(async (options: RefreshOptions = {}) => {\n    const { scope = 'all', mode = 'silent' } = options\n\n    // 完整刷新模式：显示 loading\n    if (mode === 'full') {\n      setLoading(true)\n      setError(null)\n    }\n\n    // 资产刷新时显示 assetsLoading\n    if (scope === 'assets') {\n      setAssetsLoading(true)\n    }\n\n    try {\n      // 刷新项目数据\n      if (scope === 'all' || scope === 'project') {\n        const res = await apiFetch(`/api/projects/${projectId}/data`)\n        if (!res.ok) {\n          const errorData = await res.json()\n          throw new Error(errorData.error || 'Failed to load project')\n        }\n        const data = await res.json()\n        setProject(data.project)\n\n        // 完整刷新时重置资产加载状态\n        if (mode === 'full') {\n          setAssetsLoaded(false)\n        }\n      }\n\n      // 刷新资产数据\n      if (scope === 'all' || scope === 'assets') {\n        const res = await apiFetch(`/api/projects/${projectId}/assets`)\n        if (res.ok) {\n          const assets = await res.json()\n          setProject(prev => {\n            if (!prev?.novelPromotionData) return prev\n            return {\n              ...prev,\n              novelPromotionData: {\n                ...prev.novelPromotionData,\n                characters: assets.characters || [],\n                locations: assets.locations || []\n              }\n            }\n          })\n          setAssetsLoaded(true)\n        }\n      }\n    } catch (err: unknown) {\n      _ulogError('Refresh error:', err)\n      if (mode === 'full') {\n        setError(getErrorMessage(err))\n      }\n      // 静默刷新不设置错误状态，避免干扰用户\n    } finally {\n      if (mode === 'full') {\n        setLoading(false)\n      }\n      if (scope === 'assets') {\n        setAssetsLoading(false)\n      }\n    }\n  }, [projectId])\n\n  /**\n   * 更新项目数据（乐观更新）\n   */\n  const updateProject = useCallback((updates: Partial<Project>) => {\n    setProject(prev => prev ? { ...prev, ...updates } : null)\n  }, [])\n\n  return {\n    // 状态\n    project,\n    loading,\n    error,\n    assetsLoaded,\n    assetsLoading,\n\n    // 🔥 统一刷新函数\n    refresh,\n\n    // 乐观更新\n    updateProject\n  }\n}\n  const getErrorMessage = (err: unknown): string => {\n    if (err instanceof Error) return err.message\n    return String(err)\n  }\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/NovelPromotionWorkspace.tsx",
    "content": "'use client'\n\nimport ProgressToast from '@/components/ProgressToast'\nimport ConfirmDialog from '@/components/ConfirmDialog'\nimport { AnimatedBackground } from '@/components/ui/SharedComponents'\nimport { useTranslations } from 'next-intl'\nimport { WorkspaceProvider } from './WorkspaceProvider'\nimport WorkspaceRunStreamConsoles from './components/WorkspaceRunStreamConsoles'\nimport WorkspaceStageContent from './components/WorkspaceStageContent'\nimport WorkspaceAssetLibraryModal from './components/WorkspaceAssetLibraryModal'\nimport WorkspaceHeaderShell from './components/WorkspaceHeaderShell'\nimport { WorkspaceStageRuntimeProvider } from './WorkspaceStageRuntimeContext'\nimport { useNovelPromotionWorkspaceController } from './hooks/useNovelPromotionWorkspaceController'\nimport type { NovelPromotionWorkspaceProps } from './types'\nimport '@/styles/animations.css'\n\nfunction NovelPromotionWorkspaceContent(props: NovelPromotionWorkspaceProps) {\n  const vm = useNovelPromotionWorkspaceController(props)\n  const tProgress = useTranslations('progress')\n\n  const {\n    project,\n    projectId,\n    episodeId,\n    episodes = [],\n    onEpisodeSelect,\n    onEpisodeCreate,\n    onEpisodeRename,\n    onEpisodeDelete,\n  } = props\n\n  const storyToScriptStream = vm.execution.storyToScriptStream\n  const scriptToStoryboardStream = vm.execution.scriptToStoryboardStream\n  const storyToScriptActive =\n    storyToScriptStream.isRunning ||\n    storyToScriptStream.isRecoveredRunning ||\n    storyToScriptStream.status === 'running'\n  const scriptToStoryboardActive =\n    scriptToStoryboardStream.isRunning ||\n    scriptToStoryboardStream.isRecoveredRunning ||\n    scriptToStoryboardStream.status === 'running'\n\n  const showStoryToScriptMinBadge =\n    storyToScriptStream.isVisible &&\n    storyToScriptStream.stages.length > 0 &&\n    storyToScriptActive &&\n    vm.execution.storyToScriptConsoleMinimized\n\n  const showScriptToStoryboardMinBadge =\n    scriptToStoryboardStream.isVisible &&\n    scriptToStoryboardStream.stages.length > 0 &&\n    scriptToStoryboardActive &&\n    vm.execution.scriptToStoryboardConsoleMinimized\n\n  const runBadges: { id: string; label: string; onClick: () => void }[] = []\n\n  if (showStoryToScriptMinBadge) {\n    runBadges.push({\n      id: 'story-to-script',\n      label: tProgress('runConsole.storyToScriptRunning'),\n      onClick: () => vm.execution.setStoryToScriptConsoleMinimized(false),\n    })\n  }\n\n  if (showScriptToStoryboardMinBadge) {\n    runBadges.push({\n      id: 'script-to-storyboard',\n      label: tProgress('runConsole.scriptToStoryboardRunning'),\n      onClick: () => vm.execution.setScriptToStoryboardConsoleMinimized(false),\n    })\n  }\n\n  if (!vm.project.projectData) {\n    return <div className=\"text-center text-(--glass-text-secondary)\">{vm.i18n.tc('loading')}</div>\n  }\n\n  return (\n    <div>\n      <AnimatedBackground />\n\n      <WorkspaceHeaderShell\n        isSettingsModalOpen={vm.ui.isSettingsModalOpen}\n        isWorldContextModalOpen={vm.ui.isWorldContextModalOpen}\n        onCloseSettingsModal={() => vm.ui.setIsSettingsModalOpen(false)}\n        onCloseWorldContextModal={() => vm.ui.setIsWorldContextModalOpen(false)}\n        availableModels={vm.ui.userModelsForSettings || undefined}\n        modelsLoaded={vm.ui.userModelsLoaded}\n        artStyle={vm.project.artStyle}\n        analysisModel={vm.project.analysisModel}\n        characterModel={vm.project.characterModel}\n        locationModel={vm.project.locationModel}\n        storyboardModel={vm.project.storyboardModel}\n        editModel={vm.project.editModel}\n        videoModel={vm.project.videoModel}\n        audioModel={vm.project.audioModel}\n        capabilityOverrides={vm.project.capabilityOverrides}\n        videoRatio={vm.project.videoRatio}\n        ttsRate={vm.project.ttsRate !== undefined && vm.project.ttsRate !== null ? String(vm.project.ttsRate) : undefined}\n        onUpdateConfig={vm.actions.handleUpdateConfig}\n        globalAssetText={vm.project.globalAssetText}\n        projectName={project.name}\n        episodes={episodes}\n        currentEpisodeId={episodeId}\n        onEpisodeSelect={onEpisodeSelect}\n        onEpisodeCreate={onEpisodeCreate}\n        onEpisodeRename={onEpisodeRename}\n        onEpisodeDelete={onEpisodeDelete}\n        capsuleNavItems={vm.stageNav.capsuleNavItems}\n        currentStage={vm.stageNav.currentStage}\n        onStageChange={vm.stageNav.handleStageChange}\n        projectId={projectId}\n        episodeId={episodeId}\n        onOpenAssetLibrary={() => vm.ui.openAssetLibrary()}\n        onOpenSettingsModal={() => vm.ui.setIsSettingsModalOpen(true)}\n        onRefresh={() => vm.ui.onRefresh({ mode: 'full' })}\n        assetLibraryLabel={vm.i18n.t('buttons.assetLibrary')}\n        settingsLabel={vm.i18n.t('buttons.settings')}\n        refreshTitle={vm.i18n.t('buttons.refreshData')}\n      />\n\n      <div className=\"pt-24\">\n        <WorkspaceStageRuntimeProvider value={vm.runtime.stageRuntime}>\n          <WorkspaceStageContent currentStage={vm.stageNav.currentStage} />\n        </WorkspaceStageRuntimeProvider>\n\n        <WorkspaceAssetLibraryModal\n          isOpen={vm.ui.isAssetLibraryOpen}\n          onClose={vm.ui.closeAssetLibrary}\n          assetsLoading={vm.ui.assetsLoading}\n          assetsLoadingState={vm.ui.assetsLoadingState}\n          hasCharacters={vm.project.projectCharacters.length > 0}\n          hasLocations={vm.project.projectLocations.length > 0}\n          projectId={projectId}\n          isAnalyzingAssets={vm.execution.isAssetAnalysisRunning}\n          focusCharacterId={vm.ui.assetLibraryFocusCharacterId}\n          focusCharacterRequestId={vm.ui.assetLibraryFocusRequestId}\n          triggerGlobalAnalyze={vm.ui.triggerGlobalAnalyzeOnOpen}\n          onGlobalAnalyzeComplete={() => vm.ui.setTriggerGlobalAnalyzeOnOpen(false)}\n        />\n\n        {vm.execution.showCreatingToast && (\n          <ProgressToast\n            show\n            message={vm.i18n.t('storyInput.creating')}\n            step={vm.execution.transitionProgress.step || ''}\n            runBadges={runBadges}\n          />\n        )}\n\n        <ConfirmDialog\n          show={vm.rebuild.showRebuildConfirm}\n          type=\"warning\"\n          title={vm.rebuild.rebuildConfirmTitle}\n          message={vm.rebuild.rebuildConfirmMessage}\n          confirmText={vm.i18n.t('rebuildConfirm.confirm')}\n          cancelText={vm.i18n.t('rebuildConfirm.cancel')}\n          onConfirm={vm.rebuild.handleAcceptRebuildConfirm}\n          onCancel={vm.rebuild.handleCancelRebuildConfirm}\n        />\n\n        <WorkspaceRunStreamConsoles\n          storyToScriptStream={vm.execution.storyToScriptStream}\n          scriptToStoryboardStream={vm.execution.scriptToStoryboardStream}\n          storyToScriptConsoleMinimized={vm.execution.storyToScriptConsoleMinimized}\n          scriptToStoryboardConsoleMinimized={vm.execution.scriptToStoryboardConsoleMinimized}\n          onStoryToScriptMinimizedChange={vm.execution.setStoryToScriptConsoleMinimized}\n          onScriptToStoryboardMinimizedChange={vm.execution.setScriptToStoryboardConsoleMinimized}\n          hideMinimizedBadges={vm.execution.showCreatingToast}\n        />\n      </div>\n    </div>\n  )\n}\n\nexport default function NovelPromotionWorkspace(props: NovelPromotionWorkspaceProps) {\n  const { projectId, episodeId } = props\n  return (\n    <WorkspaceProvider projectId={projectId} episodeId={episodeId}>\n      <NovelPromotionWorkspaceContent {...props} />\n    </WorkspaceProvider>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/StageNavigation.tsx",
    "content": "/**\n * 小说推文模式 - 阶段导航组件\n */\n\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport { Link } from '@/i18n/navigation'\n\ninterface StageNavigationProps {\n  projectId: string  // 用于构建链接\n  episodeId?: string | null  // 当前剧集ID，用于新标签页打开时保持剧集\n  currentStage: string\n  hasNovelText: boolean  // 是否有文本输入（用于启用配音阶段）\n  hasAudio: boolean\n  hasAssets: boolean\n  hasStoryboards: boolean\n  hasTextStoryboards: boolean  // 是否有文字分镜（用于启用分镜面板）\n  hasVideos?: boolean\n  hasVoiceLines?: boolean  // 是否有配音台词\n  isDisabled: boolean\n  onStageClick: (stage: string) => void\n}\n\nexport function StageNavigation({\n  projectId,\n  episodeId,\n  currentStage,\n  hasNovelText,\n  hasAudio,\n  hasAssets,\n  hasStoryboards,\n  hasTextStoryboards,\n  hasVideos,\n  hasVoiceLines,\n  isDisabled,\n  onStageClick\n}: StageNavigationProps) {\n  const t = useTranslations('stages')\n  // 如果 currentStage 是旧的 'text-storyboard'，自动重定向到 'storyboard'\n  const effectiveStage = currentStage === 'text-storyboard' ? 'storyboard' : currentStage\n\n  const stages = [\n    { id: 'config', label: t('config'), enabled: true },\n    { id: 'assets', label: t('assets'), enabled: hasAudio || hasAssets },\n    { id: 'storyboard', label: t('storyboard'), enabled: hasTextStoryboards || hasStoryboards },\n    { id: 'videos', label: t('videos'), enabled: hasStoryboards || hasVideos },\n    // 配音阶段只要有文本输入就可以启用，不受其他条件限制\n    { id: 'voice', label: t('voice'), enabled: hasNovelText || hasVoiceLines }\n  ]\n\n  return (\n    <div className=\"flex items-center justify-center space-x-3 text-sm mt-6\">\n      {stages.map((stage, index) => {\n        const isEnabled = stage.enabled && !isDisabled\n        const isCurrent = effectiveStage === stage.id\n        // 构建 URL，包含 episode 参数以支持新标签页打开时保持当前剧集\n        const href = episodeId\n          ? `/workspace/${projectId}?stage=${stage.id}&episode=${episodeId}`\n          : `/workspace/${projectId}?stage=${stage.id}`\n\n        const className = `px-5 py-2.5 rounded-xl transition-all font-medium inline-block ${isCurrent\n          ? 'bg-[var(--glass-accent-from)] text-white shadow-md'\n          : isEnabled\n            ? 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)] cursor-pointer'\n            : 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)] cursor-not-allowed pointer-events-none'\n          }`\n\n        return (\n          <div key={stage.id} className=\"flex items-center space-x-3\">\n            {isEnabled ? (\n              <Link\n                href={href}\n                onClick={(e) => {\n                  // 左键点击时阻止默认行为，使用 onStageClick\n                  if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey) {\n                    e.preventDefault()\n                    onStageClick(stage.id)\n                  }\n                  // 中键点击或 Ctrl/Cmd+点击 会使用默认的链接行为打开新标签\n                }}\n                className={className}\n              >\n                {stage.label}\n              </Link>\n            ) : (\n              <span className={className}>\n                {stage.label}\n              </span>\n            )}\n            {index < stages.length - 1 && (\n              <AppIcon name=\"chevronRight\" className=\"w-5 h-5 text-[var(--glass-text-tertiary)]\" />\n            )}\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/WorkspaceProvider.tsx",
    "content": "'use client'\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useMemo,\n  useRef,\n  type ReactNode,\n} from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '@/lib/query/keys'\nimport { useSSE } from '@/lib/query/hooks/useSSE'\nimport type { SSEEvent } from '@/lib/task/types'\n\ntype RefreshScope = 'all' | 'assets' | 'project'\ntype RefreshOptions = { scope?: string; mode?: string }\ntype TaskEventListener = (event: SSEEvent) => void\n\ninterface WorkspaceContextValue {\n  projectId: string\n  episodeId?: string\n  refreshData: (scope?: RefreshScope) => Promise<void>\n  onRefresh: (options?: RefreshOptions) => Promise<void>\n  subscribeTaskEvents: (listener: TaskEventListener) => () => void\n}\n\ninterface WorkspaceProviderProps {\n  projectId: string\n  episodeId?: string\n  children: ReactNode\n}\n\nconst WorkspaceContext = createContext<WorkspaceContextValue | null>(null)\n\nexport function WorkspaceProvider({ projectId, episodeId, children }: WorkspaceProviderProps) {\n  const queryClient = useQueryClient()\n  const listenersRef = useRef(new Set<TaskEventListener>())\n\n  const refreshData = useCallback(async (scope?: RefreshScope) => {\n    const promises: Promise<unknown>[] = []\n\n    if (!scope || scope === 'all' || scope === 'project') {\n      promises.push(queryClient.refetchQueries({ queryKey: queryKeys.projectData(projectId) }))\n    }\n\n    if (!scope || scope === 'all' || scope === 'assets') {\n      promises.push(queryClient.refetchQueries({ queryKey: queryKeys.projectAssets.all(projectId) }))\n    }\n\n    if (episodeId) {\n      promises.push(queryClient.refetchQueries({ queryKey: queryKeys.episodeData(projectId, episodeId) }))\n      promises.push(queryClient.refetchQueries({ queryKey: queryKeys.storyboards.all(episodeId) }))\n      promises.push(queryClient.refetchQueries({ queryKey: queryKeys.voiceLines.all(episodeId) }))\n    }\n\n    await Promise.all(promises)\n  }, [episodeId, projectId, queryClient])\n\n  const onRefresh = useCallback(async (options?: RefreshOptions) => {\n    await refreshData(options?.scope as RefreshScope | undefined)\n  }, [refreshData])\n\n  const subscribeTaskEvents = useCallback((listener: TaskEventListener) => {\n    listenersRef.current.add(listener)\n    return () => {\n      listenersRef.current.delete(listener)\n    }\n  }, [])\n\n  const handleTaskEvent = useCallback((event: SSEEvent) => {\n    for (const listener of listenersRef.current) {\n      listener(event)\n    }\n  }, [])\n\n  useSSE({\n    projectId,\n    episodeId,\n    enabled: !!projectId,\n    onEvent: handleTaskEvent,\n  })\n\n  const value = useMemo<WorkspaceContextValue>(() => ({\n    projectId,\n    episodeId,\n    refreshData,\n    onRefresh,\n    subscribeTaskEvents,\n  }), [episodeId, onRefresh, projectId, refreshData, subscribeTaskEvents])\n\n  return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>\n}\n\nexport function useWorkspaceProvider() {\n  const context = useContext(WorkspaceContext)\n  if (!context) {\n    throw new Error('useWorkspaceProvider must be used within WorkspaceProvider')\n  }\n  return context\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/WorkspaceStageRuntimeContext.tsx",
    "content": "'use client'\n\nimport { createContext, useContext, type ReactNode } from 'react'\nimport type { CapabilitySelections, ModelCapabilities } from '@/lib/model-config-contract'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\nimport type { BatchVideoGenerationParams, VideoGenerationOptions } from './components/video'\n\nexport interface WorkspaceStageVideoModelOption {\n  value: string\n  label: string\n  provider?: string\n  providerName?: string\n  capabilities?: ModelCapabilities\n  videoPricingTiers?: VideoPricingTier[]\n}\n\nexport interface WorkspaceStageRuntimeValue {\n  assetsLoading: boolean\n  isSubmittingTTS: boolean\n  isTransitioning: boolean\n  isConfirmingAssets: boolean\n  isStartingStoryToScript: boolean\n  isStartingScriptToStoryboard: boolean\n  videoRatio: string | null | undefined\n  artStyle: string | null | undefined\n  videoModel: string | null | undefined\n  capabilityOverrides: CapabilitySelections\n  userVideoModels: WorkspaceStageVideoModelOption[]\n  onNovelTextChange: (value: string) => Promise<void>\n  onVideoRatioChange: (value: string) => Promise<void>\n  onArtStyleChange: (value: string) => Promise<void>\n  onRunStoryToScript: () => Promise<void>\n  onClipUpdate: (clipId: string, data: unknown) => Promise<void>\n  onOpenAssetLibrary: () => void\n  onRunScriptToStoryboard: () => Promise<void>\n  onStageChange: (stage: string) => void\n  onGenerateVideo: (\n    storyboardId: string,\n    panelIndex: number,\n    model?: string,\n    firstLastFrame?: {\n      lastFrameStoryboardId: string\n      lastFramePanelIndex: number\n      flModel: string\n      customPrompt?: string\n    },\n    generationOptions?: VideoGenerationOptions,\n    panelId?: string,\n  ) => Promise<void>\n  onGenerateAllVideos: (options?: BatchVideoGenerationParams) => Promise<void>\n  onUpdateVideoPrompt: (\n    storyboardId: string,\n    panelIndex: number,\n    value: string,\n    field?: 'videoPrompt' | 'firstLastFramePrompt',\n  ) => Promise<void>\n  onUpdatePanelVideoModel: (storyboardId: string, panelIndex: number, model: string) => Promise<void>\n  onOpenAssetLibraryForCharacter: (characterId?: string | null, refreshAssets?: boolean) => void\n}\n\nconst WorkspaceStageRuntimeContext = createContext<WorkspaceStageRuntimeValue | null>(null)\n\ninterface WorkspaceStageRuntimeProviderProps {\n  value: WorkspaceStageRuntimeValue\n  children: ReactNode\n}\n\nexport function WorkspaceStageRuntimeProvider({ value, children }: WorkspaceStageRuntimeProviderProps) {\n  return (\n    <WorkspaceStageRuntimeContext.Provider value={value}>\n      {children}\n    </WorkspaceStageRuntimeContext.Provider>\n  )\n}\n\nexport function useWorkspaceStageRuntime() {\n  const context = useContext(WorkspaceStageRuntimeContext)\n  if (!context) {\n    throw new Error('useWorkspaceStageRuntime must be used within WorkspaceStageRuntimeProvider')\n  }\n  return context\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetLibrary.tsx",
    "content": "'use client'\n\n/**\n * 资产库 - 全局浮动按钮,打开后显示完整的资产管理界面\n * 复用AssetsStage组件,保持功能完全一致\n * \n * 🔥 V6.5 重构：删除 characters/locations props，AssetsStage 现在内部直接订阅\n * 🔥 V6.6 重构：删除 onGenerateImage prop，AssetsStage 现在内部使用 mutation hooks\n */\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport AssetsStage from './AssetsStage'\nimport { AppIcon } from '@/components/ui/icons'\nimport { useProjectAssets } from '@/lib/query/hooks'\nimport JSZip from 'jszip'\nimport { logError as _logError } from '@/lib/logging/core'\n\ninterface AssetLibraryProps {\n  projectId: string\n  isAnalyzingAssets: boolean\n}\n\nexport default function AssetLibrary({\n  projectId,\n  isAnalyzingAssets\n}: AssetLibraryProps) {\n  const [isOpen, setIsOpen] = useState(false)\n  const [isDownloading, setIsDownloading] = useState(false)\n  const t = useTranslations('assets')\n\n  // 获取项目资产数据用于下载\n  const { data: assets } = useProjectAssets(projectId)\n\n  const handleDownloadAll = async () => {\n    const characters = assets?.characters ?? []\n    const locations = assets?.locations ?? []\n\n    // 收集所有有效图片\n    const imageEntries: Array<{ filename: string; url: string }> = []\n\n    // 角色图片\n    for (const character of characters) {\n      for (const appearance of character.appearances ?? []) {\n        const url = appearance.imageUrl\n        if (!url) continue\n        const safeName = character.name.replace(/[/\\\\:*?\"<>|]/g, '_')\n        const filename = appearance.appearanceIndex === 0\n          ? `characters/${safeName}.jpg`\n          : `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`\n        imageEntries.push({ filename, url })\n      }\n    }\n\n    // 场景图片：取已选中的那张\n    for (const location of locations) {\n      const selectedImage = location.images?.find(img => img.isSelected) ?? location.images?.[0]\n      const url = selectedImage?.imageUrl\n      if (!url) continue\n      const safeName = location.name.replace(/[/\\\\:*?\"<>|]/g, '_')\n      imageEntries.push({ filename: `locations/${safeName}.jpg`, url })\n    }\n\n    if (imageEntries.length === 0) {\n      alert(t('assetLibrary.downloadEmpty'))\n      return\n    }\n\n    setIsDownloading(true)\n    try {\n      const zip = new JSZip()\n      await Promise.all(\n        imageEntries.map(async ({ filename, url }) => {\n          try {\n            const response = await fetch(url)\n            if (!response.ok) return\n            const blob = await response.blob()\n            zip.file(filename, blob)\n          } catch {\n            // 单张失败不影响其他\n          }\n        })\n      )\n      const content = await zip.generateAsync({ type: 'blob' })\n      const link = document.createElement('a')\n      link.href = URL.createObjectURL(content)\n      link.download = `assets_${new Date().toISOString().slice(0, 10)}.zip`\n      document.body.appendChild(link)\n      link.click()\n      document.body.removeChild(link)\n      URL.revokeObjectURL(link.href)\n    } catch (error) {\n      _logError('打包下载失败:', error)\n      alert(t('assetLibrary.downloadFailed'))\n    } finally {\n      setIsDownloading(false)\n    }\n  }\n\n  return (\n    <>\n      {/* 触发按钮 - 现代玻璃态风格 */}\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(true)}\n        className=\"fixed top-20 right-4 z-40 flex items-center gap-2 px-5 py-2.5 glass-btn-base glass-btn-secondary text-[var(--glass-text-secondary)] font-medium\"\n      >\n        <AppIcon name=\"folderCards\" className=\"w-5 h-5\" />\n        {t('assetLibrary.button')}\n      </button>\n\n      {/* 全屏弹窗 - 现代玻璃态风格 */}\n      {isOpen && (\n        <div className=\"fixed inset-0 glass-overlay z-50 flex items-center justify-center p-6\">\n          <div className=\"glass-surface-modal w-full h-full max-w-[95vw] max-h-[95vh] flex flex-col overflow-hidden\">\n            {/* 头部 */}\n            <div className=\"flex items-center justify-between px-8 py-5 border-b border-[var(--glass-stroke-base)]\">\n              <div className=\"flex items-center gap-4\">\n                <div className=\"w-10 h-10 bg-[var(--glass-accent-from)] rounded-2xl flex items-center justify-center shadow-[var(--glass-shadow-md)]\">\n                  <AppIcon name=\"folderCards\" className=\"w-5 h-5 text-white\" />\n                </div>\n                <h2 className=\"text-2xl font-bold text-[var(--glass-text-primary)]\">{t('assetLibrary.title')}</h2>\n\n                {/* 下载按钮 - 紧贴标题 */}\n                <button\n                  type=\"button\"\n                  onClick={handleDownloadAll}\n                  disabled={isDownloading}\n                  title={t('common.download')}\n                  className=\"w-9 h-9 glass-btn-base glass-btn-secondary flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed\"\n                >\n                  <AppIcon\n                    name={isDownloading ? 'refresh' : 'download'}\n                    className={`w-4 h-4${isDownloading ? ' animate-spin' : ''}`}\n                  />\n                </button>\n              </div>\n              <button\n                type=\"button\"\n                onClick={() => setIsOpen(false)}\n                className=\"w-10 h-10 glass-btn-base glass-btn-secondary flex items-center justify-center\"\n              >\n                <AppIcon name=\"close\" className=\"w-5 h-5 text-[var(--glass-text-tertiary)]\" />\n              </button>\n            </div>\n\n            {/* 内容区域 - 复用AssetsStage，现在 AssetsStage 内部直接订阅和处理图片生成 */}\n            <div className=\"flex-1 overflow-y-auto p-8\">\n              <AssetsStage\n                projectId={projectId}\n                isAnalyzingAssets={isAnalyzingAssets}\n              />\n            </div>\n          </div>\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetsStage.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\n/**\n * 资产确认阶段 - 小说推文模式专用\n * 包含TTS生成和资产分析\n * \n * 重构说明 v2:\n * - 角色和场景操作函数已提取到 hooks/useCharacterActions 和 hooks/useLocationActions\n * - 批量生成逻辑已提取到 hooks/useBatchGeneration\n * - TTS/音色逻辑已提取到 hooks/useTTSGeneration\n * - 弹窗状态已提取到 hooks/useAssetModals\n * - 档案管理已提取到 hooks/useProfileManagement\n * - UI已拆分为 CharacterSection, LocationSection, AssetToolbar, AssetModals 组件\n */\n\nimport { useState, useCallback, useMemo } from 'react'\n// 移除了 useRouter 导入，因为不再需要在组件中操作 URL\nimport { Character, CharacterAppearance } from '@/types/project'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport {\n  useGenerateProjectCharacterImage,\n  useGenerateProjectLocationImage,\n  useProjectAssets,\n  useRefreshProjectAssets,\n} from '@/lib/query/hooks'\n\n// Hooks\nimport { useCharacterActions } from './assets/hooks/useCharacterActions'\nimport { useLocationActions } from './assets/hooks/useLocationActions'\nimport { useBatchGeneration } from './assets/hooks/useBatchGeneration'\nimport { useTTSGeneration } from './assets/hooks/useTTSGeneration'\nimport { useAssetModals } from './assets/hooks/useAssetModals'\nimport { useProfileManagement } from './assets/hooks/useProfileManagement'\nimport { useAssetsCopyFromHub } from './assets/hooks/useAssetsCopyFromHub'\nimport { useAssetsGlobalActions } from './assets/hooks/useAssetsGlobalActions'\nimport { useAssetsImageEdit } from './assets/hooks/useAssetsImageEdit'\n\n// Components\nimport CharacterSection from './assets/CharacterSection'\nimport LocationSection from './assets/LocationSection'\nimport AssetToolbar from './assets/AssetToolbar'\nimport AssetsStageStatusOverlays from './assets/AssetsStageStatusOverlays'\nimport UnconfirmedProfilesSection from './assets/UnconfirmedProfilesSection'\nimport AssetsStageModals from './assets/AssetsStageModals'\n\ninterface AssetsStageProps {\n  projectId: string\n  isAnalyzingAssets: boolean\n  focusCharacterId?: string | null\n  focusCharacterRequestId?: number\n  // 🔥 通过 props 触发全局分析（避免 URL 参数竞态条件）\n  triggerGlobalAnalyze?: boolean\n  onGlobalAnalyzeComplete?: () => void\n}\n\nexport default function AssetsStage({\n  projectId,\n  isAnalyzingAssets,\n  focusCharacterId = null,\n  focusCharacterRequestId = 0,\n  triggerGlobalAnalyze = false,\n  onGlobalAnalyzeComplete\n}: AssetsStageProps) {\n  // 🔥 V6.5 重构：直接订阅缓存，消除 props drilling\n  const { data: assets } = useProjectAssets(projectId)\n  // 🔧 使用 useMemo 稳定引用，防止 useCallback/useEffect 依赖问题\n  const characters = useMemo(() => assets?.characters ?? [], [assets?.characters])\n  const locations = useMemo(() => assets?.locations ?? [], [assets?.locations])\n  // 🔥 使用 React Query 刷新，替代 onRefresh prop\n  const refreshAssets = useRefreshProjectAssets(projectId)\n  const onRefresh = useCallback(() => { refreshAssets() }, [refreshAssets])\n\n  // 🔥 V6.6 重构：使用 mutation hooks 替代 onGenerateImage prop\n  const generateCharacterImage = useGenerateProjectCharacterImage(projectId)\n  const generateLocationImage = useGenerateProjectLocationImage(projectId)\n\n  // 🔥 内部图片生成函数 - 使用 mutation hooks 实现乐观更新\n  const handleGenerateImage = useCallback(async (\n    type: 'character' | 'location',\n    id: string,\n    appearanceId?: string,\n    count?: number,\n  ) => {\n    if (type === 'character' && appearanceId) {\n      await generateCharacterImage.mutateAsync({ characterId: id, appearanceId, count })\n    } else if (type === 'location') {\n      await generateLocationImage.mutateAsync({ locationId: id, count })\n    }\n  }, [generateCharacterImage, generateLocationImage])\n\n  const t = useTranslations('assets')\n  // 计算资产总数\n  const totalAppearances = characters.reduce((sum, char) => sum + (char.appearances?.length || 0), 0)\n  const totalLocations = locations.length\n  const totalAssets = totalAppearances + totalLocations\n\n  // 本地 UI 状态\n  const [previewImage, setPreviewImage] = useState<string | null>(null)\n  const [toast, setToast] = useState<{ message: string; type: 'success' | 'warning' | 'error' } | null>(null)\n\n  // 辅助：获取角色形象\n  const getAppearances = (character: Character): CharacterAppearance[] => {\n    return character.appearances || []\n  }\n\n  // 显示提示\n  const showToast = useCallback((message: string, type: 'success' | 'warning' | 'error' = 'success', duration = 3000) => {\n    setToast({ message, type })\n    setTimeout(() => setToast(null), duration)\n  }, [])\n\n  // === 使用提取的 Hooks ===\n\n  // 🔥 V6.5 重构：hooks 现在内部订阅 useProjectAssets，不再需要传 characters/locations\n\n  // 批量生成\n  const {\n    isBatchSubmitting,\n    batchProgress,\n    activeTaskKeys,\n    registerTransientTaskKey,\n    clearTransientTaskKey,\n    handleGenerateAllImages,\n    handleRegenerateAllImages\n  } = useBatchGeneration({\n    projectId,\n    handleGenerateImage\n  })\n\n  const {\n    isGlobalAnalyzing,\n    globalAnalyzingState,\n    handleGlobalAnalyze,\n  } = useAssetsGlobalActions({\n    projectId,\n    triggerGlobalAnalyze,\n    onGlobalAnalyzeComplete,\n    onRefresh,\n    showToast,\n    t,\n  })\n\n  const {\n    copyFromGlobalTarget,\n    isGlobalCopyInFlight,\n    handleCopyFromGlobal,\n    handleCopyLocationFromGlobal,\n    handleVoiceSelectFromHub,\n    handleConfirmCopyFromGlobal,\n    handleCloseCopyPicker,\n  } = useAssetsCopyFromHub({\n    projectId,\n    onRefresh,\n    showToast,\n  })\n\n  // 角色操作\n  const {\n    handleDeleteCharacter,\n    handleDeleteAppearance,\n    handleSelectCharacterImage,\n    handleConfirmSelection,\n    handleRegenerateSingleCharacter,\n    handleRegenerateCharacterGroup\n  } = useCharacterActions({\n    projectId,\n    showToast\n  })\n\n  // 场景操作\n  const {\n    handleDeleteLocation,\n    handleSelectLocationImage,\n    handleConfirmLocationSelection,\n    handleRegenerateSingleLocation,\n    handleRegenerateLocationGroup\n  } = useLocationActions({\n    projectId,\n    showToast\n  })\n\n  // TTS/音色\n  const {\n    voiceDesignCharacter,\n    handleVoiceChange,\n    handleOpenVoiceDesign,\n    handleVoiceDesignSave,\n    handleCloseVoiceDesign\n  } = useTTSGeneration({\n    projectId\n  })\n\n  // 弹窗状态\n  const {\n    editingAppearance,\n    editingLocation,\n    showAddCharacter,\n    showAddLocation,\n    imageEditModal,\n    characterImageEditModal,\n    setShowAddCharacter,\n    setShowAddLocation,\n    handleEditAppearance,\n    handleEditLocation,\n    handleOpenLocationImageEdit,\n    handleOpenCharacterImageEdit,\n    closeEditingAppearance,\n    closeEditingLocation,\n    closeAddCharacter,\n    closeAddLocation,\n    closeImageEditModal,\n    closeCharacterImageEditModal\n  } = useAssetModals({\n    projectId\n  })\n  // 档案管理\n  const {\n    unconfirmedCharacters,\n    isConfirmingCharacter,\n    deletingCharacterId,\n    batchConfirming,\n    editingProfile,\n    handleEditProfile,\n    handleConfirmProfile,\n    handleBatchConfirm,\n    handleDeleteProfile,\n    setEditingProfile\n  } = useProfileManagement({\n    projectId,\n    showToast\n  })\n  const batchConfirmingState = batchConfirming\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'modify',\n      resource: 'image',\n      hasOutput: false,\n    })\n    : null\n\n  const {\n    handleUndoCharacter,\n    handleUndoLocation,\n    handleLocationImageEdit,\n    handleCharacterImageEdit,\n    handleUpdateAppearanceDescription,\n    handleUpdateLocationDescription,\n  } = useAssetsImageEdit({\n    projectId,\n    t,\n    showToast,\n    onRefresh,\n    editingAppearance,\n    editingLocation,\n    imageEditModal,\n    characterImageEditModal,\n    closeEditingAppearance,\n    closeEditingLocation,\n    closeImageEditModal,\n    closeCharacterImageEditModal,\n  })\n\n  return (\n    <div className=\"space-y-4\">\n      <AssetsStageStatusOverlays\n        toast={toast}\n        onCloseToast={() => setToast(null)}\n        isGlobalAnalyzing={isGlobalAnalyzing}\n        globalAnalyzingState={globalAnalyzingState}\n        globalAnalyzingTitle={t('toolbar.globalAnalyzing')}\n        globalAnalyzingHint={t('toolbar.globalAnalyzingHint')}\n        globalAnalyzingTip={t('toolbar.globalAnalyzingTip')}\n      />\n\n      {/* 资产工具栏 */}\n      <AssetToolbar\n        projectId={projectId}\n        totalAssets={totalAssets}\n        totalAppearances={totalAppearances}\n        totalLocations={totalLocations}\n        isBatchSubmitting={isBatchSubmitting}\n        isAnalyzingAssets={isAnalyzingAssets}\n        isGlobalAnalyzing={isGlobalAnalyzing}\n        batchProgress={batchProgress}\n        onGenerateAll={handleGenerateAllImages}\n        onRegenerateAll={handleRegenerateAllImages}\n        onGlobalAnalyze={handleGlobalAnalyze}\n      />\n\n      <UnconfirmedProfilesSection\n        unconfirmedCharacters={unconfirmedCharacters}\n        confirmTitle={t('stage.confirmProfiles')}\n        confirmHint={t('stage.confirmHint')}\n        confirmAllLabel={t('stage.confirmAll', { count: unconfirmedCharacters.length })}\n        batchConfirming={batchConfirming}\n        batchConfirmingState={batchConfirmingState}\n        deletingCharacterId={deletingCharacterId}\n        isConfirmingCharacter={isConfirmingCharacter}\n        onBatchConfirm={handleBatchConfirm}\n        onEditProfile={handleEditProfile}\n        onConfirmProfile={handleConfirmProfile}\n        onUseExistingProfile={handleCopyFromGlobal}\n        onDeleteProfile={handleDeleteProfile}\n      />\n\n      {/* 角色资产区块 */}\n      <CharacterSection\n        projectId={projectId}\n        focusCharacterId={focusCharacterId}\n        focusCharacterRequestId={focusCharacterRequestId}\n        activeTaskKeys={activeTaskKeys}\n        onClearTaskKey={clearTransientTaskKey}\n        onRegisterTransientTaskKey={registerTransientTaskKey}\n        isAnalyzingAssets={isAnalyzingAssets}\n        onAddCharacter={() => setShowAddCharacter(true)}\n        onDeleteCharacter={handleDeleteCharacter}\n        onDeleteAppearance={handleDeleteAppearance}\n        onEditAppearance={handleEditAppearance}\n        handleGenerateImage={handleGenerateImage}\n        onSelectImage={handleSelectCharacterImage}\n        onConfirmSelection={handleConfirmSelection}\n        onRegenerateSingle={handleRegenerateSingleCharacter}\n        onRegenerateGroup={handleRegenerateCharacterGroup}\n        onUndo={handleUndoCharacter}\n        onImageClick={setPreviewImage}\n        onImageEdit={(charId, appIdx, imgIdx, name) => handleOpenCharacterImageEdit(charId, appIdx, imgIdx, name)}\n        onVoiceChange={(characterId, customVoiceUrl) => handleVoiceChange(characterId, 'custom', characterId, customVoiceUrl)}\n        onVoiceDesign={handleOpenVoiceDesign}\n        onVoiceSelectFromHub={handleVoiceSelectFromHub}\n        onCopyFromGlobal={handleCopyFromGlobal}\n        getAppearances={getAppearances}\n      />\n\n      {/* 场景资产区块 */}\n      <LocationSection\n        projectId={projectId}\n        activeTaskKeys={activeTaskKeys}\n        onClearTaskKey={clearTransientTaskKey}\n        onRegisterTransientTaskKey={registerTransientTaskKey}\n        onAddLocation={() => setShowAddLocation(true)}\n        onDeleteLocation={handleDeleteLocation}\n        onEditLocation={handleEditLocation}\n        handleGenerateImage={handleGenerateImage}\n        onSelectImage={handleSelectLocationImage}\n        onConfirmSelection={handleConfirmLocationSelection}\n        onRegenerateSingle={handleRegenerateSingleLocation}\n        onRegenerateGroup={handleRegenerateLocationGroup}\n        onUndo={handleUndoLocation}\n        onImageClick={setPreviewImage}\n        onImageEdit={(locId, imgIdx) => handleOpenLocationImageEdit(locId, imgIdx)}\n        onCopyFromGlobal={handleCopyLocationFromGlobal}\n      />\n\n      <AssetsStageModals\n        projectId={projectId}\n        onRefresh={onRefresh}\n        onClosePreview={() => setPreviewImage(null)}\n        handleGenerateImage={handleGenerateImage}\n        handleUpdateAppearanceDescription={handleUpdateAppearanceDescription}\n        handleUpdateLocationDescription={handleUpdateLocationDescription}\n        handleLocationImageEdit={handleLocationImageEdit}\n        handleCharacterImageEdit={handleCharacterImageEdit}\n        handleCloseVoiceDesign={handleCloseVoiceDesign}\n        handleVoiceDesignSave={handleVoiceDesignSave}\n        handleCloseCopyPicker={handleCloseCopyPicker}\n        handleConfirmCopyFromGlobal={handleConfirmCopyFromGlobal}\n        handleConfirmProfile={handleConfirmProfile}\n        closeEditingAppearance={closeEditingAppearance}\n        closeEditingLocation={closeEditingLocation}\n        closeAddCharacter={closeAddCharacter}\n        closeAddLocation={closeAddLocation}\n        closeImageEditModal={closeImageEditModal}\n        closeCharacterImageEditModal={closeCharacterImageEditModal}\n        isConfirmingCharacter={isConfirmingCharacter}\n        setEditingProfile={setEditingProfile}\n        previewImage={previewImage}\n        imageEditModal={imageEditModal}\n        characterImageEditModal={characterImageEditModal}\n        editingAppearance={editingAppearance}\n        editingLocation={editingLocation}\n        showAddCharacter={showAddCharacter}\n        showAddLocation={showAddLocation}\n        voiceDesignCharacter={voiceDesignCharacter}\n        editingProfile={editingProfile}\n        copyFromGlobalTarget={copyFromGlobalTarget}\n        isGlobalCopyInFlight={isGlobalCopyInFlight}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/ConfigStage.tsx",
    "content": "'use client'\n\nimport NovelInputStage from './NovelInputStage'\nimport { useWorkspaceStageRuntime } from '../WorkspaceStageRuntimeContext'\nimport { useWorkspaceEpisodeStageData } from '../hooks/useWorkspaceEpisodeStageData'\n\nexport default function ConfigStage() {\n  const runtime = useWorkspaceStageRuntime()\n  const { episodeName, novelText } = useWorkspaceEpisodeStageData()\n\n  return (\n    <NovelInputStage\n      novelText={novelText}\n      episodeName={episodeName}\n      onNovelTextChange={runtime.onNovelTextChange}\n      isSubmittingTask={runtime.isSubmittingTTS || runtime.isStartingStoryToScript}\n      isSwitchingStage={runtime.isTransitioning}\n      videoRatio={runtime.videoRatio ?? undefined}\n      artStyle={runtime.artStyle ?? undefined}\n      onVideoRatioChange={runtime.onVideoRatioChange}\n      onArtStyleChange={runtime.onArtStyleChange}\n      onNext={runtime.onRunStoryToScript}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/NovelInputStage.tsx",
    "content": "'use client'\n\n/**\n * 小说推文模式 - 故事输入阶段 (Story View)\n * V3.2 UI: 极简版，专注剧本输入，资产管理移至资产库\n */\n\nimport { useTranslations } from 'next-intl'\nimport { useState, useRef, useEffect } from 'react'\nimport '@/styles/animations.css'\nimport { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon, RatioPreviewIcon } from '@/components/ui/icons'\n\n/**\n * RatioIcon - 比例预览图标组件\n * 需求：所有比例选项的图标永远保持蓝色，帮助用户建立比例视觉记忆\n */\nfunction RatioIcon({ ratio, size = 24, selected = false }: { ratio: string; size?: number; selected?: boolean }) {\n  // 始终以选中态渲染图标，但仍保留 selected 参数以满足类型与未来扩展\n  return <RatioPreviewIcon ratio={ratio} size={size} selected={selected || true} />\n}\n\n/**\n * RatioSelector - 比例选择下拉组件\n */\nfunction RatioSelector({\n  value,\n  onChange,\n  options,\n  getUsage\n}: {\n  value: string\n  onChange: (value: string) => void\n  options: { value: string; label: string; recommended?: boolean }[]\n  getUsage?: (ratio: string) => string\n}) {\n  const [isOpen, setIsOpen] = useState(false)\n  const dropdownRef = useRef<HTMLDivElement>(null)\n  const t = useTranslations('novelPromotion')\n\n  useEffect(() => {\n    function handleClickOutside(event: MouseEvent) {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n        setIsOpen(false)\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => document.removeEventListener('mousedown', handleClickOutside)\n  }, [])\n\n  const selectedOption = options.find(o => o.value === value)\n\n  return (\n    <div className=\"relative\" ref={dropdownRef}>\n      {/* 触发按钮 */}\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(!isOpen)}\n        className=\"glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors\"\n      >\n        <div className=\"flex items-center gap-3\">\n          <RatioIcon ratio={value} size={20} selected />\n          <span className=\"text-sm text-[var(--glass-text-primary)] font-medium\">{selectedOption?.label || value}</span>\n        </div>\n        <AppIcon name=\"chevronDown\" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n      </button>\n\n      {/* 下拉面板 - 横向网格布局 */}\n      {isOpen && (\n        <div className=\"glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar\" style={{ minWidth: '280px' }}>\n          <div className=\"grid grid-cols-5 gap-2\">\n            {options.map((option) => (\n              <button\n                key={option.value}\n                type=\"button\"\n                onClick={() => {\n                  onChange(option.value)\n                  setIsOpen(false)\n                }}\n                className={`flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-[var(--glass-bg-muted)]/70 transition-colors ${value === option.value\n                  ? 'bg-[var(--glass-tone-info-bg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'\n                  : ''\n                  }`}\n              >\n                <RatioIcon ratio={option.value} size={28} selected={value === option.value} />\n                <span className={`flex flex-col items-center gap-1 text-xs ${value === option.value ? 'text-[var(--glass-tone-info-fg)] font-medium' : 'text-[var(--glass-text-secondary)]'}`}>\n                  <span className=\"flex items-center gap-1\">\n                    <span>{option.label}</span>\n                    {option.recommended && (\n                      <span className=\"px-1.5 py-0.5 rounded-full bg-[var(--glass-tone-info-bg)] text-[10px] text-[var(--glass-tone-info-fg)] font-semibold\">\n                        {t('smartImport.smartImport.recommended')}\n                      </span>\n                    )}\n                  </span>\n                  {getUsage && (\n                    <span className=\"text-[10px] font-normal text-[var(--glass-text-tertiary)] leading-snug text-center\">\n                      {getUsage(option.value)}\n                    </span>\n                  )}\n                </span>\n              </button>\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n\n/**\n * StyleSelector - 视觉风格选择抽屉组件\n */\nfunction StyleSelector({\n  value,\n  onChange,\n  options\n}: {\n  value: string\n  onChange: (value: string) => void\n  options: { value: string; label: string; recommended?: boolean }[]\n}) {\n  const [isOpen, setIsOpen] = useState(false)\n  const dropdownRef = useRef<HTMLDivElement>(null)\n  const t = useTranslations('novelPromotion')\n\n  useEffect(() => {\n    function handleClickOutside(event: MouseEvent) {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n        setIsOpen(false)\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => document.removeEventListener('mousedown', handleClickOutside)\n  }, [])\n\n  const selectedOption = options.find(o => o.value === value) || options[0]\n\n  return (\n    <div className=\"relative\" ref={dropdownRef}>\n      {/* 触发按钮 */}\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(!isOpen)}\n        className=\"glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors\"\n      >\n        <div className=\"flex items-center\">\n          <span className=\"text-sm text-[var(--glass-text-primary)] font-medium\">{selectedOption.label}</span>\n        </div>\n        <AppIcon name=\"chevronDown\" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n      </button>\n\n      {/* 下拉面板 */}\n      {isOpen && (\n        <div className=\"glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3\">\n          <div className=\"grid grid-cols-2 gap-2\">\n            {options.map((option) => (\n              <button\n                key={option.value}\n                type=\"button\"\n                onClick={() => {\n                  onChange(option.value)\n                  setIsOpen(false)\n                }}\n                className={`flex items-center p-3 rounded-lg text-left transition-all ${value === option.value\n                  ? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'\n                  : 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'\n                  }`}\n              >\n                <span className=\"flex items-center gap-1 font-medium text-sm\">\n                  <span>{option.label}</span>\n                  {option.recommended && (\n                    <span className=\"px-1.5 py-0.5 rounded-full bg-[var(--glass-tone-info-bg)] text-[10px] text-[var(--glass-tone-info-fg)] font-semibold\">\n                      {t('smartImport.smartImport.recommended')}\n                    </span>\n                  )}\n                </span>\n              </button>\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n\ninterface NovelInputStageProps {\n  // 核心数据\n  novelText: string\n  // 当前剧集名称\n  episodeName?: string\n  // 回调函数\n  onNovelTextChange: (value: string) => void\n  onNext: () => void\n  // 状态\n  isSubmittingTask?: boolean\n  isSwitchingStage?: boolean\n  // 旁白开关\n  enableNarration?: boolean\n  onEnableNarrationChange?: (enabled: boolean) => void\n  // 配置项 - 比例与风格\n  videoRatio?: string\n  artStyle?: string\n  onVideoRatioChange?: (value: string) => void\n  onArtStyleChange?: (value: string) => void\n}\n\nexport default function NovelInputStage({\n  novelText,\n  episodeName,\n  onNovelTextChange,\n  onNext,\n  isSubmittingTask = false,\n  isSwitchingStage = false,\n  enableNarration = false,\n  onEnableNarrationChange,\n  videoRatio = '9:16',\n  artStyle = 'american-comic',\n  onVideoRatioChange,\n  onArtStyleChange\n}: NovelInputStageProps) {\n  const t = useTranslations('novelPromotion')\n\n  // ── IME 组合输入处理 ──\n  // 中文/日文/韩文输入法在组合（composing）期间会持续触发 onChange，\n  // 如果此时同步到父组件（触发 API 请求 + React Query invalidation），\n  // 服务端返回的旧数据会覆盖当前输入，导致拼音跳动。\n  // 解决方案：组合期间仅更新本地 state，组合结束后再同步到父组件。\n  const isComposingRef = useRef(false)\n  const [localText, setLocalText] = useState(novelText)\n\n  // 当父组件的 novelText 变化（非本地编辑触发）时，同步到本地 state\n  useEffect(() => {\n    if (!isComposingRef.current) {\n      setLocalText(novelText)\n    }\n  }, [novelText])\n\n  const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    const newValue = e.target.value\n    setLocalText(newValue)\n    // 仅在非 IME 组合状态下才同步到父组件\n    if (!isComposingRef.current) {\n      onNovelTextChange(newValue)\n    }\n  }\n\n  const handleCompositionStart = () => {\n    isComposingRef.current = true\n  }\n\n  const handleCompositionEnd = (e: React.CompositionEvent<HTMLTextAreaElement>) => {\n    isComposingRef.current = false\n    // 组合结束，将最终文本同步到父组件\n    onNovelTextChange(e.currentTarget.value)\n  }\n\n  const hasContent = localText.trim().length > 0\n\n  // 当前配置展示文案\n  const ratioDisplayLabel = (VIDEO_RATIOS.find((option) => option.value === videoRatio) ?? VIDEO_RATIOS[0])?.label\n  const artStyleDisplayLabel = (ART_STYLES.find((option) => option.value === artStyle) ?? ART_STYLES[0])?.label\n\n  // 不同比例适合的素材类型文案映射（完整句子，用于 info 悬浮层）\n  const ratioUsageTextMap: Record<string, string> = {\n    '1:1': t('storyInput.ratioUsage.1_1'),\n    '9:16': t('storyInput.ratioUsage.9_16'),\n    '16:9': t('storyInput.ratioUsage.16_9'),\n    '4:3': t('storyInput.ratioUsage.4_3'),\n    '3:4': t('storyInput.ratioUsage.3_4'),\n    '2:3': t('storyInput.ratioUsage.2_3'),\n    '3:2': t('storyInput.ratioUsage.3_2'),\n    '4:5': t('storyInput.ratioUsage.4_5'),\n    '5:4': t('storyInput.ratioUsage.5_4'),\n    '21:9': t('storyInput.ratioUsage.21_9'),\n  }\n\n  // 下拉中使用的简短标签（低信息密度）\n  const ratioUsageTagMap: Record<string, string> = {\n    '1:1': t('storyInput.ratioUsageTag.1_1'),\n    '9:16': t('storyInput.ratioUsageTag.9_16'),\n    '16:9': t('storyInput.ratioUsageTag.16_9'),\n    '4:3': t('storyInput.ratioUsageTag.4_3'),\n    '3:4': t('storyInput.ratioUsageTag.3_4'),\n    '2:3': t('storyInput.ratioUsageTag.2_3'),\n    '3:2': t('storyInput.ratioUsageTag.3_2'),\n    '4:5': t('storyInput.ratioUsageTag.4_5'),\n    '5:4': t('storyInput.ratioUsageTag.5_4'),\n    '21:9': t('storyInput.ratioUsageTag.21_9'),\n  }\n\n  const getRatioUsageText = (ratio: string): string =>\n    ratioUsageTextMap[ratio] ?? t('storyInput.videoRatioHint')\n\n  const getRatioUsageTag = (ratio: string): string =>\n    ratioUsageTagMap[ratio] ?? ''\n\n  const ratioUsageText = getRatioUsageText(videoRatio)\n  const stageSwitchingState = isSwitchingStage\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'text',\n      hasOutput: false,\n    })\n    : null\n\n  return (\n    <div className=\"max-w-5xl mx-auto space-y-5\">\n\n      {/* 当前编辑剧集提示 - 顶部居中醒目显示 */}\n      {episodeName && (\n        <div className=\"text-center py-1\">\n          <div className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n            {t(\"storyInput.currentEditing\", { name: episodeName })}\n          </div>\n          <div className=\"text-sm text-[var(--glass-text-tertiary)] mt-1\">{t(\"storyInput.editingTip\")}</div>\n        </div>\n      )}\n\n      {/* 主输入区域 */}\n      <div className=\"glass-surface-elevated overflow-hidden\">\n        <div className=\"p-6\">\n          {/* 字数统计 */}\n          <div className=\"flex items-center justify-end mb-3\">\n            <span className=\"glass-chip glass-chip-neutral text-xs\">\n              {t(\"storyInput.wordCount\")} {localText.length}\n            </span>\n          </div>\n\n          {/* 剧本输入框 */}\n          <textarea\n            value={localText}\n            onChange={handleTextChange}\n            onCompositionStart={handleCompositionStart}\n            onCompositionEnd={handleCompositionEnd}\n            placeholder={`请输入您的剧本或小说内容...\n\nAI 将根据您的文本智能分析：\n• 自动识别场景切换\n• 提取角色对话和动作\n• 生成分镜脚本\n\n例如：\n清晨，阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起，看了一眼床头的闹钟——已经八点了！他猛地跳下床，手忙脚乱地开始穿衣服...`}\n            className=\"glass-textarea-base custom-scrollbar h-80 px-4 py-3 text-base resize-none placeholder:text-[var(--glass-text-tertiary)]\"\n            disabled={isSubmittingTask || isSwitchingStage}\n          />\n\n          {/* 资产库引导提示 */}\n          <div className=\"mt-5 p-4 glass-surface-soft\">\n            <div className=\"flex items-start gap-3\">\n              <div className=\"w-10 h-10 glass-surface-soft rounded-xl flex items-center justify-center flex-shrink-0\">\n                <AppIcon name=\"folderCards\" className=\"w-5 h-5 text-[var(--glass-text-secondary)]\" />\n              </div>\n              <div className=\"flex-1 min-w-0\">\n                <div className=\"font-semibold text-[var(--glass-text-secondary)] mb-1\">{t(\"storyInput.assetLibraryTip.title\")}</div>\n                <p className=\"text-sm text-[var(--glass-text-tertiary)] leading-relaxed\">\n                  {t(\"storyInput.assetLibraryTip.description\")}\n                </p>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* 画面比例与视觉风格配置 */}\n      <div className=\"glass-surface p-6 relative z-10\">\n        <div className=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n          {/* 画面比例 */}\n          <div className=\"space-y-3\">\n            <div className=\"flex items-center gap-1\">\n              <h3 className=\"text-sm font-semibold text-[var(--glass-text-muted)] tracking-[0.01em]\">\n                {t(\"storyInput.videoRatio\")}\n              </h3>\n              <div className=\"relative inline-flex items-center group\">\n                <div className=\"w-4 h-4 flex items-center justify-center rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-sm\">\n                  <AppIcon name=\"info\" className=\"w-3 h-3\" />\n                </div>\n                <div className=\"pointer-events-none absolute left-1/2 top-full mt-2 -translate-x-1/2 opacity-0 translate-y-1 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-150 z-20\">\n                  <div\n                    className=\"rounded-lg border bg-[var(--glass-bg-surface-strong)]/95 border-[var(--glass-tone-info-bg)] px-3.5 py-2.5 text-xs leading-relaxed text-[var(--glass-text-primary)] shadow-[0_18px_45px_rgba(15,23,42,0.55)] whitespace-pre-wrap\"\n                    style={{ minWidth: 220 }}\n                  >\n                    {ratioUsageText}\n                  </div>\n                </div>\n              </div>\n            </div>\n            <p className=\"text-xs text-[var(--glass-text-tertiary)]\">\n              {t(\"storyInput.videoRatioHint\")}\n            </p>\n            <RatioSelector\n              value={videoRatio}\n              onChange={(value) => onVideoRatioChange?.(value)}\n              options={VIDEO_RATIOS.map((option) => ({\n                ...option,\n                recommended: option.value === '9:16'\n              }))}\n              getUsage={getRatioUsageTag}\n            />\n          </div>\n\n          {/* 视觉风格 */}\n          <div className=\"space-y-3\">\n            <h3 className=\"text-sm font-semibold text-[var(--glass-text-muted)] tracking-[0.01em]\">{t(\"storyInput.visualStyle\")}</h3>\n            <p className=\"text-xs text-[var(--glass-text-tertiary)]\">\n              {t(\"storyInput.visualStyleHint\")}\n            </p>\n            <StyleSelector\n              value={artStyle}\n              onChange={(value) => onArtStyleChange?.(value)}\n              options={ART_STYLES.map((option) => ({\n                ...option,\n                recommended: option.value === 'realistic'\n              }))}\n            />\n          </div>\n        </div>\n        <p className=\"text-xs text-[var(--glass-text-secondary)] mt-4 text-center\">\n          {t(\"storyInput.currentConfigSummary\", {\n            ratio: ratioDisplayLabel,\n            style: artStyleDisplayLabel\n          })}\n        </p>\n        <p className=\"text-xs text-[var(--glass-text-tertiary)] mt-1 text-center\">\n          {t(\"storyInput.assetLibraryRatioNote\")}\n        </p>\n        <p className=\"text-xs text-[var(--glass-text-tertiary)] mt-1 text-center\">\n          {t(\"storyInput.moreConfig\")}\n        </p>\n      </div>\n\n      {/* 旁白开关 + 操作按钮 */}\n      <div className=\"glass-surface p-6\">\n        {/* 旁白开关 */}\n        {onEnableNarrationChange && (\n          <div className=\"glass-surface-soft flex items-center justify-between p-4 rounded-xl mb-6\">\n            <div className=\"flex items-center gap-3\">\n              <span className=\"inline-flex h-9 w-9 items-center justify-center rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] font-semibold text-sm\">VO</span>\n              <div>\n                <div className=\"font-medium text-[var(--glass-text-primary)]\">{t(\"storyInput.narration.title\")}</div>\n                <div className=\"text-xs text-[var(--glass-text-tertiary)]\">{t(\"storyInput.narration.description\")}</div>\n              </div>\n            </div>\n            <button\n              onClick={() => onEnableNarrationChange(!enableNarration)}\n              className={`relative w-14 h-8 rounded-full transition-colors ${enableNarration\n                ? 'bg-[var(--glass-accent-from)]'\n                : 'bg-[var(--glass-stroke-strong)]'\n                }`}\n            >\n              <span\n                className={`absolute top-1 left-1 w-6 h-6 bg-[var(--glass-bg-surface)] rounded-full shadow-sm transition-transform ${enableNarration ? 'translate-x-6' : 'translate-x-0'\n                  }`}\n              />\n            </button>\n          </div>\n        )}\n\n        {/* 开始创作按钮 */}\n        <button\n          onClick={onNext}\n          disabled={!hasContent || isSubmittingTask || isSwitchingStage}\n          className=\"glass-btn-base glass-btn-primary w-full py-4 text-white font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2\"\n        >\n          {isSwitchingStage ? (\n            <TaskStatusInline state={stageSwitchingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n          ) : (\n            <>\n              <span>{t(\"smartImport.manualCreate.button\")}</span>\n              <AppIcon name=\"arrowRight\" className=\"w-5 h-5\" />\n            </>\n          )}\n        </button>\n        <p className=\"text-center text-xs text-[var(--glass-text-tertiary)] mt-3\">\n          {hasContent ? t(\"storyInput.ready\") : t(\"storyInput.pleaseInput\")}\n        </p>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PanelEditForm.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport PanelEditFormV2 from '@/components/ui/patterns/PanelEditFormV2'\nimport { GlassButton, GlassModalShell, GlassSurface } from '@/components/ui/primitives'\nimport { Character, Location } from '@/types/project'\nimport { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface CharacterAppearance {\n  id?: string\n  appearanceIndex?: string | number\n  changeReason?: string | null\n}\n\nexport interface PanelEditData {\n  id: string\n  panelIndex: number\n  panelNumber: number | null\n  shotType: string | null\n  cameraMove: string | null\n  description: string | null\n  location: string | null\n  characters: { name: string; appearance: string }[]\n  srtStart: number | null\n  srtEnd: number | null\n  duration: number | null\n  videoPrompt: string | null\n  photographyRules?: string | null\n  actingNotes?: string | null\n  sourceText?: string | null\n}\n\ninterface PanelEditFormProps {\n  panelData: PanelEditData\n  isSaving?: boolean\n  saveStatus?: 'idle' | 'saving' | 'error'\n  saveErrorMessage?: string | null\n  onRetrySave?: () => void\n  onUpdate: (updates: Partial<PanelEditData>) => void\n  onOpenCharacterPicker: () => void\n  onOpenLocationPicker: () => void\n  onRemoveCharacter: (index: number) => void\n  onRemoveLocation: () => void\n}\n\nexport default function PanelEditForm({\n  panelData,\n  isSaving = false,\n  saveStatus = 'idle',\n  saveErrorMessage = null,\n  onRetrySave,\n  onUpdate,\n  onOpenCharacterPicker,\n  onOpenLocationPicker,\n  onRemoveCharacter,\n  onRemoveLocation\n}: PanelEditFormProps) {\n  return (\n    <PanelEditFormV2\n      panelData={panelData}\n      isSaving={isSaving}\n      saveStatus={saveStatus}\n      saveErrorMessage={saveErrorMessage}\n      onRetrySave={onRetrySave}\n      onUpdate={onUpdate}\n      onOpenCharacterPicker={onOpenCharacterPicker}\n      onOpenLocationPicker={onOpenLocationPicker}\n      onRemoveCharacter={onRemoveCharacter}\n      onRemoveLocation={onRemoveLocation}\n      uiMode=\"flow\"\n    />\n  )\n}\n\ninterface CharacterPickerModalProps {\n  projectId: string\n  currentCharacters: { name: string; appearance: string }[]\n  onSelect: (charName: string, appearance: string) => void\n  onClose: () => void\n}\n\nexport function CharacterPickerModal({\n  projectId,\n  currentCharacters,\n  onSelect,\n  onClose\n}: CharacterPickerModalProps) {\n  const ts = useTranslations('storyboard')\n  const { data: assets } = useProjectAssets(projectId)\n  const characters: Character[] = assets?.characters ?? []\n\n  return (\n    <GlassModalShell open onClose={onClose} size=\"md\" title={ts('panel.selectCharacter')}>\n      <div className=\"max-h-[60vh] space-y-4 overflow-y-auto\">\n        {characters.length === 0 ? (\n          <p className=\"py-8 text-center text-[var(--glass-text-secondary)]\">{ts('panel.noCharacterAssets')}</p>\n        ) : (\n          characters.map(char => {\n            const appearances = char.appearances || []\n            return (\n              <GlassSurface key={char.id} variant=\"panel\" className=\"space-y-2 p-3\">\n                <h5 className=\"text-sm font-medium text-[var(--glass-text-primary)]\">{char.name}</h5>\n                <div className=\"flex flex-wrap gap-2\">\n                  {appearances.map((app: CharacterAppearance) => {\n                    const appearanceName = app.changeReason || ts('panel.defaultAppearance')\n                    const isSelected = currentCharacters.some(\n                      c => c.name === char.name && c.appearance === appearanceName\n                    )\n                    return (\n                      <GlassButton\n                        key={app.id || app.appearanceIndex}\n                        size=\"sm\"\n                        variant={isSelected ? 'secondary' : 'ghost'}\n                        disabled={isSelected}\n                        onClick={() => {\n                          if (!isSelected) onSelect(char.name, appearanceName)\n                        }}\n                      >\n                        {appearanceName}\n                        {isSelected && (\n                          <AppIcon name=\"checkTiny\" className=\"h-3 w-3\" />\n                        )}\n                      </GlassButton>\n                    )\n                  })}\n                </div>\n              </GlassSurface>\n            )\n          })\n        )}\n      </div>\n    </GlassModalShell>\n  )\n}\n\ninterface LocationPickerModalProps {\n  projectId: string\n  currentLocation: string | null\n  onSelect: (locationName: string) => void\n  onClose: () => void\n}\n\nexport function LocationPickerModal({\n  projectId,\n  currentLocation,\n  onSelect,\n  onClose\n}: LocationPickerModalProps) {\n  const ts = useTranslations('storyboard')\n  const { data: assets } = useProjectAssets(projectId)\n  const locations: Location[] = assets?.locations ?? []\n\n  return (\n    <GlassModalShell open onClose={onClose} size=\"md\" title={ts('panel.selectLocation')}>\n      <div className=\"max-h-[60vh] overflow-y-auto\">\n        {locations.length === 0 ? (\n          <p className=\"py-8 text-center text-[var(--glass-text-secondary)]\">{ts('panel.noLocationAssets')}</p>\n        ) : (\n          <div className=\"grid grid-cols-2 gap-3\">\n            {locations.map(loc => {\n              const isSelected = currentLocation === loc.name\n              return (\n                <button\n                  key={loc.id}\n                  type=\"button\"\n                  onClick={() => onSelect(loc.name)}\n                  className={`rounded-[var(--glass-radius-md)] border px-3 py-3 text-left transition-colors ${\n                    isSelected\n                      ? 'bg-[var(--glass-tone-success-bg)] text-[var(--glass-tone-success-fg)]'\n                      : 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'\n                  }`}\n                >\n                  <div className=\"font-medium text-[var(--glass-text-primary)] flex items-center gap-1.5\">\n                    <AppIcon name=\"imageAlt\" className=\"h-3.5 w-3.5 text-[var(--glass-text-tertiary)]\" />\n                    <span>{loc.name}</span>\n                  </div>\n                  {isSelected ? (\n                    <span className=\"text-xs text-[var(--glass-tone-success-fg)]\">{ts('panel.selected')}</span>\n                  ) : null}\n                </button>\n              )\n            })}\n          </div>\n        )}\n      </div>\n    </GlassModalShell>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PromptsStage.tsx",
    "content": "'use client'\n\nimport PromptsStageShell, { type PromptsStageShellProps } from './prompts-stage/PromptsStageShell'\n\nexport type { PromptsStageShellProps as PromptsStageProps } from './prompts-stage/PromptsStageShell'\n\nexport default function PromptsStage(props: PromptsStageShellProps) {\n  return <PromptsStageShell {...props} />\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/ScriptStage.tsx",
    "content": "'use client'\n\nimport ScriptView from './ScriptView'\nimport { useWorkspaceStageRuntime } from '../WorkspaceStageRuntimeContext'\nimport { useWorkspaceEpisodeStageData } from '../hooks/useWorkspaceEpisodeStageData'\nimport { useWorkspaceProvider } from '../WorkspaceProvider'\n\nexport default function ScriptStage() {\n  const runtime = useWorkspaceStageRuntime()\n  const { projectId, episodeId } = useWorkspaceProvider()\n  const { clips, storyboards } = useWorkspaceEpisodeStageData()\n\n  return (\n    <ScriptView\n      projectId={projectId}\n      episodeId={episodeId}\n      clips={clips}\n      storyboards={storyboards}\n      assetsLoading={runtime.assetsLoading}\n      onClipUpdate={runtime.onClipUpdate}\n      onOpenAssetLibrary={runtime.onOpenAssetLibrary}\n      onGenerateStoryboard={runtime.onRunScriptToStoryboard}\n      isSubmittingStoryboardBuild={runtime.isConfirmingAssets || runtime.isStartingScriptToStoryboard}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/ScriptView.tsx",
    "content": "export { default } from './script-view/ScriptViewContainer'\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/SmartImportWizard.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { useWizardState } from './smart-import/hooks/useWizardState'\nimport StepSource from './smart-import/steps/StepSource'\nimport StepParse from './smart-import/steps/StepParse'\nimport StepMapping from './smart-import/steps/StepMapping'\nimport StepConfirm from './smart-import/steps/StepConfirm'\nimport type { SplitEpisode } from './smart-import/types'\n\nexport type { SplitEpisode } from './smart-import/types'\n\ninterface SmartImportWizardProps {\n  onManualCreate: () => void\n  onImportComplete: (episodes: SplitEpisode[], triggerGlobalAnalysis?: boolean) => void\n  projectId: string\n  importStatus?: string | null\n}\n\nexport default function SmartImportWizard({\n  onManualCreate,\n  onImportComplete,\n  projectId,\n  importStatus,\n}: SmartImportWizardProps) {\n  const t = useTranslations('smartImport')\n  const wizard = useWizardState({ projectId, importStatus, onImportComplete, t })\n\n  const savingTaskState = wizard.saving\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'build',\n      resource: 'text',\n      hasOutput: false,\n    })\n    : null\n\n  if (wizard.stage === 'select') {\n    return (\n      <StepSource\n        onManualCreate={onManualCreate}\n        rawContent={wizard.rawContent}\n        onRawContentChange={wizard.setRawContent}\n        onAnalyze={() => { void wizard.handleAnalyze() }}\n        error={wizard.error}\n        showMarkerConfirm={wizard.showMarkerConfirm}\n        markerResult={wizard.markerResult}\n        onCloseMarkerConfirm={() => wizard.setShowMarkerConfirm(false)}\n        onUseMarkerSplit={() => { void wizard.handleMarkerSplit() }}\n        onUseAiSplit={() => {\n          wizard.setShowMarkerConfirm(false)\n          wizard.setMarkerResult(null)\n          void wizard.performAISplit()\n        }}\n      />\n    )\n  }\n\n  if (wizard.stage === 'analyzing') {\n    return <StepParse />\n  }\n\n  return (\n    <div className=\"p-6\">\n      <StepConfirm\n        episodes={wizard.episodes}\n        saving={wizard.saving}\n        savingTaskState={savingTaskState}\n        onReanalyze={() => wizard.setStage('select')}\n        onConfirm={() => { void wizard.handleConfirm() }}\n        onConfirmWithGlobalAnalysis={() => { void wizard.handleConfirm(true) }}\n      />\n\n      <StepMapping\n        episodes={wizard.episodes}\n        selectedEpisode={wizard.selectedEpisode}\n        onSelectEpisode={wizard.setSelectedEpisode}\n        onUpdateEpisodeNumber={wizard.updateEpisodeNumber}\n        onUpdateEpisodeTitle={wizard.updateEpisodeTitle}\n        onUpdateEpisodeSummary={wizard.updateEpisodeSummary}\n        onUpdateEpisodeContent={wizard.updateEpisodeContent}\n        onAddEpisode={wizard.addEpisode}\n        deleteConfirm={wizard.deleteConfirm}\n        onOpenDeleteConfirm={wizard.openDeleteConfirm}\n        onCloseDeleteConfirm={wizard.closeDeleteConfirm}\n        onConfirmDeleteEpisode={wizard.confirmDeleteEpisode}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/StoryboardStage.tsx",
    "content": "'use client'\n\nimport StoryboardStageView from './storyboard'\nimport { useWorkspaceStageRuntime } from '../WorkspaceStageRuntimeContext'\nimport { useWorkspaceEpisodeStageData } from '../hooks/useWorkspaceEpisodeStageData'\nimport { useWorkspaceProvider } from '../WorkspaceProvider'\n\nexport default function StoryboardStage() {\n  const runtime = useWorkspaceStageRuntime()\n  const { projectId, episodeId } = useWorkspaceProvider()\n  const { clips, storyboards } = useWorkspaceEpisodeStageData()\n\n  if (!episodeId) return null\n\n  return (\n    <StoryboardStageView\n      projectId={projectId}\n      episodeId={episodeId}\n      storyboards={storyboards}\n      clips={clips}\n      videoRatio={runtime.videoRatio || '9:16'}\n      onBack={() => runtime.onStageChange('script')}\n      onNext={async () => runtime.onStageChange('videos')}\n      isTransitioning={runtime.isTransitioning}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/VideoStage.tsx",
    "content": "'use client'\n\nimport VideoStageShell, { type VideoStageShellProps } from './video-stage/VideoStageShell'\n\nexport type { VideoStageShellProps as VideoStageProps } from './video-stage/VideoStageShell'\n\nexport default function VideoStage(props: VideoStageShellProps) {\n  return <VideoStageShell {...props} />\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/VideoStageRoute.tsx",
    "content": "'use client'\n\nimport VideoStage from './VideoStage'\nimport { useWorkspaceStageRuntime } from '../WorkspaceStageRuntimeContext'\nimport { useWorkspaceEpisodeStageData } from '../hooks/useWorkspaceEpisodeStageData'\nimport type { Clip as VideoClip } from './video'\nimport { useWorkspaceProvider } from '../WorkspaceProvider'\n\nexport default function VideoStageRoute() {\n  const runtime = useWorkspaceStageRuntime()\n  const { projectId, episodeId } = useWorkspaceProvider()\n  const { clips, storyboards } = useWorkspaceEpisodeStageData()\n  const normalizedClips: VideoClip[] = clips.map((clip) => ({\n    id: clip.id,\n    start: clip.start ?? 0,\n    end: clip.end ?? 0,\n    summary: clip.summary,\n  }))\n\n  if (!episodeId) return null\n\n  return (\n    <VideoStage\n      projectId={projectId}\n      episodeId={episodeId}\n      storyboards={storyboards}\n      clips={normalizedClips}\n      defaultVideoModel={runtime.videoModel || ''}\n      capabilityOverrides={runtime.capabilityOverrides}\n      videoRatio={runtime.videoRatio ?? undefined}\n      userVideoModels={runtime.userVideoModels}\n      onGenerateVideo={runtime.onGenerateVideo}\n      onGenerateAllVideos={runtime.onGenerateAllVideos}\n      onBack={() => runtime.onStageChange('storyboard')}\n      onUpdateVideoPrompt={runtime.onUpdateVideoPrompt}\n      onUpdatePanelVideoModel={runtime.onUpdatePanelVideoModel}\n      onOpenAssetLibraryForCharacter={(characterId) =>\n        characterId\n          ? runtime.onOpenAssetLibraryForCharacter(characterId, false)\n          : runtime.onOpenAssetLibrary()\n      }\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/VoiceStage.tsx",
    "content": "'use client'\n\nimport VoiceStageShell, { type VoiceStageShellProps } from './voice-stage/VoiceStageShell'\n\nexport type { VoiceStageShellProps as VoiceStageProps } from './voice-stage/VoiceStageShell'\n\nexport default function VoiceStage(props: VoiceStageShellProps) {\n  return <VoiceStageShell {...props} />\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/VoiceStageRoute.tsx",
    "content": "'use client'\n\nimport VoiceStage from './VoiceStage'\nimport { useWorkspaceStageRuntime } from '../WorkspaceStageRuntimeContext'\nimport { useWorkspaceProvider } from '../WorkspaceProvider'\n\nexport default function VoiceStageRoute() {\n  const runtime = useWorkspaceStageRuntime()\n  const { projectId, episodeId } = useWorkspaceProvider()\n\n  if (!episodeId) return null\n\n  return (\n    <VoiceStage\n      projectId={projectId}\n      episodeId={episodeId}\n      onBack={() => runtime.onStageChange('videos')}\n      onOpenAssetLibraryForCharacter={(characterId) =>\n        characterId\n          ? runtime.onOpenAssetLibraryForCharacter(characterId, false)\n          : runtime.onOpenAssetLibrary()\n      }\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceAssetLibraryModal.tsx",
    "content": "'use client'\n\nimport AssetsStage from './AssetsStage'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface WorkspaceAssetLibraryModalProps {\n  isOpen: boolean\n  onClose: () => void\n  assetsLoading: boolean\n  assetsLoadingState: TaskPresentationState | null\n  hasCharacters: boolean\n  hasLocations: boolean\n  projectId: string\n  isAnalyzingAssets: boolean\n  focusCharacterId: string | null\n  focusCharacterRequestId: number\n  triggerGlobalAnalyze: boolean\n  onGlobalAnalyzeComplete: () => void\n}\n\nexport default function WorkspaceAssetLibraryModal({\n  isOpen,\n  onClose,\n  assetsLoading,\n  assetsLoadingState,\n  hasCharacters,\n  hasLocations,\n  projectId,\n  isAnalyzingAssets,\n  focusCharacterId,\n  focusCharacterRequestId,\n  triggerGlobalAnalyze,\n  onGlobalAnalyzeComplete,\n}: WorkspaceAssetLibraryModalProps) {\n  if (!isOpen) return null\n\n  return (\n    <div\n      className=\"fixed inset-0 z-[100] flex items-center justify-center glass-overlay animate-fadeIn\"\n      onClick={(e) => {\n        if (e.target === e.currentTarget) onClose()\n      }}\n    >\n      <div className=\"glass-surface-modal w-[95vw] max-w-6xl h-[90vh] flex flex-col\">\n        <div className=\"flex items-center justify-between px-8 py-5 border-b border-[var(--glass-stroke-base)] flex-shrink-0\">\n          <h2 className=\"text-2xl font-bold text-[var(--glass-text-primary)] flex items-center gap-3\">\n            <AppIcon name=\"package\" className=\"h-7 w-7 text-[var(--glass-text-secondary)]\" />\n            资产库\n          </h2>\n          <button\n            onClick={onClose}\n            className=\"glass-btn-base glass-btn-soft rounded-full p-3\"\n          >\n            <AppIcon name=\"close\" className=\"w-6 h-6\" />\n          </button>\n        </div>\n\n        <div className=\"flex-1 overflow-y-auto p-6 custom-scrollbar\" data-asset-scroll-container=\"1\">\n          {assetsLoading && !hasCharacters && !hasLocations && (\n            <div className=\"flex flex-col items-center justify-center h-64 text-[var(--glass-text-tertiary)] animate-pulse\">\n              <TaskStatusInline state={assetsLoadingState} className=\"text-base [&>span]:text-base\" />\n            </div>\n          )}\n          <AssetsStage\n            projectId={projectId}\n            isAnalyzingAssets={isAnalyzingAssets}\n            focusCharacterId={focusCharacterId}\n            focusCharacterRequestId={focusCharacterRequestId}\n            triggerGlobalAnalyze={triggerGlobalAnalyze}\n            onGlobalAnalyzeComplete={onGlobalAnalyzeComplete}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceHeaderShell.tsx",
    "content": "'use client'\n\nimport { CapsuleNav, EpisodeSelector } from '@/components/ui/CapsuleNav'\nimport { SettingsModal, WorldContextModal } from '@/components/ui/ConfigModals'\nimport WorkspaceTopActions from './WorkspaceTopActions'\nimport type { NovelPromotionPanel } from '@/types/project'\nimport type { CapabilitySelections, ModelCapabilities } from '@/lib/model-config-contract'\n\ninterface EpisodeSummary {\n  id: string\n  name: string\n  episodeNumber?: number\n  description?: string | null\n  clips?: unknown[]\n  storyboards?: Array<{\n    panels?: NovelPromotionPanel[] | null\n  }>\n}\n\ninterface UserModelOption {\n  value: string\n  label: string\n  provider?: string\n  providerName?: string\n  capabilities?: ModelCapabilities\n}\n\ninterface UserModelsPayload {\n  llm: UserModelOption[]\n  image: UserModelOption[]\n  video: UserModelOption[]\n  audio: UserModelOption[]\n}\n\ninterface WorkspaceHeaderShellProps {\n  isSettingsModalOpen: boolean\n  isWorldContextModalOpen: boolean\n  onCloseSettingsModal: () => void\n  onCloseWorldContextModal: () => void\n  availableModels?: UserModelsPayload\n  modelsLoaded: boolean\n  artStyle: string | null | undefined\n  analysisModel: string | null | undefined\n  characterModel: string | null | undefined\n  locationModel: string | null | undefined\n  storyboardModel: string | null | undefined\n  editModel: string | null | undefined\n  videoModel: string | null | undefined\n  audioModel: string | null | undefined\n  capabilityOverrides: CapabilitySelections\n  videoRatio: string | null | undefined\n  ttsRate: string | null | undefined\n  onUpdateConfig: (key: string, value: unknown) => Promise<void>\n  globalAssetText: string\n  projectName: string\n  episodes: EpisodeSummary[]\n  currentEpisodeId?: string\n  onEpisodeSelect?: (episodeId: string) => void\n  onEpisodeCreate?: () => void\n  onEpisodeRename?: (episodeId: string, newName: string) => void\n  onEpisodeDelete?: (episodeId: string) => void\n  capsuleNavItems: Array<{\n    id: string\n    icon: string\n    label: string\n    status: 'empty' | 'active' | 'processing' | 'ready'\n    disabled?: boolean\n    disabledLabel?: string\n  }>\n  currentStage: string\n  onStageChange: (stage: string) => void\n  projectId: string\n  episodeId?: string\n  onOpenAssetLibrary: () => void\n  onOpenSettingsModal: () => void\n  onRefresh: () => void\n  assetLibraryLabel: string\n  settingsLabel: string\n  refreshTitle: string\n}\n\nexport default function WorkspaceHeaderShell({\n  isSettingsModalOpen,\n  isWorldContextModalOpen,\n  onCloseSettingsModal,\n  onCloseWorldContextModal,\n  availableModels,\n  modelsLoaded,\n  artStyle,\n  analysisModel,\n  characterModel,\n  locationModel,\n  storyboardModel,\n  editModel,\n  videoModel,\n  audioModel,\n  capabilityOverrides,\n  videoRatio,\n  ttsRate,\n  onUpdateConfig,\n  globalAssetText,\n  projectName,\n  episodes,\n  currentEpisodeId,\n  onEpisodeSelect,\n  onEpisodeCreate,\n  onEpisodeRename,\n  onEpisodeDelete,\n  capsuleNavItems,\n  currentStage,\n  onStageChange,\n  projectId,\n  episodeId,\n  onOpenAssetLibrary,\n  onOpenSettingsModal,\n  onRefresh,\n  assetLibraryLabel,\n  settingsLabel,\n  refreshTitle,\n}: WorkspaceHeaderShellProps) {\n  return (\n    <>\n      <SettingsModal\n        isOpen={isSettingsModalOpen}\n        onClose={onCloseSettingsModal}\n        availableModels={availableModels}\n        modelsLoaded={modelsLoaded}\n        artStyle={artStyle ?? undefined}\n        analysisModel={analysisModel ?? undefined}\n        characterModel={characterModel ?? undefined}\n        locationModel={locationModel ?? undefined}\n        imageModel={storyboardModel ?? undefined}\n        editModel={editModel ?? undefined}\n        videoModel={videoModel ?? undefined}\n        audioModel={audioModel ?? undefined}\n        videoRatio={videoRatio ?? undefined}\n        capabilityOverrides={capabilityOverrides}\n        ttsRate={ttsRate ?? undefined}\n        onArtStyleChange={(value) => { onUpdateConfig('artStyle', value) }}\n        onAnalysisModelChange={(value) => { onUpdateConfig('analysisModel', value) }}\n        onCharacterModelChange={(value) => { onUpdateConfig('characterModel', value) }}\n        onLocationModelChange={(value) => { onUpdateConfig('locationModel', value) }}\n        onImageModelChange={(value) => { onUpdateConfig('storyboardModel', value) }}\n        onEditModelChange={(value) => { onUpdateConfig('editModel', value) }}\n        onVideoModelChange={(value) => { onUpdateConfig('videoModel', value) }}\n        onAudioModelChange={(value) => { onUpdateConfig('audioModel', value) }}\n        onVideoRatioChange={(value) => { onUpdateConfig('videoRatio', value) }}\n        onCapabilityOverridesChange={(value) => { onUpdateConfig('capabilityOverrides', value) }}\n        onTTSRateChange={(value) => { onUpdateConfig('ttsRate', value) }}\n      />\n\n      <WorldContextModal\n        isOpen={isWorldContextModalOpen}\n        onClose={onCloseWorldContextModal}\n        text={globalAssetText}\n        onChange={(value) => { onUpdateConfig('globalAssetText', value) }}\n      />\n      {episodes.length > 0 && currentEpisodeId && (() => {\n        const getNum = (name: string) => { const m = name.match(/\\d+/); return m ? parseInt(m[0], 10) : Infinity }\n        const sorted = [...episodes].sort((a, b) => {\n          const d = getNum(a.name) - getNum(b.name)\n          return d !== 0 ? d : a.name.localeCompare(b.name, 'zh')\n        })\n        return (\n          <EpisodeSelector\n            projectName={projectName}\n            episodes={sorted.map((ep) => ({\n              id: ep.id,\n              title: ep.name,\n              summary: ep.description ?? undefined,\n              status: {\n                script: ep.clips?.length ? 'ready' as const : 'empty' as const,\n                visual: ep.storyboards?.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' as const : 'empty' as const,\n              },\n            }))}\n            currentId={currentEpisodeId}\n            onSelect={(id) => onEpisodeSelect?.(id)}\n            onAdd={onEpisodeCreate}\n            onRename={(id, newName) => onEpisodeRename?.(id, newName)}\n            onDelete={onEpisodeDelete}\n          />\n        )\n      })()}\n\n\n\n      <CapsuleNav\n        items={capsuleNavItems}\n        activeId={currentStage}\n        onItemClick={onStageChange}\n        projectId={projectId}\n        episodeId={episodeId}\n      />\n\n      <WorkspaceTopActions\n        onOpenAssetLibrary={onOpenAssetLibrary}\n        onOpenSettings={onOpenSettingsModal}\n        onRefresh={onRefresh}\n        assetLibraryLabel={assetLibraryLabel}\n        settingsLabel={settingsLabel}\n        refreshTitle={refreshTitle}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceRunStreamConsoles.tsx",
    "content": "'use client'\n\nimport LLMStageStreamCard, { type LLMStageViewItem } from '@/components/llm-console/LLMStageStreamCard'\nimport { useTranslations } from 'next-intl'\n\ntype RunStreamStep = {\n  id: string\n  title?: string\n  status?: 'pending' | 'running' | 'completed' | 'failed' | 'blocked' | 'stale'\n  retryable?: boolean\n}\n\ntype RunStreamState = {\n  status?: 'idle' | 'running' | 'completed' | 'failed'\n  isVisible: boolean\n  isRecoveredRunning: boolean\n  stages: LLMStageViewItem[]\n  selectedStep?: RunStreamStep | null\n  activeStepId?: string | null\n  outputText: string\n  activeMessage?: string\n  overallProgress: number\n  isRunning: boolean\n  errorMessage?: string\n  stop: () => void\n  reset: () => void\n  selectStep: (stepId: string) => void\n  retryStep: (params: { stepId: string; modelOverride?: string; reason?: string }) => Promise<{\n    runId: string\n    status: string\n    summary: Record<string, unknown> | null\n    payload: Record<string, unknown> | null\n    errorMessage: string\n  }>\n}\n\ninterface WorkspaceRunStreamConsolesProps {\n  storyToScriptStream: RunStreamState\n  scriptToStoryboardStream: RunStreamState\n  storyToScriptConsoleMinimized: boolean\n  scriptToStoryboardConsoleMinimized: boolean\n  onStoryToScriptMinimizedChange: (next: boolean) => void\n  onScriptToStoryboardMinimizedChange: (next: boolean) => void\n  hideMinimizedBadges?: boolean\n}\n\nexport default function WorkspaceRunStreamConsoles({\n  storyToScriptStream,\n  scriptToStoryboardStream,\n  storyToScriptConsoleMinimized,\n  scriptToStoryboardConsoleMinimized,\n  onStoryToScriptMinimizedChange,\n  onScriptToStoryboardMinimizedChange,\n  hideMinimizedBadges,\n}: WorkspaceRunStreamConsolesProps) {\n  const t = useTranslations('progress')\n  const storyToScriptActive =\n    storyToScriptStream.isRunning ||\n    storyToScriptStream.isRecoveredRunning ||\n    storyToScriptStream.status === 'running'\n  const scriptToStoryboardActive =\n    scriptToStoryboardStream.isRunning ||\n    scriptToStoryboardStream.isRecoveredRunning ||\n    scriptToStoryboardStream.status === 'running'\n\n  const showStoryToScriptConsole =\n    storyToScriptStream.isVisible &&\n    (storyToScriptStream.stages.length > 0 || !!storyToScriptStream.errorMessage)\n  const storyFallbackStatus: LLMStageViewItem['status'] =\n    storyToScriptStream.status === 'failed' ? 'failed' : 'processing'\n  const storyToScriptStages = storyToScriptStream.stages.length > 0\n    ? storyToScriptStream.stages\n    : [{\n      id: 'story_to_script_run',\n      title: t('runConsole.storyToScript'),\n      status: storyFallbackStatus,\n      progress: 0,\n      subtitle: storyToScriptStream.errorMessage || undefined,\n    }]\n  const storyToScriptActiveStage = storyToScriptStream.activeStepId\n    ? storyToScriptStages.find((stage) => stage.id === storyToScriptStream.activeStepId) || null\n    : null\n  const storyToScriptCardTitle =\n    storyToScriptActiveStage?.title ||\n    t('runConsole.storyToScript')\n  const storyToScriptSelectedStageId =\n    storyToScriptStream.selectedStep?.id || storyToScriptStream.activeStepId || null\n  const storyToScriptSelectedStage = storyToScriptSelectedStageId\n    ? storyToScriptStages.find((stage) => stage.id === storyToScriptSelectedStageId) || null\n    : null\n  const storyToScriptShowCursor =\n    storyToScriptStream.isRunning &&\n    storyToScriptStream.selectedStep?.id === storyToScriptStream.activeStepId &&\n    storyToScriptSelectedStage?.status === 'processing'\n  const showScriptToStoryboardConsole =\n    scriptToStoryboardStream.isVisible &&\n    (scriptToStoryboardStream.stages.length > 0 || !!scriptToStoryboardStream.errorMessage)\n  const storyboardFallbackStatus: LLMStageViewItem['status'] =\n    scriptToStoryboardStream.status === 'failed' ? 'failed' : 'processing'\n  const scriptToStoryboardStages = scriptToStoryboardStream.stages.length > 0\n    ? scriptToStoryboardStream.stages\n    : [{\n      id: 'script_to_storyboard_run',\n      title: t('runConsole.scriptToStoryboard'),\n      status: storyboardFallbackStatus,\n      progress: 0,\n      subtitle: scriptToStoryboardStream.errorMessage || undefined,\n    }]\n  const scriptToStoryboardActiveStage = scriptToStoryboardStream.activeStepId\n    ? scriptToStoryboardStages.find((stage) => stage.id === scriptToStoryboardStream.activeStepId) || null\n    : null\n  const scriptToStoryboardCardTitle =\n    scriptToStoryboardActiveStage?.title ||\n    t('runConsole.scriptToStoryboard')\n  const scriptToStoryboardSelectedStageId =\n    scriptToStoryboardStream.selectedStep?.id || scriptToStoryboardStream.activeStepId || null\n  const scriptToStoryboardSelectedStage = scriptToStoryboardSelectedStageId\n    ? scriptToStoryboardStages.find((stage) => stage.id === scriptToStoryboardSelectedStageId) || null\n    : null\n  const scriptToStoryboardShowCursor =\n    scriptToStoryboardStream.isRunning &&\n    scriptToStoryboardStream.selectedStep?.id === scriptToStoryboardStream.activeStepId &&\n    scriptToStoryboardSelectedStage?.status === 'processing'\n\n  const handleRetryStepById = async (\n    stream: RunStreamState,\n    stepId: string,\n  ) => {\n    const input = typeof window !== 'undefined'\n      ? window.prompt('可选：输入重试模型（留空使用当前模型）')\n      : null\n    const modelOverride = typeof input === 'string' ? input.trim() : ''\n    await stream.retryStep({\n      stepId,\n      modelOverride: modelOverride || undefined,\n      reason: 'user_retry_from_console',\n    })\n  }\n\n  return (\n    <>\n      {!hideMinimizedBadges && showStoryToScriptConsole && storyToScriptConsoleMinimized && storyToScriptActive && (\n        <button\n          type=\"button\"\n          onClick={() => onStoryToScriptMinimizedChange(false)}\n          className=\"fixed right-6 bottom-6 z-120 glass-surface-modal rounded-2xl px-4 py-3 text-sm font-medium text-(--glass-tone-info-fg)\"\n        >\n          {t('runConsole.storyToScriptRunning')}\n        </button>\n      )}\n\n      {showStoryToScriptConsole && !storyToScriptConsoleMinimized && (\n        <div className=\"fixed inset-0 z-120 glass-overlay backdrop-blur-sm\">\n          <div className=\"mx-auto mt-4 h-[calc(100vh-2rem)] w-[min(96vw,1400px)]\">\n            <LLMStageStreamCard\n              title={storyToScriptCardTitle}\n              subtitle={t('runConsole.storyToScriptSubtitle')}\n              stages={storyToScriptStages}\n              activeStageId={storyToScriptStream.activeStepId || storyToScriptStages[storyToScriptStages.length - 1]?.id || ''}\n              selectedStageId={storyToScriptStream.selectedStep?.id || undefined}\n              onSelectStage={storyToScriptStream.selectStep}\n              onRetryStage={(stepId) => {\n                void handleRetryStepById(storyToScriptStream, stepId)\n              }}\n              outputText={storyToScriptStream.outputText}\n              activeMessage={storyToScriptStream.activeMessage}\n              overallProgress={storyToScriptStream.overallProgress}\n              showCursor={storyToScriptShowCursor}\n              autoScroll={storyToScriptStream.selectedStep?.id === storyToScriptStream.activeStepId}\n              errorMessage={storyToScriptStream.errorMessage}\n              topRightAction={(\n                <div className=\"flex items-center gap-2\">\n                  <button\n                    type=\"button\"\n                    onClick={storyToScriptStream.reset}\n                    className=\"glass-btn-base glass-btn-secondary rounded-lg px-3 py-1.5 text-xs\"\n                  >\n                    {t('runConsole.stop')}\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={() => onStoryToScriptMinimizedChange(true)}\n                    className=\"glass-btn-base glass-btn-secondary rounded-lg px-3 py-1.5 text-xs\"\n                  >\n                    {t('runConsole.minimize')}\n                  </button>\n                </div>\n              )}\n            />\n          </div>\n        </div>\n      )}\n\n      {!hideMinimizedBadges && showScriptToStoryboardConsole && scriptToStoryboardConsoleMinimized && scriptToStoryboardActive && (\n        <button\n          type=\"button\"\n          onClick={() => onScriptToStoryboardMinimizedChange(false)}\n          className=\"fixed right-6 bottom-20 z-120 glass-surface-modal rounded-2xl px-4 py-3 text-sm font-medium text-(--glass-tone-info-fg)\"\n        >\n          {t('runConsole.scriptToStoryboardRunning')}\n        </button>\n      )}\n\n      {showScriptToStoryboardConsole && !scriptToStoryboardConsoleMinimized && (\n        <div className=\"fixed inset-0 z-120 glass-overlay backdrop-blur-sm\">\n          <div className=\"mx-auto mt-4 h-[calc(100vh-2rem)] w-[min(96vw,1400px)]\">\n            <LLMStageStreamCard\n              title={scriptToStoryboardCardTitle}\n              subtitle={t('runConsole.scriptToStoryboardSubtitle')}\n              stages={scriptToStoryboardStages}\n              activeStageId={scriptToStoryboardStream.activeStepId || scriptToStoryboardStages[scriptToStoryboardStages.length - 1]?.id || ''}\n              selectedStageId={scriptToStoryboardStream.selectedStep?.id || undefined}\n              onSelectStage={scriptToStoryboardStream.selectStep}\n              onRetryStage={(stepId) => {\n                void handleRetryStepById(scriptToStoryboardStream, stepId)\n              }}\n              outputText={scriptToStoryboardStream.outputText}\n              activeMessage={scriptToStoryboardStream.activeMessage}\n              overallProgress={scriptToStoryboardStream.overallProgress}\n              showCursor={scriptToStoryboardShowCursor}\n              autoScroll={scriptToStoryboardStream.selectedStep?.id === scriptToStoryboardStream.activeStepId}\n              errorMessage={scriptToStoryboardStream.errorMessage}\n              topRightAction={(\n                <div className=\"flex items-center gap-2\">\n                  <button\n                    type=\"button\"\n                    onClick={scriptToStoryboardStream.reset}\n                    className=\"glass-btn-base glass-btn-secondary rounded-lg px-3 py-1.5 text-xs\"\n                  >\n                    {t('runConsole.stop')}\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={() => onScriptToStoryboardMinimizedChange(true)}\n                    className=\"glass-btn-base glass-btn-secondary rounded-lg px-3 py-1.5 text-xs\"\n                  >\n                    {t('runConsole.minimize')}\n                  </button>\n                </div>\n              )}\n            />\n          </div>\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceStageContent.tsx",
    "content": "'use client'\n\nimport ConfigStage from './ConfigStage'\nimport ScriptStage from './ScriptStage'\nimport StoryboardStage from './StoryboardStage'\nimport VideoStageRoute from './VideoStageRoute'\nimport VoiceStageRoute from './VoiceStageRoute'\n\ninterface WorkspaceStageContentProps {\n  currentStage: string\n}\n\nexport default function WorkspaceStageContent({\n  currentStage,\n}: WorkspaceStageContentProps) {\n  return (\n    <div key={currentStage} className=\"animate-page-enter\">\n      {currentStage === 'config' && <ConfigStage />}\n\n      {(currentStage === 'script' || currentStage === 'assets') && <ScriptStage />}\n\n      {currentStage === 'storyboard' && <StoryboardStage />}\n\n      {currentStage === 'videos' && <VideoStageRoute />}\n\n      {currentStage === 'voice' && <VoiceStageRoute />}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceTopActions.tsx",
    "content": "'use client'\n\nimport { useCallback, useState } from 'react'\nimport { AppIcon } from '@/components/ui/icons'\nimport { useToast } from '@/contexts/ToastContext'\n\ninterface WorkspaceTopActionsProps {\n  onOpenAssetLibrary: () => void\n  onOpenSettings: () => void\n  onRefresh: () => Promise<void> | void\n  assetLibraryLabel: string\n  settingsLabel: string\n  refreshTitle: string\n}\n\nexport default function WorkspaceTopActions({\n  onOpenAssetLibrary,\n  onOpenSettings,\n  onRefresh,\n  assetLibraryLabel,\n  settingsLabel,\n  refreshTitle,\n}: WorkspaceTopActionsProps) {\n  const [isRefreshing, setIsRefreshing] = useState(false)\n  const { showToast } = useToast()\n\n  const handleRefreshClick = useCallback(async () => {\n    if (isRefreshing) {\n      return\n    }\n\n    try {\n      setIsRefreshing(true)\n      await Promise.resolve(onRefresh())\n      showToast(refreshTitle, 'success', 2400)\n    } catch (error) {\n      // 显式记录错误，保持“显式失败”原则，但不打断用户操作\n      // eslint-disable-next-line no-console\n      console.error('[WorkspaceTopActions] 刷新失败', error)\n    } finally {\n      setIsRefreshing(false)\n    }\n  }, [isRefreshing, onRefresh, refreshTitle, showToast])\n\n  return (\n    <div className=\"fixed top-24 right-6 z-40 flex gap-3\">\n      <button\n        onClick={onOpenAssetLibrary}\n        className=\"glass-btn-base glass-btn-secondary flex items-center gap-2 px-4 py-3 rounded-3xl text-[var(--glass-text-primary)]\"\n      >\n        <AppIcon name=\"package\" className=\"h-5 w-5\" />\n        <span className=\"font-semibold text-sm hidden md:inline tracking-[0.01em]\">{assetLibraryLabel}</span>\n      </button>\n      <button\n        onClick={onOpenSettings}\n        className=\"glass-btn-base glass-btn-secondary flex items-center gap-2 px-4 py-3 rounded-3xl text-[var(--glass-text-primary)]\"\n      >\n        <AppIcon name=\"settingsHexMinor\" className=\"h-5 w-5\" />\n        <span className=\"font-semibold text-sm hidden md:inline tracking-[0.01em]\">{settingsLabel}</span>\n      </button>\n      <button\n        onClick={handleRefreshClick}\n        className={`glass-btn-base glass-btn-secondary flex items-center gap-2 px-4 py-3 rounded-3xl text-[var(--glass-text-primary)] ${\n          isRefreshing ? 'opacity-60 cursor-wait' : ''\n        }`}\n        title={refreshTitle}\n        disabled={isRefreshing}\n      >\n        <AppIcon name=\"refresh\" className={`w-5 h-5 ${isRefreshing ? 'animate-spin' : ''}`} />\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AddLocationModal.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { ART_STYLES } from '@/lib/constants'\nimport { shouldShowError } from '@/lib/error-utils'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\nimport { useAiCreateProjectLocation, useCreateProjectLocation } from '@/lib/query/hooks'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface AddLocationModalProps {\n  projectId: string\n  onClose: () => void\n  onSuccess: () => void\n}\n\nfunction getErrorMessage(error: unknown, fallback: string): string {\n  if (error instanceof Error && error.message) return error.message\n  if (typeof error === 'object' && error !== null) {\n    const message = (error as { message?: unknown }).message\n    if (typeof message === 'string') return message\n  }\n  return fallback\n}\n\nfunction getErrorStatus(error: unknown): number | null {\n  if (typeof error === 'object' && error !== null) {\n    const status = (error as { status?: unknown }).status\n    if (typeof status === 'number') return status\n  }\n  return null\n}\n\n// 内联 SVG 图标\nconst XMarkIcon = ({ className }: { className?: string }) => (\n  <AppIcon name=\"close\" className={className} />\n)\n\nconst SparklesIcon = ({ className }: { className?: string }) => (\n  <AppIcon name=\"sparklesAlt\" className={className} />\n)\n\nexport default function AddLocationModal({\n  projectId,\n  onClose,\n  onSuccess\n}: AddLocationModalProps) {\n  const t = useTranslations('assets')\n  const tc = useTranslations('common')\n  const aiCreateLocationMutation = useAiCreateProjectLocation(projectId)\n  const createLocationMutation = useCreateProjectLocation(projectId)\n  const { count: locationGenerationCount } = useImageGenerationCount('location')\n\n  const [name, setName] = useState('')\n  const [description, setDescription] = useState('')\n  const [aiInstruction, setAiInstruction] = useState('')\n  const [artStyle, setArtStyle] = useState('american-comic')\n  const [isSubmitting, setIsSubmitting] = useState(false)\n  const [isAiDesigning, setIsAiDesigning] = useState(false)\n  const aiDesigningState = isAiDesigning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'image',\n      hasOutput: false,\n    })\n    : null\n  const submitState = isSubmitting\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'build',\n      resource: 'image',\n      hasOutput: false,\n    })\n    : null\n\n  // AI 设计描述\n  const handleAiDesign = async () => {\n    if (!aiInstruction.trim()) return\n\n    try {\n      setIsAiDesigning(true)\n      const data = await aiCreateLocationMutation.mutateAsync({\n        userInstruction: aiInstruction,\n      })\n      setDescription(data.prompt || '')\n      setAiInstruction('')\n    } catch (error: unknown) {\n      if (getErrorStatus(error) === 402) {\n        alert(getErrorMessage(error, tc('insufficientBalanceDetail')))\n      } else {\n        _ulogError('AI设计失败:', error)\n        if (shouldShowError(error)) {\n          alert(getErrorMessage(error, t('errors.aiDesignFailed')))\n        }\n      }\n    } finally {\n      setIsAiDesigning(false)\n    }\n  }\n\n  // 提交创建\n  const handleSubmit = async () => {\n    if (!name.trim() || !description.trim()) return\n\n    try {\n      setIsSubmitting(true)\n      await createLocationMutation.mutateAsync({\n        name: name.trim(),\n        description: description.trim(),\n        artStyle,\n        count: locationGenerationCount,\n      })\n      onSuccess()\n      onClose()\n    } catch (error: unknown) {\n      if (getErrorStatus(error) === 402) {\n        alert(getErrorMessage(error, tc('insufficientBalanceDetail')))\n      } else if (shouldShowError(error)) {\n        alert(getErrorMessage(error, t('errors.createFailed')))\n      }\n    } finally {\n      setIsSubmitting(false)\n    }\n  }\n\n  return (\n    <div className=\"fixed inset-0 bg-[var(--glass-overlay)] flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-[var(--glass-bg-surface)] rounded-xl shadow-xl max-w-2xl w-full max-h-[85vh] overflow-y-auto\">\n        <div className=\"p-6\">\n          {/* 标题 */}\n          <div className=\"flex items-center justify-between mb-6\">\n            <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n              {t('modal.addLocation')}\n            </h3>\n            <button\n              onClick={onClose}\n              className=\"w-8 h-8 rounded-full hover:bg-[var(--glass-bg-muted)] flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors\"\n            >\n              <XMarkIcon className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          <div className=\"space-y-5\">\n            {/* 场景名称 */}\n            <div className=\"space-y-2\">\n              <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)]\">\n                {t('location.name')} <span className=\"text-[var(--glass-tone-danger-fg)]\">*</span>\n              </label>\n              <input\n                type=\"text\"\n                value={name}\n                onChange={(e) => setName(e.target.value)}\n                placeholder={t('modal.namePlaceholder')}\n                className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n              />\n            </div>\n\n            {/* 风格选择 */}\n            <div className=\"space-y-2\">\n              <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)]\">\n                {t('modal.artStyle')}\n              </label>\n              <div className=\"grid grid-cols-2 gap-2\">\n                {ART_STYLES.map((style) => (\n                  <button\n                    key={style.value}\n                    type=\"button\"\n                    onClick={() => setArtStyle(style.value)}\n                    className={`px-3 py-2 rounded-lg text-sm border transition-all flex items-center ${artStyle === style.value\n                      ? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'\n                      : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-strong)] text-[var(--glass-text-secondary)]'\n                      }`}\n                  >\n                    <span>{style.label}</span>\n                  </button>\n                ))}\n              </div>\n            </div>\n\n            {/* AI 设计区域 */}\n            <div className=\"bg-[var(--glass-tone-info-bg)] rounded-xl p-4 space-y-3 border border-[var(--glass-stroke-focus)]\">\n              <div className=\"flex items-center gap-2 text-sm font-medium text-[var(--glass-tone-info-fg)]\">\n                <SparklesIcon className=\"w-4 h-4\" />\n                <span>{t('modal.aiDesign')}{tc('optional')}</span>\n              </div>\n              <div className=\"flex gap-2\">\n                <input\n                  type=\"text\"\n                  value={aiInstruction}\n                  onChange={(e) => setAiInstruction(e.target.value)}\n                  placeholder={t('modal.aiDesignPlaceholderLocation')}\n                  className=\"flex-1 px-3 py-2 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-focus)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n                  disabled={isAiDesigning}\n                  onKeyDown={(e) => {\n                    if (e.key === 'Enter' && !e.shiftKey) {\n                      e.preventDefault()\n                      handleAiDesign()\n                    }\n                  }}\n                />\n                <button\n                  onClick={handleAiDesign}\n                  disabled={isAiDesigning || !aiInstruction.trim()}\n                  className=\"px-4 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm whitespace-nowrap\"\n                >\n                  {isAiDesigning ? (\n                    <TaskStatusInline state={aiDesigningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                  ) : (\n                    <>\n                      <SparklesIcon className=\"w-4 h-4\" />\n                      <span>{t('modal.generate')}</span>\n                    </>\n                  )}\n                </button>\n              </div>\n              <p className=\"text-xs text-[var(--glass-tone-info-fg)]\">\n                {t('modal.aiDesignTip')}\n              </p>\n            </div>\n\n            {/* 场景描述 */}\n            <div className=\"space-y-2\">\n              <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)]\">\n                {t('location.description')} <span className=\"text-[var(--glass-tone-danger-fg)]\">*</span>\n              </label>\n              <textarea\n                value={description}\n                onChange={(e) => setDescription(e.target.value)}\n                placeholder={t('modal.descPlaceholder')}\n                className=\"w-full h-36 px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] resize-none\"\n                disabled={isAiDesigning}\n              />\n            </div>\n          </div>\n\n          {/* 按钮区 */}\n          <div className=\"flex gap-3 justify-end mt-6 pt-4 border-t border-[var(--glass-stroke-base)]\">\n            <button\n              onClick={onClose}\n              className=\"px-4 py-2 text-[var(--glass-text-secondary)] bg-[var(--glass-bg-muted)] rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors text-sm\"\n              disabled={isSubmitting}\n            >\n              {t('common.cancel')}\n            </button>\n            <button\n              onClick={handleSubmit}\n              disabled={isSubmitting || !name.trim() || !description.trim()}\n              className=\"px-4 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm flex items-center gap-2\"\n            >\n              {isSubmitting ? (\n                <TaskStatusInline state={submitState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n              ) : (\n                <span>{t('location.add')}</span>\n              )}\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar.tsx",
    "content": "'use client'\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { useRefreshProjectAssets, useProjectAssets, useProjectData } from '@/lib/query/hooks'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\nimport JSZip from 'jszip'\nimport { logError as _logError } from '@/lib/logging/core'\n\n/**\n * AssetToolbar - 资产管理工具栏组件\n * 从 AssetsStage.tsx 提取，负责批量操作和刷新按钮\n */\n\ninterface AssetToolbarProps {\n    projectId: string\n    totalAssets: number\n    totalAppearances: number\n    totalLocations: number\n    isBatchSubmitting: boolean\n    isAnalyzingAssets: boolean\n    isGlobalAnalyzing?: boolean\n    batchProgress: { current: number; total: number }\n    onGenerateAll: () => void\n    onRegenerateAll: () => void\n    onGlobalAnalyze?: () => void\n}\n\nexport default function AssetToolbar({\n    projectId,\n    totalAssets,\n    totalAppearances,\n    totalLocations,\n    isBatchSubmitting,\n    isAnalyzingAssets,\n    isGlobalAnalyzing = false,\n    batchProgress,\n    onGenerateAll,\n    onRegenerateAll,\n    onGlobalAnalyze\n}: AssetToolbarProps) {\n    const onRefresh = useRefreshProjectAssets(projectId)\n    const t = useTranslations('assets')\n    const { data: assets } = useProjectAssets(projectId)\n    const { data: projectData } = useProjectData(projectId)\n    const projectName = projectData?.name\n    const [isDownloading, setIsDownloading] = useState(false)\n\n    const assetTaskRunningState = isBatchSubmitting\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'image',\n            hasOutput: true,\n        })\n        : null\n\n    const handleDownloadAll = async () => {\n        const characters = assets?.characters ?? []\n        const locations = assets?.locations ?? []\n\n        const imageEntries: Array<{ filename: string; url: string }> = []\n\n        // 角色图片\n        for (const character of characters) {\n            for (const appearance of character.appearances ?? []) {\n                const url = appearance.imageUrl\n                if (!url) continue\n                const safeName = character.name.replace(/[/\\\\:*?\"<>|]/g, '_')\n                const filename = appearance.appearanceIndex === 0\n                    ? `characters/${safeName}.jpg`\n                    : `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`\n                imageEntries.push({ filename, url })\n            }\n        }\n\n        // 场景图片：取已选中的那张（或第一张）\n        for (const location of locations) {\n            const selectedImage = location.images?.find((img: { isSelected: boolean; imageUrl: string | null }) => img.isSelected) ?? location.images?.[0]\n            const url = selectedImage?.imageUrl\n            if (!url) continue\n            const safeName = location.name.replace(/[/\\\\:*?\"<>|]/g, '_')\n            imageEntries.push({ filename: `locations/${safeName}.jpg`, url })\n        }\n\n        if (imageEntries.length === 0) {\n            alert(t('assetLibrary.downloadEmpty'))\n            return\n        }\n\n        setIsDownloading(true)\n        try {\n            const zip = new JSZip()\n            await Promise.all(\n                imageEntries.map(async ({ filename, url }) => {\n                    try {\n                        const response = await fetch(url)\n                        if (!response.ok) return\n                        const blob = await response.blob()\n                        zip.file(filename, blob)\n                    } catch {\n                        // 单张失败不阻断其他\n                    }\n                })\n            )\n            const content = await zip.generateAsync({ type: 'blob' })\n            const link = document.createElement('a')\n            link.href = URL.createObjectURL(content)\n            const safeName = projectName ? projectName.replace(/[/\\\\:*?\"<>|]/g, '_') : 'assets'\n            link.download = `${safeName}_${new Date().toISOString().slice(0, 10)}.zip`\n            document.body.appendChild(link)\n            link.click()\n            document.body.removeChild(link)\n            URL.revokeObjectURL(link.href)\n        } catch (error) {\n            _logError('打包下载失败:', error)\n            alert(t('assetLibrary.downloadFailed'))\n        } finally {\n            setIsDownloading(false)\n        }\n    }\n\n    return (\n        <div className=\"glass-surface p-4\">\n            <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-4\">\n                    <span className=\"text-sm font-semibold text-[var(--glass-text-secondary)] inline-flex items-center gap-2\">\n                        <AppIcon name=\"diamond\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n                        {t(\"toolbar.assetManagement\")}\n                    </span>\n                    <span className=\"text-sm text-[var(--glass-text-tertiary)]\">\n                        {t(\"toolbar.assetCount\", { total: totalAssets, appearances: totalAppearances, locations: totalLocations })}\n                    </span>\n                    {/* 全局资产分析按钮 */}\n                    {onGlobalAnalyze && (\n                        <button\n                            onClick={onGlobalAnalyze}\n                            disabled={isGlobalAnalyzing || isBatchSubmitting || isAnalyzingAssets}\n                            className=\"glass-btn-base glass-btn-primary flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n                            title={t(\"toolbar.globalAnalyzeHint\")}\n                        >\n                            <AppIcon name=\"idea\" className=\"w-3.5 h-3.5\" />\n                            <span>{t(\"toolbar.globalAnalyze\")}</span>\n                        </button>\n                    )}\n                </div>\n                <div className=\"flex items-center gap-2\">\n                    <button\n                        onClick={onGenerateAll}\n                        disabled={isBatchSubmitting || isAnalyzingAssets || isGlobalAnalyzing}\n                        className=\"glass-btn-base glass-btn-tone-success flex items-center gap-2 px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n                    >\n                        {isBatchSubmitting ? (\n                            <>\n                                <TaskStatusInline state={assetTaskRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                                <span className=\"text-xs text-white/90\">({batchProgress.current}/{batchProgress.total})</span>\n                            </>\n                        ) : (\n                            <>\n                                <AppIcon name=\"image\" className=\"w-4 h-4\" />\n                                <span>{t(\"toolbar.generateAll\")}</span>\n                            </>\n                        )}\n                    </button>\n                    <button\n                        onClick={onRegenerateAll}\n                        disabled={isBatchSubmitting || isAnalyzingAssets || isGlobalAnalyzing}\n                        className=\"glass-btn-base glass-btn-tone-warning flex items-center gap-2 px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n                        title={t(\"toolbar.regenerateAllHint\")}\n                    >\n                        <AppIcon name=\"refresh\" className=\"w-4 h-4\" />\n                        <span>{t(\"toolbar.regenerateAll\")}</span>\n                    </button>\n                    <button\n                        onClick={() => onRefresh()}\n                        className=\"glass-btn-base glass-btn-secondary flex items-center gap-2 px-4 py-2 text-sm font-medium border border-[var(--glass-stroke-base)]\"\n                    >\n                        <AppIcon name=\"refresh\" className=\"w-4 h-4\" />\n                        <span>{t(\"common.refresh\")}</span>\n                    </button>\n                    {/* 打包下载按钮 */}\n                    <button\n                        onClick={handleDownloadAll}\n                        disabled={isDownloading || totalAssets === 0}\n                        title={t(\"toolbar.downloadAll\")}\n                        className=\"glass-btn-base glass-btn-secondary flex items-center justify-center w-9 h-9 disabled:opacity-50 disabled:cursor-not-allowed border border-[var(--glass-stroke-base)]\"\n                    >\n                        <AppIcon\n                            name={isDownloading ? 'refresh' : 'download'}\n                            className={`w-4 h-4 ${isDownloading ? 'animate-spin' : ''}`}\n                        />\n                    </button>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetsStageModals.tsx",
    "content": "'use client'\n\nimport ImagePreviewModal from '@/components/ui/ImagePreviewModal'\nimport ImageEditModal from './ImageEditModal'\nimport VoiceDesignDialog from '../voice/VoiceDesignDialog'\nimport CharacterProfileDialog from './CharacterProfileDialog'\nimport {\n  CharacterCreationModal,\n  CharacterEditModal,\n  LocationCreationModal,\n  LocationEditModal,\n} from '@/components/shared/assets'\nimport GlobalAssetPicker from '@/components/shared/assets/GlobalAssetPicker'\nimport type { CharacterProfileData } from '@/types/character-profile'\nimport type { GlobalCopyTarget } from './hooks/useAssetsCopyFromHub'\n\ninterface EditingAppearanceState {\n  characterId: string\n  characterName: string\n  appearanceId: string\n  description: string\n  descriptionIndex?: number\n  introduction?: string | null\n}\n\ninterface EditingLocationState {\n  locationId: string\n  locationName: string\n  description: string\n}\n\ninterface LocationImageEditModalState {\n  locationName: string\n}\n\ninterface CharacterImageEditModalState {\n  characterName: string\n}\n\ninterface VoiceDesignCharacterState {\n  name: string\n  hasExistingVoice: boolean\n}\n\ninterface EditingProfileState {\n  characterId: string\n  characterName: string\n  profileData: CharacterProfileData\n}\n\ninterface AssetsStageModalsProps {\n  projectId: string\n  onRefresh: () => void\n  onClosePreview: () => void\n  handleGenerateImage: (type: 'character' | 'location', id: string, appearanceId?: string) => Promise<void>\n  handleUpdateAppearanceDescription: (newDescription: string) => Promise<void>\n  handleUpdateLocationDescription: (newDescription: string) => Promise<void>\n  handleLocationImageEdit: (modifyPrompt: string, extraImageUrls?: string[]) => Promise<void>\n  handleCharacterImageEdit: (modifyPrompt: string, extraImageUrls?: string[]) => Promise<void>\n  handleCloseVoiceDesign: () => void\n  handleVoiceDesignSave: (voiceId: string, audioBase64: string) => Promise<void>\n  handleCloseCopyPicker: () => void\n  handleConfirmCopyFromGlobal: (globalAssetId: string) => Promise<void>\n  handleConfirmProfile: (characterId: string, updatedProfileData?: CharacterProfileData) => Promise<void>\n  closeEditingAppearance: () => void\n  closeEditingLocation: () => void\n  closeAddCharacter: () => void\n  closeAddLocation: () => void\n  closeImageEditModal: () => void\n  closeCharacterImageEditModal: () => void\n  isConfirmingCharacter: (characterId: string) => boolean\n  setEditingProfile: (value: EditingProfileState | null) => void\n  previewImage: string | null\n  imageEditModal: LocationImageEditModalState | null\n  characterImageEditModal: CharacterImageEditModalState | null\n  editingAppearance: EditingAppearanceState | null\n  editingLocation: EditingLocationState | null\n  showAddCharacter: boolean\n  showAddLocation: boolean\n  voiceDesignCharacter: VoiceDesignCharacterState | null\n  editingProfile: EditingProfileState | null\n  copyFromGlobalTarget: GlobalCopyTarget | null\n  isGlobalCopyInFlight: boolean\n}\n\nexport default function AssetsStageModals({\n  projectId,\n  onRefresh,\n  onClosePreview,\n  handleGenerateImage,\n  handleUpdateAppearanceDescription,\n  handleUpdateLocationDescription,\n  handleLocationImageEdit,\n  handleCharacterImageEdit,\n  handleCloseVoiceDesign,\n  handleVoiceDesignSave,\n  handleCloseCopyPicker,\n  handleConfirmCopyFromGlobal,\n  handleConfirmProfile,\n  closeEditingAppearance,\n  closeEditingLocation,\n  closeAddCharacter,\n  closeAddLocation,\n  closeImageEditModal,\n  closeCharacterImageEditModal,\n  isConfirmingCharacter,\n  setEditingProfile,\n  previewImage,\n  imageEditModal,\n  characterImageEditModal,\n  editingAppearance,\n  editingLocation,\n  showAddCharacter,\n  showAddLocation,\n  voiceDesignCharacter,\n  editingProfile,\n  copyFromGlobalTarget,\n  isGlobalCopyInFlight,\n}: AssetsStageModalsProps) {\n  return (\n    <>\n      {previewImage && <ImagePreviewModal imageUrl={previewImage} onClose={onClosePreview} />}\n\n      {imageEditModal && (\n        <ImageEditModal\n          type=\"location\"\n          name={imageEditModal.locationName}\n          onClose={closeImageEditModal}\n          onConfirm={handleLocationImageEdit}\n        />\n      )}\n\n      {characterImageEditModal && (\n        <ImageEditModal\n          type=\"character\"\n          name={characterImageEditModal.characterName}\n          onClose={closeCharacterImageEditModal}\n          onConfirm={handleCharacterImageEdit}\n        />\n      )}\n\n      {editingAppearance && (\n        <CharacterEditModal\n          mode=\"project\"\n          characterId={editingAppearance.characterId}\n          characterName={editingAppearance.characterName}\n          appearanceId={editingAppearance.appearanceId}\n          description={editingAppearance.description}\n          descriptionIndex={editingAppearance.descriptionIndex}\n          introduction={editingAppearance.introduction}\n          projectId={projectId}\n          onClose={closeEditingAppearance}\n          onSave={(characterId, appearanceId) => void handleGenerateImage('character', characterId, appearanceId)}\n          onUpdate={handleUpdateAppearanceDescription}\n        />\n      )}\n\n      {editingLocation && (\n        <LocationEditModal\n          mode=\"project\"\n          locationId={editingLocation.locationId}\n          locationName={editingLocation.locationName}\n          description={editingLocation.description}\n          projectId={projectId}\n          onClose={closeEditingLocation}\n          onSave={(locationId) => void handleGenerateImage('location', locationId)}\n          onUpdate={handleUpdateLocationDescription}\n        />\n      )}\n\n      {showAddCharacter && (\n        <CharacterCreationModal\n          mode=\"project\"\n          projectId={projectId}\n          onClose={closeAddCharacter}\n          onSuccess={() => {\n            closeAddCharacter()\n            onRefresh()\n          }}\n        />\n      )}\n\n      {showAddLocation && (\n        <LocationCreationModal\n          mode=\"project\"\n          projectId={projectId}\n          onClose={closeAddLocation}\n          onSuccess={() => {\n            closeAddLocation()\n            onRefresh()\n          }}\n        />\n      )}\n\n      {voiceDesignCharacter && (\n        <VoiceDesignDialog\n          isOpen={!!voiceDesignCharacter}\n          speaker={voiceDesignCharacter.name}\n          hasExistingVoice={voiceDesignCharacter.hasExistingVoice}\n          projectId={projectId}\n          onClose={handleCloseVoiceDesign}\n          onSave={handleVoiceDesignSave}\n        />\n      )}\n\n      {editingProfile && (\n        <CharacterProfileDialog\n          isOpen={!!editingProfile}\n          characterName={editingProfile.characterName}\n          profileData={editingProfile.profileData}\n          onClose={() => setEditingProfile(null)}\n          onSave={(profileData) => handleConfirmProfile(editingProfile.characterId, profileData)}\n          isSaving={isConfirmingCharacter(editingProfile.characterId)}\n        />\n      )}\n\n      {copyFromGlobalTarget && (\n        <GlobalAssetPicker\n          isOpen={!!copyFromGlobalTarget}\n          onClose={handleCloseCopyPicker}\n          onSelect={handleConfirmCopyFromGlobal}\n          type={copyFromGlobalTarget.type}\n          loading={isGlobalCopyInFlight}\n        />\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetsStageStatusOverlays.tsx",
    "content": "'use client'\n\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ntype ToastType = 'success' | 'warning' | 'error'\n\ninterface ToastState {\n  message: string\n  type: ToastType\n}\n\ninterface AssetsStageStatusOverlaysProps {\n  toast: ToastState | null\n  onCloseToast: () => void\n  isGlobalAnalyzing: boolean\n  globalAnalyzingState: TaskPresentationState | null\n  globalAnalyzingTitle: string\n  globalAnalyzingHint: string\n  globalAnalyzingTip: string\n}\n\nexport default function AssetsStageStatusOverlays({\n  toast,\n  onCloseToast,\n  isGlobalAnalyzing,\n  globalAnalyzingState,\n  globalAnalyzingTitle,\n  globalAnalyzingHint,\n  globalAnalyzingTip,\n}: AssetsStageStatusOverlaysProps) {\n  return (\n    <>\n      {toast && (\n        <div className=\"fixed top-4 right-4 z-50 animate-in slide-in-from-right\">\n          <div\n            className={`flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg ${\n              toast.type === 'success'\n                ? 'bg-[var(--glass-tone-success-fg)] text-white'\n                : toast.type === 'warning'\n                  ? 'bg-[var(--glass-tone-warning-fg)] text-white'\n                  : 'bg-[var(--glass-tone-danger-fg)] text-white'\n            }`}\n          >\n            <span className=\"text-sm font-medium\">{toast.message}</span>\n            <button onClick={onCloseToast} className=\"ml-2 hover:opacity-80\">\n              <AppIcon name=\"close\" className=\"w-4 h-4\" />\n            </button>\n          </div>\n        </div>\n      )}\n\n      {isGlobalAnalyzing && (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center glass-overlay\">\n          <div className=\"glass-surface-modal p-8 max-w-md mx-4 animate-in zoom-in-95 duration-300\">\n            <div className=\"flex flex-col items-center text-center\">\n              <div className=\"relative mb-6\">\n                <div className=\"w-20 h-20 rounded-full bg-[var(--glass-accent-from)] flex items-center justify-center\">\n                  <AppIcon name=\"ideaAlt\" className=\"w-10 h-10 text-white\" />\n                </div>\n              </div>\n\n              <h3 className=\"text-xl font-bold text-[var(--glass-text-primary)] mb-2\">\n                {globalAnalyzingTitle}\n              </h3>\n              <p className=\"text-[var(--glass-text-tertiary)] text-sm mb-4\">{globalAnalyzingHint}</p>\n              <TaskStatusInline state={globalAnalyzingState} />\n\n              <div className=\"w-full h-2 bg-[var(--glass-bg-muted)] rounded-full overflow-hidden\">\n                <div className=\"h-full bg-[var(--glass-accent-from)] rounded-full animate-pulse\" style={{ width: '100%' }} />\n              </div>\n              <p className=\"text-xs text-[var(--glass-text-tertiary)] mt-2\">{globalAnalyzingTip}</p>\n            </div>\n          </div>\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterCard.tsx",
    "content": "'use client'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\n\nimport { useTranslations } from 'next-intl'\n/**\n * 角色卡片组件 - 支持多图片选择和音色设置\n * 布局：上面名字+描述，下面三张图片（每张图片有独立的编辑和重新生成按钮）\n */\n\nimport { useState, useRef } from 'react'\nimport { Character, CharacterAppearance } from '@/types/project'\nimport { shouldShowError } from '@/lib/error-utils'\nimport VoiceSettings from './VoiceSettings'\nimport { useUploadProjectCharacterImage } from '@/lib/query/mutations'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\nimport CharacterCardHeader from './character-card/CharacterCardHeader'\nimport CharacterCardGallery from './character-card/CharacterCardGallery'\nimport CharacterCardActions from './character-card/CharacterCardActions'\nimport { getImageGenerationCountOptions } from '@/lib/image-generation/count'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\nimport { AppIcon } from '@/components/ui/icons'\nimport { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'\nimport AISparklesIcon from '@/components/ui/icons/AISparklesIcon'\n\ninterface CharacterCardProps {\n  character: Character\n  appearance: CharacterAppearance\n  onEdit: () => void\n  onDelete: () => void\n  onDeleteAppearance?: () => void  // 删除单个形象\n  onRegenerate: (count?: number) => void\n  onGenerate: (count?: number) => void\n  onUndo?: () => void  // 撤回到上一版本\n  onImageClick: (imageUrl: string) => void\n  showDeleteButton: boolean\n  appearanceCount?: number  // 该角色的形象数量\n  onSelectImage?: (characterId: string, appearanceId: string, imageIndex: number | null) => void\n  activeTaskKeys?: Set<string>\n  onClearTaskKey?: (key: string) => void\n  onImageEdit?: (characterId: string, appearanceId: string, imageIndex: number) => void\n  isPrimaryAppearance?: boolean\n  primaryAppearanceSelected?: boolean\n  projectId: string\n  onConfirmSelection?: (characterId: string, appearanceId: string) => void  // 确认选择\n  // 音色相关\n  onVoiceChange?: (characterId: string, customVoiceUrl?: string) => void\n  onVoiceDesign?: (characterId: string, characterName: string) => void  // AI 声音设计\n  onVoiceSelectFromHub?: (characterId: string) => void  // 从资产中心选择音色\n}\n\nexport default function CharacterCard({\n  character,\n  appearance,\n  onEdit,\n  onDelete,\n  onDeleteAppearance,\n  onRegenerate,\n  onGenerate,\n  onUndo,\n  onImageClick,\n  showDeleteButton,\n  appearanceCount = 1,\n  onSelectImage,\n  activeTaskKeys = new Set(),\n  onImageEdit,\n  isPrimaryAppearance = false,\n  primaryAppearanceSelected = false,\n  projectId,\n  onConfirmSelection,\n  onVoiceChange,\n  onVoiceDesign,\n  onVoiceSelectFromHub\n}: CharacterCardProps) {\n  // 🔥 使用 mutation\n  const uploadImage = useUploadProjectCharacterImage(projectId)\n  const t = useTranslations('assets')\n  const { count: generationCount, setCount: setGenerationCount } = useImageGenerationCount('character')\n  const fileInputRef = useRef<HTMLInputElement>(null)\n  const [pendingUploadIndex, setPendingUploadIndex] = useState<number | undefined>(undefined)\n  const [showDeleteMenu, setShowDeleteMenu] = useState(false)\n  const [isConfirmingSelection, setIsConfirmingSelection] = useState(false)\n\n  // 处理删除按钮点击\n  const handleDeleteClick = () => {\n    if (appearanceCount <= 1) {\n      // 只有一个形象，直接删除角色\n      onDelete()\n    } else {\n      // 多个形象，显示菜单\n      setShowDeleteMenu(!showDeleteMenu)\n    }\n  }\n\n  // 触发文件选择\n  const triggerUpload = (imageIndex?: number) => {\n    setPendingUploadIndex(imageIndex)\n    fileInputRef.current?.click()\n  }\n\n  // 处理图片上传\n  const handleUpload = () => {\n    const file = fileInputRef.current?.files?.[0]\n    if (!file) return\n\n    const uploadIndex = pendingUploadIndex\n\n    uploadImage.mutate(\n      {\n        file,\n        characterId: character.id,\n        appearanceId: appearance.id,\n        imageIndex: uploadIndex,\n        labelText: `${character.name} - ${appearance.changeReason}`\n      },\n      {\n        onSuccess: () => {\n          alert(t('image.uploadSuccess'))\n        },\n        onError: (error) => {\n          if (shouldShowError(error)) {\n            alert(t('image.uploadFailed') + ': ' + error.message)\n          }\n        },\n        onSettled: () => {\n          setPendingUploadIndex(undefined)\n          if (fileInputRef.current) {\n            fileInputRef.current.value = ''\n          }\n        }\n      }\n    )\n  }\n\n  // 音色设置由 VoiceSettings 组件处理\n\n  // 获取图片数组（已经是数组，不需要 JSON 解析）\n  const rawImageUrls = appearance.imageUrls || []\n  const imageUrlsWithIndex = rawImageUrls\n    .map((url, idx) => ({ url, originalIndex: idx }))\n    .filter((item) => !!item.url) as { url: string; originalIndex: number }[]\n\n  const hasMultipleImages = imageUrlsWithIndex.length > 1\n  const selectedIndex = appearance.selectedIndex ?? null\n\n  // 🔥 统一图片URL优先级：imageUrl > imageUrls[selectedIndex] > imageUrls[0]\n  // 这样确保编辑后的新图片能正确显示\n  const currentImageUrl = appearance.imageUrl ||\n    (selectedIndex !== null ? rawImageUrls[selectedIndex] : null) ||\n    imageUrlsWithIndex[0]?.url\n\n  // 调试日志\n  if (!currentImageUrl) {\n    _ulogInfo(`[CharacterCard调试] ${character.name}-${appearance.changeReason}:`, {\n      imageUrl: appearance.imageUrl,\n      imageUrls: appearance.imageUrls,\n      rawImageUrls,\n      imageUrlsWithIndex,\n      currentImageUrl\n    })\n  }\n\n  const showSelectionMode = hasMultipleImages\n\n  const isImageTaskRunning = (imageIndex: number) => {\n    return activeTaskKeys.has(`character-${character.id}-${appearance.appearanceIndex}-${imageIndex}`)\n  }\n\n  const isGroupTaskRunning = activeTaskKeys.has(`character-${character.id}-${appearance.appearanceIndex}-group`)\n\n  const isAnyTaskRunning = isGroupTaskRunning || Array.from(activeTaskKeys).some(key =>\n    key.startsWith(`character-${character.id}-${appearance.appearanceIndex}`)\n  )\n  const appearanceTaskRunning = !!appearance.imageTaskRunning\n  const appearanceTaskPresentation = appearanceTaskRunning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: currentImageUrl ? 'regenerate' : 'generate',\n      resource: 'image',\n      hasOutput: !!currentImageUrl,\n    })\n    : null\n  const fallbackRunningPresentation = isAnyTaskRunning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'regenerate',\n      resource: 'image',\n      hasOutput: !!currentImageUrl,\n    })\n    : null\n  const displayTaskPresentation = appearanceTaskPresentation || fallbackRunningPresentation\n  const confirmSelectionState = isConfirmingSelection\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'process',\n      resource: 'image',\n      hasOutput: !!currentImageUrl,\n    })\n    : null\n  const uploadPendingState = uploadImage.isPending\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'process',\n      resource: 'image',\n      hasOutput: !!currentImageUrl,\n    })\n    : null\n  const isAppearanceTaskRunning =\n    appearanceTaskRunning ||\n    isAnyTaskRunning\n\n  // 注意：不再使用 editingItems，生成/编辑状态统一由任务态 + 实体态提供\n\n  // 选择模式：显示名字+描述在上，三张图片在下\n  if (showSelectionMode) {\n    const selectionActions = (\n      <>\n        <ImageGenerationInlineCountButton\n          prefix={isGroupTaskRunning ? (\n            <TaskStatusInline state={displayTaskPresentation} className=\"[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]\" />\n          ) : (\n            <>\n              <AppIcon name=\"refresh\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n              <span className=\"text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5\">{t('image.regenCountPrefix')}</span>\n            </>\n          )}\n          suffix={<span className=\"text-[10px] font-medium text-[var(--glass-tone-info-fg)]\">{t('image.regenCountSuffix')}</span>}\n          value={generationCount}\n          options={getImageGenerationCountOptions('character')}\n          onValueChange={setGenerationCount}\n          onClick={() => onRegenerate(generationCount)}\n          disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending}\n          ariaLabel={t('image.regenCountAriaLabel')}\n          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\"\n          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\"\n        />\n        {onUndo && (appearance.previousImageUrl || appearance.previousImageUrls.length > 0) && (\n          <button\n            onClick={onUndo}\n            disabled={isAppearanceTaskRunning || isAnyTaskRunning}\n            className=\"w-6 h-6 rounded hover:bg-[var(--glass-tone-warning-bg)] flex items-center justify-center transition-colors disabled:opacity-50\"\n            title={t('image.undo')}\n          >\n            <AppIcon name=\"undo\" className=\"w-4 h-4 text-[var(--glass-tone-warning-fg)]\" />\n          </button>\n        )}\n        {showDeleteButton && (\n          <button\n            onClick={onDelete}\n            className=\"w-6 h-6 rounded hover:bg-[var(--glass-tone-danger-bg)] flex items-center justify-center transition-colors\"\n            title={t('character.delete')}\n          >\n            <AppIcon name=\"trash\" className=\"w-4 h-4 text-[var(--glass-tone-danger-fg)]\" />\n          </button>\n        )}\n      </>\n    )\n\n    const selectionVoiceSettings = (\n      <VoiceSettings\n        characterId={character.id}\n        characterName={character.name}\n        customVoiceUrl={character.customVoiceUrl}\n        projectId={projectId}\n        onVoiceChange={onVoiceChange}\n        onVoiceDesign={onVoiceDesign}\n        onSelectFromHub={onVoiceSelectFromHub}\n      />\n    )\n\n    return (\n      <div className=\"col-span-3 bg-[var(--glass-bg-surface)] rounded-lg border-2 border-[var(--glass-stroke-base)] p-4 shadow-sm transition-all\">\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          accept=\"image/*\"\n          onChange={() => handleUpload()}\n          className=\"hidden\"\n        />\n\n        <CharacterCardHeader\n          mode=\"selection\"\n          characterName={character.name}\n          changeReason={appearance.changeReason}\n          isPrimaryAppearance={isPrimaryAppearance}\n          selectedIndex={selectedIndex}\n          actions={selectionActions}\n        />\n\n        <CharacterCardGallery\n          mode=\"selection\"\n          characterId={character.id}\n          appearanceId={appearance.id}\n          characterName={character.name}\n          imageUrlsWithIndex={imageUrlsWithIndex}\n          selectedIndex={selectedIndex}\n          isGroupTaskRunning={isGroupTaskRunning}\n          isImageTaskRunning={isImageTaskRunning}\n          displayTaskPresentation={displayTaskPresentation}\n          onImageClick={onImageClick}\n          onSelectImage={onSelectImage}\n        />\n\n        <CharacterCardActions\n          mode=\"selection\"\n          selectedIndex={selectedIndex}\n          isConfirmingSelection={isConfirmingSelection}\n          confirmSelectionState={confirmSelectionState}\n          onConfirmSelection={() => {\n            setIsConfirmingSelection(true)\n            onConfirmSelection?.(character.id, appearance.id)\n          }}\n          isPrimaryAppearance={isPrimaryAppearance}\n          voiceSettings={selectionVoiceSettings}\n        />\n      </div>\n    )\n  }\n\n  // 单图模式或已选择模式\n  const overlayActions = (\n    <>\n      {!isAppearanceTaskRunning && !isAnyTaskRunning && (\n        <button\n          onClick={() => triggerUpload(selectedIndex !== null ? selectedIndex : 0)}\n          disabled={uploadImage.isPending || isAppearanceTaskRunning || isAnyTaskRunning}\n          className=\"w-7 h-7 rounded-full bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-tone-success-fg)] hover:text-white flex items-center justify-center transition-all shadow-sm disabled:opacity-50\"\n          title={currentImageUrl ? t('image.uploadReplace') : t('image.upload')}\n        >\n          {uploadImage.isPending ? (\n            <TaskStatusInline state={uploadPendingState} className=\"[&_span]:sr-only [&_svg]:text-current\" />\n          ) : (\n            <AppIcon name=\"upload\" className=\"w-4 h-4 text-[var(--glass-tone-success-fg)]\" />\n          )}\n        </button>\n      )}\n      {!isAppearanceTaskRunning && !isAnyTaskRunning && currentImageUrl && onImageEdit && (\n        <button\n          onClick={() => onImageEdit(character.id, appearance.id, selectedIndex !== null ? selectedIndex : 0)}\n          className={`w-7 h-7 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS}`}\n          title={t('image.edit')}\n        >\n          <AISparklesIcon className={`w-4 h-4 ${AI_EDIT_ICON_CLASS}`} />\n        </button>\n      )}\n      <button\n        onClick={() => onRegenerate()}\n        disabled={uploadImage.isPending || isAppearanceTaskRunning}\n        className={`w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-sm active:scale-90 ${(isAppearanceTaskRunning || isAnyTaskRunning)\n          ? 'bg-[var(--glass-tone-success-fg)] hover:bg-[var(--glass-tone-success-fg)]'\n          : 'bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-bg-surface)]'\n          }`}\n        title={(isAppearanceTaskRunning || isAnyTaskRunning) ? t('image.regenerateStuck') : t('location.regenerateImage')}\n      >\n        {isGroupTaskRunning ? (\n          <TaskStatusInline state={displayTaskPresentation} className=\"[&_span]:sr-only [&_svg]:text-white\" />\n        ) : (\n          <AppIcon name=\"refresh\" className={`w-4 h-4 ${(isAppearanceTaskRunning || isAnyTaskRunning) ? 'text-white' : 'text-[var(--glass-text-secondary)]'}`} />\n        )}\n      </button>\n      {!isAppearanceTaskRunning && !isAnyTaskRunning && currentImageUrl && onUndo && (appearance.previousImageUrl || appearance.previousImageUrls.length > 0) && (\n        <button\n          onClick={onUndo}\n          disabled={isAppearanceTaskRunning || isAnyTaskRunning}\n          className=\"w-7 h-7 rounded-full bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-tone-warning-fg)] hover:text-white flex items-center justify-center transition-all shadow-sm disabled:opacity-50\"\n          title={t('image.undo')}\n        >\n          <AppIcon name=\"undo\" className=\"w-4 h-4 text-[var(--glass-tone-warning-fg)] hover:text-white\" />\n        </button>\n      )}\n    </>\n  )\n\n  const compactHeaderActions = (\n    <>\n      <button\n        onClick={onEdit}\n        className=\"flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-bg-muted)] flex items-center justify-center transition-colors\"\n        title={t('video.panelCard.editPrompt')}\n      >\n        <AppIcon name=\"edit\" className=\"w-3.5 h-3.5 text-[var(--glass-text-secondary)]\" />\n      </button>\n      {showDeleteButton && (\n        <div className=\"relative\">\n          <button\n            onClick={handleDeleteClick}\n            className=\"flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-tone-danger-bg)] flex items-center justify-center transition-colors\"\n            title={appearanceCount <= 1 ? t('character.delete') : t('character.deleteOptions')}\n          >\n            <AppIcon name=\"trash\" className=\"w-3.5 h-3.5 text-[var(--glass-tone-danger-fg)]\" />\n          </button>\n\n          {showDeleteMenu && appearanceCount > 1 && (\n            <>\n              <div\n                className=\"fixed inset-0 z-10\"\n                onClick={() => setShowDeleteMenu(false)}\n              />\n              <div className=\"absolute right-0 top-full mt-1 z-20 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-lg shadow-lg py-1 min-w-[100px]\">\n                <button\n                  onClick={() => {\n                    setShowDeleteMenu(false)\n                    onDeleteAppearance?.()\n                  }}\n                  className=\"w-full px-3 py-1.5 text-left text-xs text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)] whitespace-nowrap\"\n                >\n                  {t('image.deleteThis')}\n                </button>\n                <button\n                  onClick={() => {\n                    setShowDeleteMenu(false)\n                    onDelete()\n                  }}\n                  className=\"w-full px-3 py-1.5 text-left text-xs text-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-bg)] whitespace-nowrap\"\n                >\n                  {t('character.deleteWhole')}\n                </button>\n              </div>\n            </>\n          )}\n        </div>\n      )}\n    </>\n  )\n\n  const compactVoiceSettings = (\n    <VoiceSettings\n      characterId={character.id}\n      characterName={character.name}\n      customVoiceUrl={character.customVoiceUrl}\n      projectId={projectId}\n      onVoiceChange={onVoiceChange}\n      onVoiceDesign={onVoiceDesign}\n      onSelectFromHub={onVoiceSelectFromHub}\n      compact={true}\n    />\n  )\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        accept=\"image/*\"\n        onChange={() => handleUpload()}\n        className=\"hidden\"\n      />\n      <div className=\"relative\">\n        <CharacterCardGallery\n          mode=\"single\"\n          characterName={character.name}\n          changeReason={appearance.changeReason}\n          currentImageUrl={currentImageUrl}\n          selectedIndex={selectedIndex}\n          hasMultipleImages={hasMultipleImages}\n          isAppearanceTaskRunning={isAppearanceTaskRunning || isGroupTaskRunning}\n          displayTaskPresentation={displayTaskPresentation}\n          appearanceErrorMessage={appearance.lastError?.message || appearance.imageErrorMessage}\n          onImageClick={onImageClick}\n          overlayActions={overlayActions}\n        />\n      </div>\n\n      <CharacterCardHeader\n        mode=\"compact\"\n        characterName={character.name}\n        changeReason={appearance.changeReason}\n        actions={compactHeaderActions}\n      />\n\n      <CharacterCardActions\n        mode=\"compact\"\n        isPrimaryAppearance={isPrimaryAppearance}\n        primaryAppearanceSelected={primaryAppearanceSelected}\n        currentImageUrl={currentImageUrl}\n        isAppearanceTaskRunning={isAppearanceTaskRunning}\n        isAnyTaskRunning={isAnyTaskRunning}\n        hasDescription={!!appearance.description}\n        generationCount={generationCount}\n        onGenerationCountChange={setGenerationCount}\n        onGenerate={onGenerate}\n        voiceSettings={compactVoiceSettings}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterEditModal.tsx",
    "content": "'use client'\n\nimport {\n  CharacterEditModal as SharedCharacterEditModal,\n  type CharacterEditModalProps as SharedCharacterEditModalProps,\n} from '@/components/shared/assets/CharacterEditModal'\n\ninterface CharacterEditModalProps {\n  characterId: string\n  characterName: string\n  appearanceId: number\n  description: string\n  introduction?: string | null\n  descriptionIndex?: number\n  projectId: string\n  onClose: () => void\n  onSave: (characterId: string, appearanceId: number) => void\n  onUpdate: (newDescription: string) => void\n  onIntroductionUpdate?: (newIntroduction: string) => void\n  onNameUpdate?: (newName: string) => void\n  isTaskRunning?: boolean\n}\n\nexport default function CharacterEditModal({\n  characterId,\n  characterName,\n  appearanceId,\n  description,\n  introduction,\n  descriptionIndex,\n  projectId,\n  onClose,\n  onSave,\n  onUpdate,\n  onIntroductionUpdate,\n  onNameUpdate,\n  isTaskRunning = false,\n}: CharacterEditModalProps) {\n  const handleSave: SharedCharacterEditModalProps['onSave'] = (\n    nextCharacterId,\n    nextAppearanceId,\n  ) => {\n    onSave(nextCharacterId, Number(nextAppearanceId))\n  }\n\n  return (\n    <SharedCharacterEditModal\n      mode=\"project\"\n      characterId={characterId}\n      characterName={characterName}\n      appearanceId={String(appearanceId)}\n      description={description}\n      introduction={introduction}\n      descriptionIndex={descriptionIndex}\n      projectId={projectId}\n      onClose={onClose}\n      onSave={handleSave}\n      onUpdate={onUpdate}\n      onIntroductionUpdate={onIntroductionUpdate}\n      onNameUpdate={onNameUpdate}\n      isTaskRunning={isTaskRunning}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterProfileCard.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\n/**\n * 角色档案卡片组件\n * 展示角色档案摘要，点击可编辑\n */\n\nimport { CharacterProfileData } from '@/types/character-profile'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface CharacterProfileCardProps {\n    characterId: string\n    name: string\n    profileData: CharacterProfileData\n    onEdit: () => void\n    onConfirm: () => void\n    onUseExisting?: () => void\n    onDelete?: () => void\n    isConfirming?: boolean\n    isDeleting?: boolean\n}\n\nconst ROLE_LEVEL_COLORS = {\n    S: 'bg-[var(--glass-tone-warning-bg)] text-[var(--glass-tone-warning-fg)]',\n    A: 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]',\n    B: 'bg-[var(--glass-tone-neutral-bg)] text-[var(--glass-tone-neutral-fg)]',\n    C: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-primary)]',\n    D: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'\n}\nconst ROLE_LEVELS = ['S', 'A', 'B', 'C', 'D'] as const\ntype RoleLevel = (typeof ROLE_LEVELS)[number]\n\nfunction isRoleLevel(value: string): value is RoleLevel {\n    return ROLE_LEVELS.includes(value as RoleLevel)\n}\n\nexport default function CharacterProfileCard({\n    name,\n    profileData,\n    onEdit,\n    onConfirm,\n    onUseExisting,\n    onDelete,\n    isConfirming = false,\n    isDeleting = false\n}: CharacterProfileCardProps) {\n    const t = useTranslations('assets')\n    const deletingState = isDeleting\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'process',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n    const confirmingState = isConfirming\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'process',\n            resource: 'image',\n            hasOutput: true,\n        })\n        : null\n    const roleLevel = isRoleLevel(profileData.role_level) ? profileData.role_level : null\n    const roleLevelLabel = roleLevel\n        ? t(`characterProfile.importance.${roleLevel}`)\n        : profileData.role_level\n    const roleLevelColor = roleLevel\n        ? ROLE_LEVEL_COLORS[roleLevel]\n        : 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-primary)]'\n\n    return (\n        <div className=\"bg-[var(--glass-bg-surface)] rounded-xl border border-[var(--glass-stroke-base)] p-4 hover:shadow-md transition-shadow\">\n            {/* 头部 */}\n            <div className=\"flex items-start justify-between mb-3\">\n                <div className=\"flex-1\">\n                    <h3 className=\"font-semibold text-[var(--glass-text-primary)] mb-1\">{name}</h3>\n                    <div className=\"flex items-center gap-2 flex-wrap\">\n                        <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${roleLevelColor}`}>\n                            {roleLevelLabel}\n                        </span>\n                        <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{profileData.archetype}</span>\n                    </div>\n                </div>\n                {/* 删除按钮 */}\n                {onDelete && (\n                    <button\n                        onClick={onDelete}\n                        disabled={isConfirming || isDeleting}\n                        className=\"p-1.5 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-bg)] rounded-lg transition-colors disabled:opacity-50\"\n                        title={t('characterProfile.delete')}\n                    >\n                        {isDeleting ? (\n                            <TaskStatusInline state={deletingState} className=\"[&_span]:sr-only [&_svg]:text-current\" />\n                        ) : (\n                            <AppIcon name=\"trash\" className=\"w-4 h-4\" />\n                        )}\n                    </button>\n                )}\n            </div>\n\n            {/* 档案摘要 */}\n            <div className=\"space-y-1.5 mb-3\">\n                <div className=\"flex items-center gap-2 text-sm\">\n                    <span className=\"text-[var(--glass-text-tertiary)] w-16\">{t('characterProfile.summary.gender')}</span>\n                    <span className=\"text-[var(--glass-text-primary)]\">{profileData.gender}</span>\n                </div>\n                <div className=\"flex items-center gap-2 text-sm\">\n                    <span className=\"text-[var(--glass-text-tertiary)] w-16\">{t('characterProfile.summary.age')}</span>\n                    <span className=\"text-[var(--glass-text-primary)]\">{profileData.age_range}</span>\n                </div>\n                <div className=\"flex items-center gap-2 text-sm\">\n                    <span className=\"text-[var(--glass-text-tertiary)] w-16\">{t('characterProfile.summary.era')}</span>\n                    <span className=\"text-[var(--glass-text-primary)]\">{profileData.era_period}</span>\n                </div>\n                <div className=\"flex items-center gap-2 text-sm\">\n                    <span className=\"text-[var(--glass-text-tertiary)] w-16\">{t('characterProfile.summary.class')}</span>\n                    <span className=\"text-[var(--glass-text-primary)]\">{profileData.social_class}</span>\n                </div>\n                {profileData.occupation && (\n                    <div className=\"flex items-center gap-2 text-sm\">\n                        <span className=\"text-[var(--glass-text-tertiary)] w-16\">{t('characterProfile.summary.occupation')}</span>\n                        <span className=\"text-[var(--glass-text-primary)]\">{profileData.occupation}</span>\n                    </div>\n                )}\n                <div className=\"flex items-center gap-2 text-sm\">\n                    <span className=\"text-[var(--glass-text-tertiary)] w-16\">{t('characterProfile.summary.personality')}</span>\n                    <div className=\"flex flex-wrap gap-1\">\n                        {profileData.personality_tags.map((tag, i) => (\n                            <span key={i} className=\"px-1.5 py-0.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded text-xs\">\n                                {tag}\n                            </span>\n                        ))}\n                    </div>\n                </div>\n                <div className=\"flex items-center gap-2 text-sm\">\n                    <span className=\"text-[var(--glass-text-tertiary)] w-16\">{t('characterProfile.summary.costume')}</span>\n                    <span className=\"text-[var(--glass-text-primary)]\">\n                        {'●'.repeat(profileData.costume_tier)}\n                    </span>\n                </div>\n                {profileData.primary_identifier && (\n                    <div className=\"flex items-center gap-2 text-sm\">\n                        <span className=\"text-[var(--glass-text-tertiary)] w-16\">{t('characterProfile.summary.identifier')}</span>\n                        <span className=\"text-[var(--glass-tone-warning-fg)] font-medium\">{profileData.primary_identifier}</span>\n                    </div>\n                )}\n            </div>\n\n            {/* 操作按钮 */}\n            <div className=\"flex gap-2\">\n                <button\n                    onClick={onEdit}\n                    disabled={isConfirming}\n                    className=\"flex-1 px-3 py-1.5 text-sm border border-[var(--glass-stroke-strong)] rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors disabled:opacity-50\"\n                >\n                    {t('characterProfile.editProfile')}\n                </button>\n                {onUseExisting && (\n                    <button\n                        onClick={onUseExisting}\n                        disabled={isConfirming}\n                        className=\"flex-1 px-3 py-1.5 text-sm border border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] rounded-lg hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50 flex items-center justify-center gap-1\"\n                    >\n                        {t('characterProfile.useExisting')}\n                    </button>\n                )}\n                <button\n                    onClick={onConfirm}\n                    disabled={isConfirming}\n                    className=\"flex-1 px-3 py-1.5 text-sm bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] transition-colors disabled:opacity-50 flex items-center justify-center gap-1\"\n                >\n                    {isConfirming ? (\n                        <TaskStatusInline state={confirmingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                    ) : (\n                        t('characterProfile.confirmAndGenerate')\n                    )}\n                </button>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterProfileDialog.tsx",
    "content": "'use client'\n\n/**\n * 角色档案编辑对话框\n * 允许用户编辑角色档案的各项属性\n */\n\nimport { useTranslations } from 'next-intl'\nimport { useState, useEffect } from 'react'\nimport { CharacterProfileData, RoleLevel, CostumeTier } from '@/types/character-profile'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface CharacterProfileDialogProps {\n    isOpen: boolean\n    characterName: string\n    profileData: CharacterProfileData\n    onClose: () => void\n    onSave: (profileData: CharacterProfileData) => void\n    isSaving?: boolean\n}\n\nconst ROLE_LEVELS: RoleLevel[] = ['S', 'A', 'B', 'C', 'D']\nconst COSTUME_TIERS: CostumeTier[] = [5, 4, 3, 2, 1]\n\nexport default function CharacterProfileDialog({\n    isOpen,\n    characterName,\n    profileData,\n    onClose,\n    onSave,\n    isSaving = false\n}: CharacterProfileDialogProps) {\n    const t = useTranslations('assets')\n    const savingState = isSaving\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'build',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n    const [formData, setFormData] = useState<CharacterProfileData>(profileData)\n    const [newTag, setNewTag] = useState('')\n    const [newColor, setNewColor] = useState('')\n    const [newKeyword, setNewKeyword] = useState('')\n\n    useEffect(() => {\n        setFormData(profileData)\n    }, [profileData])\n\n    if (!isOpen) return null\n\n    const handleSubmit = () => {\n        onSave(formData)\n    }\n\n    const addTag = () => {\n        if (newTag.trim() && !formData.personality_tags.includes(newTag.trim())) {\n            setFormData({ ...formData, personality_tags: [...formData.personality_tags, newTag.trim()] })\n            setNewTag('')\n        }\n    }\n\n    const removeTag = (index: number) => {\n        setFormData({\n            ...formData,\n            personality_tags: formData.personality_tags.filter((_, i) => i !== index)\n        })\n    }\n\n    const addColor = () => {\n        if (newColor.trim() && !formData.suggested_colors.includes(newColor.trim())) {\n            setFormData({ ...formData, suggested_colors: [...formData.suggested_colors, newColor.trim()] })\n            setNewColor('')\n        }\n    }\n\n    const removeColor = (index: number) => {\n        setFormData({\n            ...formData,\n            suggested_colors: formData.suggested_colors.filter((_, i) => i !== index)\n        })\n    }\n\n    const addKeyword = () => {\n        if (newKeyword.trim() && !formData.visual_keywords.includes(newKeyword.trim())) {\n            setFormData({ ...formData, visual_keywords: [...formData.visual_keywords, newKeyword.trim()] })\n            setNewKeyword('')\n        }\n    }\n\n    const removeKeyword = (index: number) => {\n        setFormData({\n            ...formData,\n            visual_keywords: formData.visual_keywords.filter((_, i) => i !== index)\n        })\n    }\n\n    return (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-[var(--glass-overlay)]\" onClick={onClose}>\n            <div\n                className=\"bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4\"\n                onClick={(e) => e.stopPropagation()}\n            >\n                {/* 头部 */}\n                <div className=\"sticky top-0 bg-[var(--glass-bg-surface)] border-b border-[var(--glass-stroke-base)] px-6 py-4 flex items-center justify-between\">\n                    <h2 className=\"text-xl font-semibold text-[var(--glass-text-primary)]\">{t('characterProfile.editDialogTitle', { name: characterName })}</h2>\n                    <button\n                        onClick={onClose}\n                        className=\"p-2 hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors\"\n                    >\n                        <AppIcon name=\"close\" className=\"w-5 h-5\" />\n                    </button>\n                </div>\n\n                {/* 表单内容 */}\n                <div className=\"p-6 space-y-4\">\n                    {/* 角色层级 */}\n                    <div>\n                        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">{t('characterProfile.importanceLevel')}</label>\n                        <select\n                            value={formData.role_level}\n                            onChange={(e) => setFormData({ ...formData, role_level: e.target.value as RoleLevel })}\n                            className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n                        >\n                            {ROLE_LEVELS.map((level) => (\n                                <option key={level} value={level}>\n                                    {t(`characterProfile.importance.${level}` as never)}\n                                </option>\n                            ))}\n                        </select>\n                    </div>\n\n                    {/* 角色原型 */}\n                    <div>\n                        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">{t('characterProfile.characterArchetype')}</label>\n                        <input\n                            type=\"text\"\n                            value={formData.archetype}\n                            onChange={(e) => setFormData({ ...formData, archetype: e.target.value })}\n                            placeholder={t('characterProfile.archetypePlaceholder')}\n                            className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n                        />\n                    </div>\n\n                    {/* 性格标签 */}\n                    <div>\n                        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">{t('characterProfile.personalityTags')}</label>\n                        <div className=\"flex gap-2 mb-2\">\n                            {formData.personality_tags.map((tag, i) => (\n                                <span key={i} className=\"inline-flex items-center gap-1 px-2 py-1 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded-lg text-sm\">\n                                    {tag}\n                                    <button onClick={() => removeTag(i)} className=\"inline-flex h-4 w-4 items-center justify-center hover:text-[var(--glass-text-primary)]\">\n                                        <AppIcon name=\"closeSm\" className=\"h-3 w-3\" />\n                                    </button>\n                                </span>\n                            ))}\n                        </div>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={newTag}\n                                onChange={(e) => setNewTag(e.target.value)}\n                                onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}\n                                placeholder={t('characterProfile.addTagPlaceholder')}\n                                className=\"flex-1 px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg\"\n                            />\n                            <button onClick={addTag} className=\"px-4 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)]\">\n                                {t(\"common.add\")}\n                            </button>\n                        </div>\n                    </div>\n\n                    {/* 服装华丽度 */}\n                    <div>\n                        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">{t('characterProfile.costumeLevelLabel')}</label>\n                        <select\n                            value={formData.costume_tier}\n                            onChange={(e) => setFormData({ ...formData, costume_tier: Number(e.target.value) as CostumeTier })}\n                            className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n                        >\n                            {COSTUME_TIERS.map((tier) => (\n                                <option key={tier} value={tier}>\n                                    {t(`characterProfile.costumeLevel.${tier}` as never)}\n                                </option>\n                            ))}\n                        </select>\n                    </div>\n\n                    {/* 建议色彩 */}\n                    <div>\n                        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">{t('characterProfile.suggestedColors')}</label>\n                        <div className=\"flex gap-2 mb-2 flex-wrap\">\n                            {formData.suggested_colors.map((color, i) => (\n                                <span key={i} className=\"inline-flex items-center gap-1 px-2 py-1 bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] rounded-lg text-sm\">\n                                    {color}\n                                    <button onClick={() => removeColor(i)} className=\"inline-flex h-4 w-4 items-center justify-center hover:text-[var(--glass-text-primary)]\">\n                                        <AppIcon name=\"closeSm\" className=\"h-3 w-3\" />\n                                    </button>\n                                </span>\n                            ))}\n                        </div>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={newColor}\n                                onChange={(e) => setNewColor(e.target.value)}\n                                onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addColor())}\n                                placeholder={t('characterProfile.colorPlaceholder')}\n                                className=\"flex-1 px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg\"\n                            />\n                            <button onClick={addColor} className=\"px-4 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)]\">\n                                {t(\"common.add\")}\n                            </button>\n                        </div>\n                    </div>\n\n                    {/* 辨识标志 */}\n                    <div>\n                        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">\n                            {t('characterProfile.primaryMarker')} <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('characterProfile.markerNote')}</span>\n                        </label>\n                        <input\n                            type=\"text\"\n                            value={formData.primary_identifier || ''}\n                            onChange={(e) => setFormData({ ...formData, primary_identifier: e.target.value })}\n                            placeholder={t('characterProfile.markingsPlaceholder')}\n                            className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n                        />\n                    </div>\n\n                    {/* 视觉关键词 */}\n                    <div>\n                        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">{t('characterProfile.visualKeywords')}</label>\n                        <div className=\"flex gap-2 mb-2 flex-wrap\">\n                            {formData.visual_keywords.map((keyword, i) => (\n                                <span key={i} className=\"inline-flex items-center gap-1 px-2 py-1 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded-lg text-sm\">\n                                    {keyword}\n                                    <button onClick={() => removeKeyword(i)} className=\"inline-flex h-4 w-4 items-center justify-center hover:text-[var(--glass-text-primary)]\">\n                                        <AppIcon name=\"closeSm\" className=\"h-3 w-3\" />\n                                    </button>\n                                </span>\n                            ))}\n                        </div>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={newKeyword}\n                                onChange={(e) => setNewKeyword(e.target.value)}\n                                onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addKeyword())}\n                                placeholder={t('characterProfile.keywordsPlaceholder')}\n                                className=\"flex-1 px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg\"\n                            />\n                            <button onClick={addKeyword} className=\"px-4 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)]\">\n                                {t(\"common.add\")}\n                            </button>\n                        </div>\n                    </div>\n                </div>\n\n                {/* 底部按钮 */}\n                <div className=\"sticky bottom-0 bg-[var(--glass-bg-surface)] border-t border-[var(--glass-stroke-base)] px-6 py-4 flex gap-3 justify-end\">\n                    <button\n                        onClick={onClose}\n                        disabled={isSaving}\n                        className=\"px-6 py-2 border border-[var(--glass-stroke-strong)] rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors disabled:opacity-50\"\n                    >\n                        {t(\"common.cancel\")}\n                    </button>\n                    <button\n                        onClick={handleSubmit}\n                        disabled={isSaving}\n                        className=\"px-6 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] transition-colors disabled:opacity-50 flex items-center gap-2\"\n                    >\n                        {isSaving && <TaskStatusInline state={savingState} className=\"text-white [&>span]:sr-only [&_svg]:text-white\" />}\n                        {t('characterProfile.confirmAndGenerate')}\n                    </button>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterSection.tsx",
    "content": "'use client'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\n\n/**\n * CharacterSection - 角色资产区块组件\n * 从 AssetsStage.tsx 提取，负责角色列表的展示和操作\n * \n * 🔥 V6.5 重构：内部直接订阅 useProjectAssets，消除 props drilling\n */\n\nimport { Character, CharacterAppearance } from '@/types/project'\nimport { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'\nimport CharacterCard from './CharacterCard'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface CharacterSectionProps {\n    // 🔥 V6.5 删除：characters prop - 现在内部直接订阅\n    projectId: string\n    focusCharacterId?: string | null\n    focusCharacterRequestId?: number\n    activeTaskKeys: Set<string>\n    onClearTaskKey: (key: string) => void\n    onRegisterTransientTaskKey: (key: string) => void\n    isAnalyzingAssets: boolean\n    // 回调函数\n    onAddCharacter: () => void\n    onDeleteCharacter: (characterId: string) => void\n    onDeleteAppearance: (characterId: string, appearanceId: string) => void\n    onEditAppearance: (characterId: string, characterName: string, appearance: CharacterAppearance, introduction?: string | null) => void\n    // 🔥 V6.6 重构：重命名为 handleGenerateImage\n    handleGenerateImage: (type: 'character' | 'location', id: string, appearanceId?: string, count?: number) => Promise<void>\n    onSelectImage: (characterId: string, appearanceId: string, imageIndex: number | null) => void\n    onConfirmSelection: (characterId: string, appearanceId: string) => void\n    onRegenerateSingle: (characterId: string, appearanceId: string, imageIndex: number) => Promise<void>\n    onRegenerateGroup: (characterId: string, appearanceId: string, count?: number) => Promise<void>\n    onUndo: (characterId: string, appearanceId: string) => void\n    onImageClick: (imageUrl: string) => void\n    onImageEdit: (characterId: string, appearanceId: string, imageIndex: number, characterName: string) => void\n    onVoiceChange: (characterId: string, customVoiceUrl: string) => void\n    onVoiceDesign: (characterId: string, characterName: string) => void\n    onVoiceSelectFromHub: (characterId: string) => void  // 🆕 从资产中心选择音色\n    onCopyFromGlobal: (characterId: string) => void  // 🆕 从资产中心复制\n    // 辅助函数\n    getAppearances: (character: Character) => CharacterAppearance[]\n}\n\nexport default function CharacterSection({\n    // 🔥 V6.5 删除：characters prop - 现在内部直接订阅\n    projectId,\n    focusCharacterId = null,\n    focusCharacterRequestId = 0,\n    activeTaskKeys,\n    onClearTaskKey,\n    onRegisterTransientTaskKey,\n    isAnalyzingAssets,\n    onAddCharacter,\n    onDeleteCharacter,\n    onDeleteAppearance,\n    onEditAppearance,\n    handleGenerateImage,\n    onSelectImage,\n    onConfirmSelection,\n    onRegenerateSingle,\n    onRegenerateGroup,\n    onUndo,\n    onImageClick,\n    onImageEdit,\n    onVoiceChange,\n    onVoiceDesign,\n    onVoiceSelectFromHub,\n    onCopyFromGlobal,\n    getAppearances\n}: CharacterSectionProps) {\n    const t = useTranslations('assets')\n    const analyzingAssetsState = isAnalyzingAssets\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n\n    // 🔥 V6.5 重构：直接订阅缓存，消除 props drilling\n    const { data: assets } = useProjectAssets(projectId)\n    const characters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters])\n    const [highlightedCharacterId, setHighlightedCharacterId] = useState<string | null>(null)\n    const scrollAnimationRef = useRef<number | null>(null)\n\n    const totalAppearances = characters.reduce((sum, char) => sum + (char.appearances?.length || 0), 0)\n\n    useEffect(() => {\n        if (!focusCharacterId) return\n        if (!characters.some(character => character.id === focusCharacterId)) return\n\n        const element = document.getElementById(`project-character-${focusCharacterId}`)\n        if (!element) return\n        const scrollContainer = (element.closest('[data-asset-scroll-container=\"1\"]') ||\n            document.querySelector('[data-asset-scroll-container=\"1\"]') ||\n            element.closest('.custom-scrollbar')) as HTMLElement | null\n\n        if (scrollAnimationRef.current !== null) {\n            window.cancelAnimationFrame(scrollAnimationRef.current)\n            scrollAnimationRef.current = null\n        }\n\n        if (scrollContainer) {\n            const startTop = scrollContainer.scrollTop\n            const elementTop = element.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top + scrollContainer.scrollTop\n            const targetTop = Math.max(0, elementTop - (scrollContainer.clientHeight - element.clientHeight) / 2)\n            const duration = 650\n            const startTime = performance.now()\n            const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3)\n\n            const animate = (now: number) => {\n                const progress = Math.min((now - startTime) / duration, 1)\n                const eased = easeOutCubic(progress)\n                scrollContainer.scrollTop = startTop + (targetTop - startTop) * eased\n                if (progress < 1) {\n                    scrollAnimationRef.current = window.requestAnimationFrame(animate)\n                } else {\n                    scrollAnimationRef.current = null\n                }\n            }\n\n            scrollAnimationRef.current = window.requestAnimationFrame(animate)\n        } else {\n            element.scrollIntoView({ behavior: 'smooth', block: 'center' })\n        }\n\n        setHighlightedCharacterId(focusCharacterId)\n\n        const timer = window.setTimeout(() => {\n            setHighlightedCharacterId((current) => (current === focusCharacterId ? null : current))\n        }, 2200)\n\n        return () => {\n            window.clearTimeout(timer)\n            if (scrollAnimationRef.current !== null) {\n                window.cancelAnimationFrame(scrollAnimationRef.current)\n                scrollAnimationRef.current = null\n            }\n        }\n    }, [characters, focusCharacterId, focusCharacterRequestId])\n\n    return (\n        <div className=\"glass-surface p-6\">\n            <div className=\"flex items-center justify-between mb-6\">\n                <div className=\"flex items-center gap-3\">\n                    <span className=\"inline-flex h-9 w-9 items-center justify-center rounded-xl bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]\">\n                        <AppIcon name=\"user\" className=\"h-5 w-5\" />\n                    </span>\n                    <h3 className=\"text-lg font-bold text-[var(--glass-text-primary)]\">{t(\"stage.characterAssets\")}</h3>\n                    {isAnalyzingAssets && (\n                        <span className=\"px-2 py-1 text-xs bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded-lg flex items-center gap-1\">\n                            <TaskStatusInline state={analyzingAssetsState} />\n                        </span>\n                    )}\n                    <span className=\"text-sm text-[var(--glass-text-tertiary)] bg-[var(--glass-bg-muted)]/50 px-2 py-1 rounded-lg\">\n                        {t(\"stage.counts\", { characterCount: characters.length, appearanceCount: totalAppearances })}\n                    </span>\n                </div>\n                <button\n                    onClick={onAddCharacter}\n                    className=\"glass-btn-base glass-btn-primary flex items-center gap-2 px-4 py-2 font-medium\"\n                >\n                    + {t(\"character.add\")}\n                </button>\n            </div>\n\n            {/* 按角色分组显示：外层 grid 让多角色并排 */}\n            <div className=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n                {characters.map(character => {\n                    const appearances = getAppearances(character)\n                    const sortedAppearances = [...appearances].sort((a, b) => a.appearanceIndex - b.appearanceIndex)\n                    const primaryAppearance = sortedAppearances.find(a => a.appearanceIndex === PRIMARY_APPEARANCE_INDEX) || sortedAppearances[0]\n\n                    const primaryImageUrl = primaryAppearance?.selectedIndex !== null && primaryAppearance?.selectedIndex !== undefined\n                        ? (primaryAppearance?.imageUrls?.[primaryAppearance.selectedIndex!] || primaryAppearance?.imageUrl)\n                        : (primaryAppearance?.imageUrl || (primaryAppearance?.imageUrls && primaryAppearance.imageUrls.length > 0 ? primaryAppearance.imageUrls[0] : null))\n                    const primarySelected = !!primaryImageUrl\n\n                    return (\n                        <div\n                            key={character.id}\n                            id={`project-character-${character.id}`}\n                            className={`glass-surface rounded-xl p-4 scroll-mt-24 transition-all duration-700 ${highlightedCharacterId === character.id ? 'ring-2 ring-[var(--glass-focus-ring)] bg-[var(--glass-tone-info-bg)]/40' : ''}`}\n                        >\n                            {/* 角色标题 */}\n                            <div className=\"flex items-center justify-between border-b border-[var(--glass-stroke-base)] pb-2\">\n                                <div className=\"flex items-center gap-3\">\n                                    <h3 className=\"text-base font-semibold text-[var(--glass-text-primary)]\">{character.name}</h3>\n                                    <span className=\"text-xs text-[var(--glass-text-tertiary)]\">\n                                        {t(\"character.assetCount\", { count: sortedAppearances.length })}\n                                    </span>\n                                </div>\n                                <div className=\"flex items-center gap-2\">\n                                    {/* 从资产中心复制按钮 */}\n                                    <button\n                                        onClick={() => onCopyFromGlobal(character.id)}\n                                        className=\"text-xs text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-tone-info-fg)] flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-[var(--glass-tone-info-bg)] transition-colors\"\n                                    >\n                                        <AppIcon name=\"copy\" className=\"w-4 h-4\" />\n                                        {t(\"character.copyFromGlobal\")}\n                                    </button>\n                                    <button\n                                        onClick={() => onDeleteCharacter(character.id)}\n                                        className=\"text-xs text-[var(--glass-tone-danger-fg)] hover:text-[var(--glass-tone-danger-fg)] flex items-center gap-1\"\n                                    >\n                                        <AppIcon name=\"trash\" className=\"w-4 h-4\" />\n                                        {t(\"character.delete\")}\n                                    </button>\n                                </div>\n                            </div>\n\n                            {/* 形象网格 */}\n                            <div className=\"grid grid-cols-2 sm:grid-cols-3 gap-3\">\n                                {sortedAppearances.map(appearance => {\n                                    const isPrimary = appearance.appearanceIndex === (primaryAppearance?.appearanceIndex ?? PRIMARY_APPEARANCE_INDEX)\n                                    return (\n                                        <CharacterCard\n                                            key={`${character.id}-${appearance.appearanceIndex}`}\n                                            character={character}\n                                            appearance={appearance}\n                                            onEdit={() => onEditAppearance(character.id, character.name, appearance, character.introduction)}\n                                            onDelete={() => onDeleteCharacter(character.id)}\n                                            onDeleteAppearance={() => appearance.id && onDeleteAppearance(character.id, appearance.id)}\n                                            onRegenerate={(count) => {\n                                                // 获取有效图片数量\n                                                const imageUrls = appearance.imageUrls || []\n                                                const validImageCount = imageUrls.filter(url => !!url).length\n\n                                                _ulogInfo('[CharacterSection] 重新生成判断:', {\n                                                    characterName: character.name,\n                                                    appearanceIndex: appearance.appearanceIndex,\n                                                    imageUrls,\n                                                    validImageCount,\n                                                    selectedIndex: appearance.selectedIndex\n                                                })\n\n                                                // 单图：重新生成单张\n                                                if (validImageCount === 1) {\n                                                    const selectedIndex = appearance.selectedIndex ?? 0\n                                                    const taskKey = `character-${character.id}-${appearance.appearanceIndex}-${selectedIndex}`\n                                                    _ulogInfo('[CharacterSection] 调用单张重新生成, imageIndex:', selectedIndex)\n                                                    onRegisterTransientTaskKey(taskKey)\n                                                    void onRegenerateSingle(character.id, appearance.id, selectedIndex).catch(() => {\n                                                        onClearTaskKey(taskKey)\n                                                    })\n                                                }\n                                                // 多图或无图：重新生成整组\n                                                else {\n                                                    const taskKey = `character-${character.id}-${appearance.appearanceIndex}-group`\n                                                    _ulogInfo('[CharacterSection] 调用整组重新生成')\n                                                    onRegisterTransientTaskKey(taskKey)\n                                                    void onRegenerateGroup(character.id, appearance.id, count).catch(() => {\n                                                        onClearTaskKey(taskKey)\n                                                    })\n                                                }\n                                            }}\n                                            onGenerate={(count) => {\n                                                const taskKey = `character-${character.id}-${appearance.appearanceIndex}-group`\n                                                onRegisterTransientTaskKey(taskKey)\n                                                void handleGenerateImage('character', character.id, appearance.id, count).catch(() => {\n                                                    onClearTaskKey(taskKey)\n                                                })\n                                            }}\n                                            onUndo={() => onUndo(character.id, appearance.id)}\n                                            onImageClick={onImageClick}\n                                            showDeleteButton={true}\n                                            appearanceCount={sortedAppearances.length}\n                                            onSelectImage={onSelectImage}\n                                            activeTaskKeys={activeTaskKeys}\n                                            onClearTaskKey={onClearTaskKey}\n                                            onImageEdit={(charId, _appearanceId, imageIndex) => onImageEdit(charId, appearance.id, imageIndex, character.name)}\n                                            isPrimaryAppearance={isPrimary}\n                                            primaryAppearanceSelected={primarySelected}\n                                            projectId={projectId}\n                                            onConfirmSelection={onConfirmSelection}\n                                            onVoiceChange={(characterId: string, customVoiceUrl?: string) => customVoiceUrl && onVoiceChange(characterId, customVoiceUrl)}\n                                            onVoiceDesign={onVoiceDesign}\n                                            onVoiceSelectFromHub={onVoiceSelectFromHub}\n                                        />\n                                    )\n                                })}\n                            </div>\n                        </div>\n                    )\n                })}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/ImageEditModal.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\n\n/**\n * 图片编辑弹窗 - 统一的 AI 修图组件\n * 支持角色和场景图片的 AI 编辑\n */\n\nimport { useState, useRef } from 'react'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ImageEditModalProps {\n    type: 'character' | 'location'\n    name: string\n    onClose: () => void\n    onConfirm: (modifyPrompt: string, extraImageUrls?: string[]) => void\n}\n\nexport default function ImageEditModal({\n    type,\n    name,\n    onClose,\n    onConfirm\n}: ImageEditModalProps) {\n    const t = useTranslations('assets')\n    const [modifyPrompt, setModifyPrompt] = useState('')\n    const [editImages, setEditImages] = useState<string[]>([])\n    const fileInputRef = useRef<HTMLInputElement>(null)\n\n    const title = type === 'character' ? t('imageEdit.editCharacterImage') : t('imageEdit.editLocationImage')\n    const subtitle = type === 'character'\n        ? t('imageEdit.characterLabel', { name })\n        : t('imageEdit.locationLabel', { name })\n\n    const handleSubmit = () => {\n        if (!modifyPrompt.trim()) {\n            alert(t('modal.designInstruction'))\n            return\n        }\n        onConfirm(modifyPrompt, editImages.length > 0 ? editImages : undefined)\n    }\n\n    // 处理粘贴事件\n    const handlePaste = async (e: React.ClipboardEvent) => {\n        const items = e.clipboardData?.items\n        if (!items) return\n\n        for (const item of Array.from(items)) {\n            if (item.type.startsWith('image/')) {\n                e.preventDefault()\n                const file = item.getAsFile()\n                if (file) {\n                    const reader = new FileReader()\n                    reader.onload = (e) => {\n                        const base64 = e.target?.result as string\n                        setEditImages(prev => [...prev, base64])\n                    }\n                    reader.readAsDataURL(file)\n                }\n            }\n        }\n    }\n\n    // 处理文件上传\n    const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {\n        const files = e.target.files\n        if (!files) return\n\n        Array.from(files).forEach(file => {\n            const reader = new FileReader()\n            reader.onload = (e) => {\n                const base64 = e.target?.result as string\n                setEditImages(prev => [...prev, base64])\n            }\n            reader.readAsDataURL(file)\n        })\n\n        if (fileInputRef.current) {\n            fileInputRef.current.value = ''\n        }\n    }\n\n    const removeImage = (index: number) => {\n        setEditImages(prev => prev.filter((_, i) => i !== index))\n    }\n\n    return (\n        <div className=\"fixed inset-0 bg-[var(--glass-overlay)] z-50 flex items-center justify-center p-4\">\n            <div\n                className=\"bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto\"\n                onPaste={handlePaste}\n            >\n                <div className=\"p-6 border-b\">\n                    <h3 className=\"text-lg font-bold text-[var(--glass-text-primary)]\">{title}</h3>\n                    <p className=\"text-sm text-[var(--glass-text-tertiary)] mt-1\">{subtitle} · {t('imageEdit.subtitle')}</p>\n                </div>\n                <div className=\"p-6 space-y-4\">\n                    <div>\n                        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">{t('imageEdit.editInstruction')}</label>\n                        <textarea\n                            value={modifyPrompt}\n                            onChange={(e) => setModifyPrompt(e.target.value)}\n                            placeholder={type === 'character'\n                                ? t('imageEdit.characterPlaceholder')\n                                : t('imageEdit.locationPlaceholder')\n                            }\n                            className=\"w-full h-24 px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] resize-none\"\n                            autoFocus\n                        />\n                    </div>\n                    <div>\n                        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">\n                            {t('imageEdit.referenceImages')} <span className=\"text-[var(--glass-text-tertiary)] font-normal\">{t('imageEdit.referenceImagesHint')}</span>\n                        </label>\n                        <input\n                            ref={fileInputRef}\n                            type=\"file\"\n                            accept=\"image/*\"\n                            multiple\n                            onChange={handleImageUpload}\n                            className=\"hidden\"\n                        />\n                        <div className=\"flex flex-wrap gap-2\">\n                            {editImages.map((img, idx) => (\n                                <div key={idx} className=\"relative w-16 h-16\">\n                                    <MediaImageWithLoading\n                                        src={img}\n                                        alt=\"\"\n                                        containerClassName=\"w-full h-full rounded-lg\"\n                                        className=\"w-full h-full object-cover rounded-lg\"\n                                    />\n                                    <button\n                                        onClick={() => removeImage(idx)}\n                                        className=\"absolute -top-1 -right-1 w-5 h-5 bg-[var(--glass-tone-danger-fg)] text-white rounded-full text-xs flex items-center justify-center hover:bg-[var(--glass-tone-danger-fg)]\"\n                                    >\n                                        <AppIcon name=\"closeSm\" className=\"h-3 w-3\" />\n                                    </button>\n                                </div>\n                            ))}\n                            <button\n                                onClick={() => fileInputRef.current?.click()}\n                                className=\"w-16 h-16 border-2 border-dashed border-[var(--glass-stroke-strong)] rounded-lg flex items-center justify-center text-[var(--glass-text-tertiary)] hover:border-[var(--glass-stroke-focus)] hover:text-[var(--glass-tone-info-fg)] transition-colors\"\n                            >\n                                <AppIcon name=\"plus\" className=\"w-6 h-6\" />\n                            </button>\n                        </div>\n                    </div>\n                </div>\n                <div className=\"p-6 border-t flex justify-end gap-3\">\n                    <button\n                        onClick={onClose}\n                        className=\"px-4 py-2 text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors\"\n                    >\n                        {t(\"common.cancel\")}\n                    </button>\n                    <button\n                        onClick={handleSubmit}\n                        disabled={!modifyPrompt.trim()}\n                        className=\"px-4 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n                    >\n                        {t('imageEdit.startEditing')}\n                    </button>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\n/**\n * 场景卡片组件 - 支持多图片选择\n * 布局：上面名字+描述，下面三张图片\n */\n\nimport { useState, useRef } from 'react'\nimport { Location } from '@/types/project'\nimport { shouldShowError } from '@/lib/error-utils'\nimport { useUploadProjectLocationImage } from '@/lib/query/mutations'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\nimport LocationCardHeader from './location-card/LocationCardHeader'\nimport LocationImageList from './location-card/LocationImageList'\nimport LocationCardActions from './location-card/LocationCardActions'\nimport { getImageGenerationCountOptions } from '@/lib/image-generation/count'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\nimport { countGeneratedImageSlots, resolveDisplayImageSlots } from '@/lib/image-generation/slot-state'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface LocationCardProps {\n  location: Location\n  onEdit: () => void\n  onDelete: () => void\n  onRegenerate: (count?: number) => void\n  onGenerate: (count?: number) => void\n  onUndo?: () => void  // 撤回到上一版本\n  onImageClick: (imageUrl: string) => void\n  onSelectImage?: (locationId: string, imageIndex: number | null) => void\n  onImageEdit?: (locationId: string, imageIndex: number) => void  // 新增：图片编辑\n  onCopyFromGlobal?: () => void\n  activeTaskKeys?: Set<string>\n  onClearTaskKey?: (key: string) => void\n  projectId: string\n  onConfirmSelection?: (locationId: string) => void\n}\n\nexport default function LocationCard({\n  location,\n  onEdit,\n  onDelete,\n  onRegenerate,\n  onGenerate,\n  onUndo,\n  onImageClick,\n  onSelectImage,\n  onImageEdit,\n  onCopyFromGlobal,\n  activeTaskKeys = new Set(),\n  projectId,\n  onConfirmSelection\n}: LocationCardProps) {\n  // 🔥 使用 mutation\n  const uploadImage = useUploadProjectLocationImage(projectId)\n  const t = useTranslations('assets')\n  const { count: generationCount, setCount: setGenerationCount } = useImageGenerationCount('location')\n  const fileInputRef = useRef<HTMLInputElement>(null)\n  const [pendingUploadIndex, setPendingUploadIndex] = useState<number | undefined>(undefined)\n  const [isConfirmingSelection, setIsConfirmingSelection] = useState(false)\n\n  // 触发文件选择\n  const triggerUpload = (imageIndex?: number) => {\n    setPendingUploadIndex(imageIndex)\n    fileInputRef.current?.click()\n  }\n\n  // 处理图片上传\n  const handleUpload = () => {\n    const file = fileInputRef.current?.files?.[0]\n    if (!file) return\n\n    const uploadIndex = pendingUploadIndex\n\n    uploadImage.mutate(\n      {\n        file,\n        locationId: location.id,\n        imageIndex: uploadIndex,\n        labelText: location.name\n      },\n      {\n        onSuccess: () => {\n          alert(t('image.uploadSuccess'))\n        },\n        onError: (error) => {\n          if (shouldShowError(error)) {\n            alert(t('image.uploadFailedError', { error: error.message }))\n          }\n        },\n        onSettled: () => {\n          setPendingUploadIndex(undefined)\n          if (fileInputRef.current) {\n            fileInputRef.current.value = ''\n          }\n        }\n      }\n    )\n  }\n\n  const orderedImages = [...(location.images || [])].sort((left, right) => left.imageIndex - right.imageIndex)\n  const imagesWithUrl = orderedImages.filter((img) => img.imageUrl)\n  const generatedImageCount = countGeneratedImageSlots(orderedImages)\n\n  // 获取选中的图片\n  const selectedImage = location.selectedImageId\n    ? orderedImages.find((img) => img.id === location.selectedImageId)\n    : orderedImages.find((img) => img.isSelected)\n  const selectedIndex = selectedImage?.imageIndex ?? null\n\n  // 当前显示的图片及其 imageIndex\n  const currentImageUrl = selectedImage?.imageUrl || imagesWithUrl[0]?.imageUrl || null\n  const currentImageIndex = selectedIndex ?? imagesWithUrl[0]?.imageIndex ?? 0\n\n  const isImageTaskRunning = (imageIndex: number) => {\n    return activeTaskKeys.has(`location-${location.id}-${imageIndex}`)\n  }\n\n  const isGroupTaskRunning = activeTaskKeys.has(`location-${location.id}-group`)\n\n  const isAnyTaskRunning = isGroupTaskRunning || Array.from(activeTaskKeys).some(key =>\n    key.startsWith(`location-${location.id}`)\n  )\n\n  const locationTaskRunning = (location.images || []).some((image) => !!image.imageTaskRunning)\n  const locationTaskPresentation = locationTaskRunning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: currentImageUrl ? 'regenerate' : 'generate',\n      resource: 'image',\n      hasOutput: !!currentImageUrl,\n    })\n    : null\n  const fallbackRunningPresentation = isAnyTaskRunning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'regenerate',\n      resource: 'image',\n      hasOutput: !!currentImageUrl,\n    })\n    : null\n  const displayTaskPresentation = locationTaskPresentation || fallbackRunningPresentation\n  const confirmingSelectionState = isConfirmingSelection\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'process',\n      resource: 'image',\n      hasOutput: !!currentImageUrl,\n    })\n    : null\n  const uploadPendingState = uploadImage.isPending\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'process',\n      resource: 'image',\n      hasOutput: !!currentImageUrl,\n    })\n    : null\n\n  // 统一任务态 + 前端瞬时提交态\n  const isTaskRunning =\n    locationTaskRunning ||\n    isAnyTaskRunning\n\n  const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {\n    hasRunningTask: isTaskRunning,\n    requestedCount: generationCount,\n  })\n  const displaySlotCount = displaySelectionImages.length\n  const hasMultipleImages = generatedImageCount > 1\n\n  // 检查是否有历史版本（用于撤回功能）\n  const hasPreviousVersion = location.images?.some(img => img.previousImageUrl) || false\n\n  const showSelectionMode = displaySlotCount > 1\n\n  // 选择模式：显示名字在上，三张图片在下\n  if (showSelectionMode) {\n    const selectionStatusText = isTaskRunning || generatedImageCount < displaySlotCount\n      ? t('image.generatedProgress', { generated: generatedImageCount, total: displaySlotCount })\n      : selectedIndex !== null\n        ? t('image.optionSelected', { number: selectedIndex + 1 })\n        : t('image.selectFirst')\n\n    const selectionHeaderActions = (\n      <>\n        <ImageGenerationInlineCountButton\n          prefix={isGroupTaskRunning ? (\n            <TaskStatusInline state={displayTaskPresentation} className=\"[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]\" />\n          ) : (\n            <>\n              <AppIcon name=\"refresh\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n              <span className=\"text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5\">{t('image.regenCountPrefix')}</span>\n            </>\n          )}\n          suffix={<span className=\"text-[10px] font-medium text-[var(--glass-tone-info-fg)]\">{t('image.regenCountSuffix')}</span>}\n          value={generationCount}\n          options={getImageGenerationCountOptions('location')}\n          onValueChange={setGenerationCount}\n          onClick={() => onRegenerate(generationCount)}\n          disabled={isTaskRunning || isAnyTaskRunning || uploadImage.isPending}\n          ariaLabel={t('image.regenCountAriaLabel')}\n          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\"\n          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\"\n        />\n        {onUndo && hasPreviousVersion && (\n          <button\n            onClick={onUndo}\n            disabled={isTaskRunning || isAnyTaskRunning}\n            className=\"w-6 h-6 rounded hover:bg-[var(--glass-tone-warning-bg)] flex items-center justify-center transition-colors disabled:opacity-50\"\n            title={t('image.undo')}\n          >\n            <AppIcon name=\"undo\" className=\"w-4 h-4 text-[var(--glass-tone-warning-fg)]\" />\n          </button>\n        )}\n        <button\n          onClick={onDelete}\n          className=\"w-6 h-6 rounded hover:bg-[var(--glass-tone-danger-bg)] flex items-center justify-center transition-colors\"\n          title={t('location.delete')}\n        >\n          <AppIcon name=\"trash\" className=\"w-4 h-4 text-[var(--glass-tone-danger-fg)]\" />\n        </button>\n      </>\n    )\n\n    return (\n      <div className=\"col-span-3 glass-surface-elevated p-4 transition-all\">\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          accept=\"image/*\"\n          onChange={() => handleUpload()}\n          className=\"hidden\"\n        />\n        <LocationCardHeader\n          mode=\"selection\"\n          locationName={location.name}\n          summary={location.summary}\n          selectedIndex={selectedIndex}\n          statusText={selectionStatusText}\n          actions={selectionHeaderActions}\n        />\n\n        <LocationImageList\n          mode=\"selection\"\n          locationId={location.id}\n          locationName={location.name}\n          images={displaySelectionImages}\n          selectedImageId={location.selectedImageId}\n          selectedIndex={selectedIndex}\n          isGroupTaskRunning={isGroupTaskRunning}\n          isImageTaskRunning={isImageTaskRunning}\n          displayTaskPresentation={displayTaskPresentation}\n          onImageClick={onImageClick}\n          onSelectImage={onSelectImage}\n        />\n\n        <LocationCardActions\n          mode=\"selection\"\n          selectedIndex={selectedIndex}\n          isConfirmingSelection={isConfirmingSelection}\n          confirmingSelectionState={confirmingSelectionState}\n          onConfirmSelection={selectedIndex !== null && onConfirmSelection\n            ? () => {\n              setIsConfirmingSelection(true)\n              onConfirmSelection(location.id)\n            }\n            : undefined}\n        />\n      </div>\n    )\n  }\n\n  // 单图模式\n  const singleOverlayActions = (\n    <>\n      <button\n        onClick={() => triggerUpload(selectedIndex !== null ? selectedIndex : 0)}\n        disabled={uploadImage.isPending || isTaskRunning || isAnyTaskRunning}\n        className=\"w-7 h-7 rounded-full bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-tone-success-fg)] hover:text-white flex items-center justify-center transition-all shadow-sm disabled:opacity-50\"\n        title={currentImageUrl ? t('image.uploadReplace') : t('image.upload')}\n      >\n        {uploadImage.isPending ? (\n          <TaskStatusInline state={uploadPendingState} className=\"[&_span]:sr-only [&_svg]:text-current\" />\n        ) : (\n          <AppIcon name=\"upload\" className=\"w-4 h-4 text-[var(--glass-tone-success-fg)]\" />\n        )}\n      </button>\n      {!isTaskRunning && currentImageUrl && onImageEdit && (\n        <button\n          onClick={() => onImageEdit(location.id, currentImageIndex)}\n          className=\"w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-sm\"\n          style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}\n          title={t('image.edit')}\n        >\n          <AppIcon name=\"edit\" className=\"w-4 h-4 text-white\" />\n        </button>\n      )}\n      <button\n        onClick={() => onRegenerate()}\n        disabled={uploadImage.isPending || isTaskRunning}\n        className={`w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-sm active:scale-90 ${isTaskRunning\n          ? 'bg-[var(--glass-tone-success-fg)] hover:bg-[var(--glass-tone-success-fg)]'\n          : 'bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-bg-surface)]'\n          }`}\n        title={isTaskRunning ? t('image.regenerateStuck') : t('location.regenerateImage')}\n      >\n        {isGroupTaskRunning ? (\n          <TaskStatusInline state={displayTaskPresentation} className=\"[&_span]:sr-only [&_svg]:text-white\" />\n        ) : (\n          <AppIcon name=\"refresh\" className={`w-4 h-4 ${isTaskRunning ? 'text-white' : 'text-[var(--glass-text-secondary)]'}`} />\n        )}\n      </button>\n      {!isTaskRunning && currentImageUrl && onUndo && hasPreviousVersion && (\n        <button\n          onClick={onUndo}\n          disabled={isTaskRunning || isAnyTaskRunning}\n          className=\"w-7 h-7 rounded-full bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-tone-warning-fg)] hover:text-white flex items-center justify-center transition-all shadow-sm disabled:opacity-50\"\n          title={t('image.undo')}\n        >\n          <AppIcon name=\"undo\" className=\"w-4 h-4 text-[var(--glass-tone-warning-fg)] hover:text-white\" />\n        </button>\n      )}\n    </>\n  )\n\n  const compactHeaderActions = (\n    <>\n      {onCopyFromGlobal && (\n        <button\n          onClick={onCopyFromGlobal}\n          className=\"flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-tone-info-bg)] flex items-center justify-center transition-colors\"\n          title={t('character.copyFromGlobal')}\n        >\n          <AppIcon name=\"copy\" className=\"w-3.5 h-3.5 text-[var(--glass-tone-info-fg)]\" />\n        </button>\n      )}\n      <button\n        onClick={onEdit}\n        className=\"flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-bg-muted)] flex items-center justify-center transition-colors\"\n        title={t('location.edit')}\n      >\n        <AppIcon name=\"edit\" className=\"w-3.5 h-3.5 text-[var(--glass-text-secondary)]\" />\n      </button>\n      <button\n        onClick={onDelete}\n        className=\"flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-tone-danger-bg)] flex items-center justify-center transition-colors\"\n        title={t('location.delete')}\n      >\n        <AppIcon name=\"trash\" className=\"w-3.5 h-3.5 text-[var(--glass-tone-danger-fg)]\" />\n      </button>\n    </>\n  )\n\n  const firstImage = location.images?.[0]\n  const hasDescription = !!firstImage?.description\n\n  return (\n    <div className=\"flex flex-col gap-2 glass-surface-elevated p-3\">\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        accept=\"image/*\"\n        onChange={() => handleUpload()}\n        className=\"hidden\"\n      />\n      <div className=\"relative\">\n        <LocationImageList\n          mode=\"single\"\n          locationName={location.name}\n          currentImageUrl={currentImageUrl}\n          selectedIndex={selectedIndex}\n          hasMultipleImages={hasMultipleImages}\n          isTaskRunning={isTaskRunning}\n          displayTaskPresentation={displayTaskPresentation}\n          imageErrorMessage={firstImage?.lastError?.message || firstImage?.imageErrorMessage}\n          onImageClick={onImageClick}\n          overlayActions={singleOverlayActions}\n        />\n      </div>\n\n      <LocationCardHeader\n        mode=\"compact\"\n        locationName={location.name}\n        summary={location.summary}\n        actions={compactHeaderActions}\n      />\n\n      <LocationCardActions\n        mode=\"compact\"\n        currentImageUrl={currentImageUrl}\n        isTaskRunning={isTaskRunning}\n        hasDescription={hasDescription}\n        generationCount={generationCount}\n        onGenerationCountChange={setGenerationCount}\n        onGenerate={onGenerate}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationEditModal.tsx",
    "content": "'use client'\n\nimport { LocationEditModal as SharedLocationEditModal } from '@/components/shared/assets/LocationEditModal'\n\ninterface LocationEditModalProps {\n  locationId: string\n  locationName: string\n  description: string\n  projectId: string\n  onClose: () => void\n  onSave: (locationId: string) => void\n  onUpdate: (newDescription: string) => void\n  onNameUpdate?: (newName: string) => void\n  isTaskRunning?: boolean\n}\n\nexport default function LocationEditModal({\n  locationId,\n  locationName,\n  description,\n  projectId,\n  onClose,\n  onSave,\n  onUpdate,\n  onNameUpdate,\n  isTaskRunning = false,\n}: LocationEditModalProps) {\n  return (\n    <SharedLocationEditModal\n      mode=\"project\"\n      locationId={locationId}\n      locationName={locationName}\n      description={description}\n      projectId={projectId}\n      onClose={onClose}\n      onSave={onSave}\n      onUpdate={onUpdate}\n      onNameUpdate={onNameUpdate}\n      isTaskRunning={isTaskRunning}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection.tsx",
    "content": "'use client'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\n/**\n * LocationSection - 场景资产区块组件\n * 从 AssetsStage.tsx 提取，负责场景列表的展示和操作\n * \n * 🔥 V6.5 重构：内部直接订阅 useProjectAssets，消除 props drilling\n */\n\nimport { Location } from '@/types/project'\nimport { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'\nimport LocationCard from './LocationCard'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface LocationSectionProps {\n    // 🔥 V6.5 删除：locations prop - 现在内部直接订阅\n    projectId: string\n    activeTaskKeys: Set<string>\n    onClearTaskKey: (key: string) => void\n    onRegisterTransientTaskKey: (key: string) => void\n    // 回调函数\n    onAddLocation: () => void\n    onDeleteLocation: (locationId: string) => void\n    onEditLocation: (location: Location) => void\n    // 🔥 V6.6 重构：重命名为 handleGenerateImage\n    handleGenerateImage: (type: 'character' | 'location', id: string, appearanceId?: string, count?: number) => Promise<void>\n    onSelectImage: (locationId: string, imageIndex: number | null) => void\n    onConfirmSelection: (locationId: string) => void\n    onRegenerateSingle: (locationId: string, imageIndex: number) => Promise<void>\n    onRegenerateGroup: (locationId: string, count?: number) => Promise<void>\n    onUndo: (locationId: string) => void\n    onImageClick: (imageUrl: string) => void\n    onImageEdit: (locationId: string, imageIndex: number, locationName: string) => void\n    onCopyFromGlobal: (locationId: string) => void  // 🆕 从资产中心复制\n}\n\nexport default function LocationSection({\n    // 🔥 V6.5 删除：locations prop - 现在内部直接订阅\n    projectId,\n    activeTaskKeys,\n    onClearTaskKey,\n    onRegisterTransientTaskKey,\n    onAddLocation,\n    onDeleteLocation,\n    onEditLocation,\n    handleGenerateImage,\n    onSelectImage,\n    onConfirmSelection,\n    onRegenerateSingle,\n    onRegenerateGroup,\n    onUndo,\n    onImageClick,\n    onImageEdit,\n    onCopyFromGlobal\n}: LocationSectionProps) {\n    const t = useTranslations('assets')\n\n    // 🔥 V6.5 重构：直接订阅缓存，消除 props drilling\n    const { data: assets } = useProjectAssets(projectId)\n    const locations: Location[] = assets?.locations ?? []\n\n    return (\n        <div className=\"glass-surface p-6\">\n            <div className=\"flex items-center justify-between mb-6\">\n                <div className=\"flex items-center gap-3\">\n                    <span className=\"inline-flex h-9 w-9 items-center justify-center rounded-xl bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]\">\n                        <AppIcon name=\"imageLandscape\" className=\"h-5 w-5\" />\n                    </span>\n                    <h3 className=\"text-lg font-bold text-[var(--glass-text-primary)]\">{t(\"stage.locationAssets\")}</h3>\n                    <span className=\"text-sm text-[var(--glass-text-tertiary)] bg-[var(--glass-bg-muted)]/50 px-2 py-1 rounded-lg\">\n                        {t(\"stage.locationCounts\", { count: locations.length })}\n                    </span>\n                </div>\n                <button\n                    onClick={onAddLocation}\n                    className=\"glass-btn-base glass-btn-primary flex items-center gap-2 px-4 py-2 font-medium\"\n                >\n                    + {t(\"location.add\")}\n                </button>\n            </div>\n\n            <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-6 gap-6\">\n                {locations.map(location => (\n                    <LocationCard\n                        key={location.id}\n                        location={location}\n                        onEdit={() => onEditLocation(location)}\n                        onDelete={() => onDeleteLocation(location.id)}\n                        onRegenerate={(count) => {\n                            // 获取有效图片数量\n                            const validImages = location.images?.filter(img => img.imageUrl) || []\n\n                            _ulogInfo('[LocationSection] 重新生成判断:', {\n                                locationName: location.name,\n                                images: location.images,\n                                validImages,\n                                validImageCount: validImages.length\n                            })\n\n                            // 单图：重新生成单张\n                            if (validImages.length === 1) {\n                                const imageIndex = validImages[0].imageIndex\n                                const taskKey = `location-${location.id}-${imageIndex}`\n                                _ulogInfo('[LocationSection] 调用单张重新生成, imageIndex:', imageIndex)\n                                onRegisterTransientTaskKey(taskKey)\n                                void onRegenerateSingle(location.id, imageIndex).catch(() => {\n                                    onClearTaskKey(taskKey)\n                                })\n                            }\n                            // 多图或无图：重新生成整组\n                            else {\n                                const taskKey = `location-${location.id}-group`\n                                _ulogInfo('[LocationSection] 调用整组重新生成')\n                                onRegisterTransientTaskKey(taskKey)\n                                void onRegenerateGroup(location.id, count).catch(() => {\n                                    onClearTaskKey(taskKey)\n                                })\n                            }\n                        }}\n                        onGenerate={(count) => {\n                            const taskKey = `location-${location.id}-group`\n                            onRegisterTransientTaskKey(taskKey)\n                            void handleGenerateImage('location', location.id, undefined, count).catch(() => {\n                                onClearTaskKey(taskKey)\n                            })\n                        }}\n                        onUndo={() => onUndo(location.id)}\n                        onImageClick={onImageClick}\n                        onSelectImage={onSelectImage}\n                        onImageEdit={(locId, imgIdx) => onImageEdit(locId, imgIdx, location.name)}\n                        onCopyFromGlobal={() => onCopyFromGlobal(location.id)}\n                        activeTaskKeys={activeTaskKeys}\n                        onClearTaskKey={onClearTaskKey}\n                        projectId={projectId}\n                        onConfirmSelection={onConfirmSelection}\n                    />\n                ))}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/UnconfirmedProfilesSection.tsx",
    "content": "'use client'\n\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport CharacterProfileCard from './CharacterProfileCard'\nimport { parseProfileData } from '@/types/character-profile'\nimport type { Character } from '@/types/project'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\n\ninterface UnconfirmedProfilesSectionProps {\n  unconfirmedCharacters: Character[]\n  confirmTitle: string\n  confirmHint: string\n  confirmAllLabel: string\n  batchConfirming: boolean\n  batchConfirmingState: TaskPresentationState | null\n  deletingCharacterId: string | null\n  isConfirmingCharacter: (characterId: string) => boolean\n  onBatchConfirm: () => void\n  onEditProfile: (characterId: string, characterName: string) => void\n  onConfirmProfile: (characterId: string) => void\n  onUseExistingProfile: (characterId: string) => void\n  onDeleteProfile: (characterId: string) => void\n}\n\nexport default function UnconfirmedProfilesSection({\n  unconfirmedCharacters,\n  confirmTitle,\n  confirmHint,\n  confirmAllLabel,\n  batchConfirming,\n  batchConfirmingState,\n  deletingCharacterId,\n  isConfirmingCharacter,\n  onBatchConfirm,\n  onEditProfile,\n  onConfirmProfile,\n  onUseExistingProfile,\n  onDeleteProfile,\n}: UnconfirmedProfilesSectionProps) {\n  if (unconfirmedCharacters.length === 0) {\n    return null\n  }\n\n  return (\n    <div className=\"bg-[var(--glass-tone-warning-bg)] border border-[var(--glass-stroke-warning)] rounded-xl p-4\">\n      <div className=\"flex items-center justify-between mb-4\">\n        <div>\n          <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">{confirmTitle}</h3>\n          <p className=\"text-sm text-[var(--glass-text-secondary)]\">{confirmHint}</p>\n        </div>\n        <button\n          onClick={onBatchConfirm}\n          disabled={batchConfirming}\n          className=\"glass-btn-base glass-btn-primary px-4 py-2 disabled:opacity-50 flex items-center gap-2\"\n        >\n          {batchConfirming ? (\n            <TaskStatusInline state={batchConfirmingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n          ) : (\n            confirmAllLabel\n          )}\n        </button>\n      </div>\n      <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3\">\n        {unconfirmedCharacters.map((character) => {\n          const profileData = parseProfileData(character.profileData!)\n          if (!profileData) return null\n          return (\n            <CharacterProfileCard\n              key={character.id}\n              characterId={character.id}\n              name={character.name}\n              profileData={profileData}\n              onEdit={() => onEditProfile(character.id, character.name)}\n              onConfirm={() => onConfirmProfile(character.id)}\n              onUseExisting={() => onUseExistingProfile(character.id)}\n              onDelete={() => onDeleteProfile(character.id)}\n              isConfirming={isConfirmingCharacter(character.id)}\n              isDeleting={deletingCharacterId === character.id}\n            />\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/VoiceSettings.tsx",
    "content": "'use client'\n\n/**\n * 音色设置组件 - 从 CharacterCard 提取\n * 支持上传自定义音频和 AI 声音设计\n */\n\nimport { useRef, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { shouldShowError } from '@/lib/error-utils'\nimport { useUploadProjectCharacterVoice } from '@/lib/query/mutations'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface VoiceSettingsProps {\n    characterId: string\n    characterName: string\n    customVoiceUrl: string | null | undefined\n    projectId: string\n    onVoiceChange?: (characterId: string, customVoiceUrl?: string) => void\n    onVoiceDesign?: (characterId: string, characterName: string) => void\n    onSelectFromHub?: (characterId: string) => void  // 从资产中心选择音色\n    compact?: boolean  // 紧凑模式（单图卡片用）\n}\n\nfunction getErrorMessage(error: unknown, fallback: string): string {\n    if (error instanceof Error) return error.message\n    if (typeof error === 'object' && error !== null) {\n        const message = (error as { message?: unknown }).message\n        if (typeof message === 'string') return message\n    }\n    return fallback\n}\n\nexport default function VoiceSettings({\n    characterId,\n    characterName,\n    customVoiceUrl,\n    projectId,\n    onVoiceChange,\n    onVoiceDesign,\n    onSelectFromHub,\n    compact = false\n}: VoiceSettingsProps) {\n    const t = useTranslations('assets')\n    // 🔥 使用 mutation\n    const uploadVoice = useUploadProjectCharacterVoice(projectId)\n    const voiceFileInputRef = useRef<HTMLInputElement>(null)\n    const audioRef = useRef<HTMLAudioElement | null>(null)\n    const [isPreviewingVoice, setIsPreviewingVoice] = useState(false)\n\n    const hasCustomVoice = !!customVoiceUrl\n\n    const confirmUploadVoice = () => {\n        return window.confirm(t('tts.uploadQwenHint'))\n    }\n\n    // 预览音色（播放/暂停自定义音频）\n    const handlePreviewVoice = async () => {\n        if (!customVoiceUrl) return\n\n        // 如果正在播放，点击则暂停\n        if (isPreviewingVoice && audioRef.current) {\n            audioRef.current.pause()\n            setIsPreviewingVoice(false)\n            return\n        }\n\n        try {\n            if (audioRef.current) {\n                audioRef.current.pause()\n            }\n            const audio = new Audio(customVoiceUrl)\n            audioRef.current = audio\n            audio.play()\n            audio.onended = () => setIsPreviewingVoice(false)\n            audio.onerror = () => setIsPreviewingVoice(false)\n            setIsPreviewingVoice(true)\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                alert(t('tts.previewFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n            }\n            setIsPreviewingVoice(false)\n        }\n    }\n\n    // 上传自定义音频\n    const handleUploadVoice = (e: React.ChangeEvent<HTMLInputElement>) => {\n        const file = e.target.files?.[0]\n        if (!file || !projectId) return\n\n        uploadVoice.mutate(\n            { file, characterId },\n            {\n                onSuccess: (data) => {\n                    const result = (data || {}) as UploadedVoiceResult\n                    onVoiceChange?.(characterId, result.audioUrl)\n                },\n                onError: (error) => {\n                    if (shouldShowError(error)) {\n                        alert(t('tts.uploadFailed', { error: error.message }))\n                    }\n                },\n                onSettled: () => {\n                    if (voiceFileInputRef.current) {\n                        voiceFileInputRef.current.value = ''\n                    }\n                }\n            }\n        )\n    }\n\n    // 紧凑模式样式\n    const containerClass = compact\n        ? 'border border-[var(--glass-stroke-base)] rounded-xl p-3 bg-[var(--glass-bg-surface-strong)]'\n        : 'mt-4 border border-[var(--glass-stroke-base)] rounded-xl p-4 bg-[var(--glass-bg-surface-strong)]'\n\n    const headerClass = compact\n        ? 'flex items-center gap-2 mb-2 pb-2 border-b'\n        : 'flex items-center gap-2 mb-3 pb-2 border-b'\n\n    const iconSize = compact ? 'w-5 h-5' : 'w-6 h-6'\n    const innerIconSize = compact ? 'w-3 h-3' : 'w-3.5 h-3.5'\n\n    return (\n        <div className={containerClass}>\n            <div className={`${headerClass} ${hasCustomVoice ? 'border-[var(--glass-stroke-base)]' : 'border-[var(--glass-stroke-warning)]'}`}>\n                <div className={`${iconSize} rounded-full flex items-center justify-center ${hasCustomVoice ? 'bg-[var(--glass-bg-muted)]' : 'bg-[var(--glass-tone-warning-bg)]'}`}>\n                    <AppIcon name=\"mic\" className={`${innerIconSize} ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`} />\n                </div>\n                <span className={`text-${compact ? 'xs' : 'sm'} font-medium ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`}>\n                    {t('tts.title')}{!hasCustomVoice && <span className=\"text-[var(--glass-tone-warning-fg)]\">({t('tts.noVoice')})</span>}\n                </span>\n            </div>\n\n            {/* 隐藏的音频文件输入 */}\n            <input\n                ref={voiceFileInputRef}\n                type=\"file\"\n                accept=\"audio/*\"\n                onChange={handleUploadVoice}\n                className=\"hidden\"\n            />\n\n            <div className=\"flex flex-wrap gap-2 w-full justify-center\">\n                {/* 上传音频按钮 */}\n                <button\n                    onClick={() => {\n                        if (!confirmUploadVoice()) return\n                        voiceFileInputRef.current?.click()\n                    }}\n                    disabled={uploadVoice.isPending}\n                    className=\"flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-lg text-xs text-[var(--glass-text-secondary)] font-medium hover:border-[var(--glass-stroke-success)] hover:bg-[var(--glass-tone-success-bg)] hover:text-[var(--glass-tone-success-fg)] transition-all relative group whitespace-nowrap\"\n                >\n                    <div className=\"flex items-center justify-center gap-1\">\n                        {hasCustomVoice && <div className=\"w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full flex-shrink-0\"></div>}\n                        <span>{uploadVoice.isPending ? t('tts.uploading') : hasCustomVoice ? t('tts.uploaded') : t('tts.uploadAudio')}</span>\n                    </div>\n                </button>\n\n                {/* 从资产中心选择按钮 */}\n                {onSelectFromHub && (\n                    <button\n                        onClick={() => onSelectFromHub(characterId)}\n                        className=\"flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-focus)] rounded-lg text-xs text-[var(--glass-tone-info-fg)] font-medium hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-all whitespace-nowrap\"\n                    >\n                        <div className=\"flex items-center justify-center gap-1\">\n                            <AppIcon name=\"copy\" className=\"w-3.5 h-3.5 flex-shrink-0\" />\n                            <span>{t('assetLibrary.button')}</span>\n                        </div>\n                    </button>\n                )}\n\n                {/* AI设计按钮 */}\n                {onVoiceDesign && (\n                    <button\n                        onClick={() => onVoiceDesign(characterId, characterName)}\n                        className=\"glass-btn-base glass-btn-primary flex-1 min-w-[80px] px-2 py-1.5 text-xs font-medium whitespace-nowrap\"\n                    >\n                        <div className=\"flex items-center justify-center gap-1\">\n                            <AppIcon name=\"bolt\" className=\"w-3.5 h-3.5 flex-shrink-0\" />\n                            <span>{t('modal.aiDesign')}</span>\n                        </div>\n                    </button>\n                )}\n            </div>\n\n            {/* 试听按钮 - 仅在有音频时显示 */}\n            {hasCustomVoice && (\n                <button\n                    onClick={handlePreviewVoice}\n                    className={`w-full mt-2 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${isPreviewingVoice\n                        ? 'bg-[var(--glass-accent-from)] border-[var(--glass-stroke-focus)] text-white hover:bg-[var(--glass-accent-to)]'\n                        : 'bg-[var(--glass-tone-info-bg)] border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)]'\n                        }`}\n                >\n                    <div className=\"flex items-center justify-center gap-2\">\n                        {isPreviewingVoice ? (\n                            <AppIcon name=\"pause\" className=\"w-4 h-4\" />\n                        ) : (\n                            <AppIcon name=\"play\" className=\"w-4 h-4\" />\n                        )}\n                        {isPreviewingVoice ? t('tts.pause') : t('tts.preview')}\n                    </div>\n                </button>\n            )}\n        </div>\n    )\n}\n    type UploadedVoiceResult = { audioUrl?: string }\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/character-card/CharacterCardActions.tsx",
    "content": "'use client'\n\nimport type { ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\nimport { getImageGenerationCountOptions } from '@/lib/image-generation/count'\n\ntype CharacterCardActionsProps =\n  | {\n    mode: 'selection'\n    selectedIndex: number | null\n    isConfirmingSelection: boolean\n    confirmSelectionState: TaskPresentationState | null\n    onConfirmSelection?: () => void\n    isPrimaryAppearance: boolean\n    voiceSettings: ReactNode\n  }\n  | {\n    mode: 'compact'\n    isPrimaryAppearance: boolean\n    primaryAppearanceSelected: boolean\n    currentImageUrl: string | null | undefined\n    isAppearanceTaskRunning: boolean\n    isAnyTaskRunning: boolean\n    hasDescription: boolean\n    generationCount: number\n    onGenerationCountChange: (value: number) => void\n    onGenerate: (count?: number) => void\n    voiceSettings: ReactNode\n  }\n\nexport default function CharacterCardActions(props: CharacterCardActionsProps) {\n  const t = useTranslations('assets')\n\n  if (props.mode === 'selection') {\n    return (\n      <>\n        <div className=\"mt-3 text-xs text-[var(--glass-text-tertiary)] text-center\">\n          {t('image.selectTip')}\n        </div>\n\n        {props.selectedIndex !== null && (\n          <div className=\"mt-4 flex justify-end\">\n            <button\n              onClick={props.onConfirmSelection}\n              disabled={props.isConfirmingSelection}\n              className=\"px-4 py-2 bg-[var(--glass-tone-success-fg)] text-white rounded-lg hover:bg-[var(--glass-tone-success-fg)] transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm font-medium\"\n            >\n              {props.isConfirmingSelection ? (\n                <TaskStatusInline state={props.confirmSelectionState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n              ) : (\n                <>\n                  <AppIcon name=\"check\" className=\"w-4 h-4\" />\n                  {t('image.confirmOption', { number: props.selectedIndex + 1 })}\n                </>\n              )}\n            </button>\n          </div>\n        )}\n\n        {props.isPrimaryAppearance && props.voiceSettings}\n      </>\n    )\n  }\n\n  return (\n    <>\n      {!props.isPrimaryAppearance && !props.primaryAppearanceSelected ? (\n        <div className=\"w-full py-2 text-xs text-center text-[var(--glass-text-tertiary)] bg-[var(--glass-bg-muted)] rounded border border-dashed border-[var(--glass-stroke-strong)]\">\n          <div className=\"flex items-center justify-center gap-1\">\n            <AppIcon name=\"lock\" className=\"w-3 h-3\" />\n            {t('character.selectPrimaryFirst')}\n          </div>\n        </div>\n      ) : (\n        !props.currentImageUrl && !props.isAppearanceTaskRunning && !props.isAnyTaskRunning && (\n          <ImageGenerationInlineCountButton\n            prefix={<span>{props.isPrimaryAppearance ? t('image.generateCountPrefix') : t('character.generateFromPrimary')}</span>}\n            suffix={<span>{t('image.generateCountSuffix')}</span>}\n            value={props.generationCount}\n            options={getImageGenerationCountOptions('character')}\n            onValueChange={props.onGenerationCountChange}\n            onClick={() => props.onGenerate(props.generationCount)}\n            disabled={!props.hasDescription}\n            ariaLabel={t('image.selectCount')}\n            className={`glass-btn-base flex w-full items-center justify-center gap-1 py-1 text-xs disabled:opacity-50 ${props.isPrimaryAppearance ? 'glass-btn-primary' : 'glass-btn-tone-info'}`}\n            selectClassName=\"appearance-none bg-transparent border-0 pl-0 pr-3 text-xs font-semibold text-current outline-none cursor-pointer leading-none transition-colors\"\n          />\n        )\n      )}\n\n      {props.isPrimaryAppearance && props.voiceSettings}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/character-card/CharacterCardGallery.tsx",
    "content": "'use client'\n\nimport type { ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { resolveErrorDisplay } from '@/lib/errors/display'\nimport TaskStatusOverlay from '@/components/task/TaskStatusOverlay'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { AppIcon } from '@/components/ui/icons'\n\ntype CharacterCardGalleryProps =\n  | {\n    mode: 'selection'\n    characterId: string\n    appearanceId: string\n    characterName: string\n    imageUrlsWithIndex: Array<{ url: string; originalIndex: number }>\n    selectedIndex: number | null\n    isGroupTaskRunning: boolean\n    isImageTaskRunning: (imageIndex: number) => boolean\n    displayTaskPresentation: TaskPresentationState | null\n    onImageClick: (imageUrl: string) => void\n    onSelectImage?: (characterId: string, appearanceId: string, imageIndex: number | null) => void\n  }\n  | {\n    mode: 'single'\n    characterName: string\n    changeReason: string\n    currentImageUrl: string | null | undefined\n    selectedIndex: number | null\n    hasMultipleImages: boolean\n    isAppearanceTaskRunning: boolean\n    displayTaskPresentation: TaskPresentationState | null\n    appearanceErrorMessage?: string | null\n    onImageClick: (imageUrl: string) => void\n    overlayActions: ReactNode\n  }\n\nexport default function CharacterCardGallery(props: CharacterCardGalleryProps) {\n  const t = useTranslations('assets')\n\n  if (props.mode === 'selection') {\n    return (\n      <div className=\"grid grid-cols-3 gap-3\">\n        {props.imageUrlsWithIndex.map(({ url, originalIndex }) => {\n          const isThisSelected = props.selectedIndex === originalIndex\n          const isThisTaskRunning = props.isImageTaskRunning(originalIndex) || props.isGroupTaskRunning\n          return (\n            <div key={originalIndex} className=\"relative group/thumb\">\n              <div\n                onClick={() => props.onImageClick(url)}\n                className={`rounded-lg overflow-hidden border-2 transition-all cursor-pointer relative ${isThisSelected\n                  ? 'border-[var(--glass-stroke-success)] ring-2 ring-[var(--glass-focus-ring)]'\n                  : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'\n                  }`}\n              >\n                <MediaImageWithLoading\n                  src={url}\n                  alt={`${props.characterName} - ${t('image.optionNumber', { number: originalIndex + 1 })}`}\n                  containerClassName=\"w-full min-h-[96px]\"\n                  className=\"w-full h-auto object-contain\"\n                />\n\n                {isThisTaskRunning && (\n                  <TaskStatusOverlay state={props.displayTaskPresentation} />\n                )}\n\n                <div\n                  className={`absolute bottom-2 left-2 flex items-center gap-1 text-white text-xs px-2 py-0.5 rounded ${isThisSelected ? 'bg-[var(--glass-tone-success-fg)]' : 'bg-[var(--glass-overlay)]'\n                    }`}\n                >\n                  <span>{t('image.optionNumber', { number: originalIndex + 1 })}</span>\n                  {isThisSelected && (\n                    <AppIcon name=\"checkTiny\" className=\"h-3 w-3\" />\n                  )}\n                </div>\n\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    if (!isThisTaskRunning) {\n                      props.onSelectImage?.(props.characterId, props.appearanceId, isThisSelected ? null : originalIndex)\n                    }\n                  }}\n                  disabled={isThisTaskRunning}\n                  className={`absolute top-2 right-2 w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-sm ${isThisSelected\n                    ? 'bg-[var(--glass-tone-success-fg)] text-white'\n                    : 'bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-accent-from)] hover:text-white'\n                    } disabled:opacity-50`}\n                  title={isThisSelected ? t('image.cancelSelection') : t('image.useThis')}\n                >\n                  <AppIcon name=\"check\" className=\"w-4 h-4\" />\n                </button>\n              </div>\n            </div>\n          )\n        })}\n      </div>\n    )\n  }\n\n  const appearanceErrorDisplay = resolveErrorDisplay({\n    code: props.appearanceErrorMessage || null,\n    message: props.appearanceErrorMessage || null,\n  })\n\n  return (\n    <div className=\"rounded-lg overflow-hidden border-2 border-[var(--glass-stroke-base)] relative\">\n      {props.currentImageUrl ? (\n        <div className=\"relative w-full\">\n          <MediaImageWithLoading\n            src={props.currentImageUrl}\n            alt={`${props.characterName} - ${props.changeReason}`}\n            containerClassName=\"w-full min-h-[120px]\"\n            className=\"w-full h-auto object-contain cursor-pointer hover:opacity-90 transition-opacity\"\n            onClick={() => props.onImageClick(props.currentImageUrl!)}\n          />\n          {props.selectedIndex !== null && props.hasMultipleImages && (\n            <div className=\"absolute bottom-2 left-2 bg-[var(--glass-tone-success-fg)] text-white text-xs px-2 py-0.5 rounded\">\n              {t('image.optionNumber', { number: props.selectedIndex + 1 })}\n            </div>\n          )}\n        </div>\n      ) : (\n        <div className=\"w-full h-full bg-[var(--glass-bg-muted)] flex items-center justify-center\">\n          {appearanceErrorDisplay && !props.isAppearanceTaskRunning ? (\n            <div className=\"flex flex-col items-center justify-center py-8 px-4 text-center\">\n              <AppIcon name=\"alert\" className=\"w-8 h-8 text-[var(--glass-tone-danger-fg)] mb-2\" />\n              <div className=\"text-[var(--glass-tone-danger-fg)] text-xs font-medium mb-1\">{t('common.generateFailed')}</div>\n              <div className=\"text-[var(--glass-tone-danger-fg)] text-xs max-w-full break-words\">{appearanceErrorDisplay.message}</div>\n            </div>\n          ) : (\n            <AppIcon name=\"userAlt\" className=\"w-8 h-8 text-[var(--glass-text-tertiary)]\" />\n          )}\n        </div>\n      )}\n      {props.isAppearanceTaskRunning && (\n        <TaskStatusOverlay state={props.displayTaskPresentation} />\n      )}\n      {!props.isAppearanceTaskRunning && (\n        <div className=\"absolute top-2 left-2 flex gap-1\">\n          {props.overlayActions}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/character-card/CharacterCardHeader.tsx",
    "content": "'use client'\n\nimport type { ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\n\ntype CharacterCardHeaderProps =\n  | {\n    mode: 'selection'\n    characterName: string\n    changeReason: string\n    isPrimaryAppearance: boolean\n    selectedIndex: number | null\n    actions: ReactNode\n  }\n  | {\n    mode: 'compact'\n    characterName: string\n    changeReason: string\n    actions: ReactNode\n  }\n\nexport default function CharacterCardHeader(props: CharacterCardHeaderProps) {\n  const t = useTranslations('assets')\n\n  if (props.mode === 'selection') {\n    return (\n      <div className=\"flex items-start justify-between mb-4\">\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-center gap-2 mb-1\">\n            <span className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">{props.characterName}</span>\n            <span className=\"text-xs text-[var(--glass-text-tertiary)] bg-[var(--glass-bg-muted)] px-2 py-0.5 rounded\">{props.changeReason}</span>\n            {props.isPrimaryAppearance ? (\n              <span className=\"text-xs text-[var(--glass-tone-success-fg)] bg-[var(--glass-tone-success-bg)] px-2 py-0.5 rounded\">{t('character.primary')}</span>\n            ) : (\n              <span className=\"text-xs text-[var(--glass-tone-info-fg)] bg-[var(--glass-tone-info-bg)] px-2 py-0.5 rounded\">{t('character.secondary')}</span>\n            )}\n          </div>\n          <div className=\"text-xs text-[var(--glass-text-tertiary)]\">\n            {props.selectedIndex !== null ? t('image.optionSelected', { number: props.selectedIndex + 1 }) : t('image.selectFirst')}\n          </div>\n        </div>\n        <div className=\"flex items-center gap-1 ml-2\">{props.actions}</div>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"space-y-1\">\n      <div className=\"flex items-center justify-between gap-1\">\n        <div className=\"text-xs font-semibold text-[var(--glass-text-primary)] truncate\" title={props.characterName}>\n          {props.characterName}\n        </div>\n        <div className=\"flex items-center gap-1\">{props.actions}</div>\n      </div>\n      <div className=\"text-xs text-[var(--glass-text-secondary)] truncate\" title={props.changeReason}>\n        {props.changeReason}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/index.ts",
    "content": "/**\n * Assets Hooks - 资产管理相关的 Custom Hooks\n * 从 AssetsStage.tsx 提取，减少组件复杂度\n */\n\nexport { useCharacterActions } from './useCharacterActions'\nexport { useLocationActions } from './useLocationActions'\nexport { useAssetsCopyFromHub } from './useAssetsCopyFromHub'\nexport { useAssetsGlobalActions } from './useAssetsGlobalActions'\nexport { useAssetsImageEdit } from './useAssetsImageEdit'\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetModals.ts",
    "content": "'use client'\n\n/**\n * useAssetModals - 资产编辑弹窗状态管理\n * 从 AssetsStage.tsx 提取\n * \n * 🔥 V6.5 重构：直接订阅 useProjectAssets，消除 props drilling\n */\n\nimport { useState, useCallback } from 'react'\nimport { CharacterAppearance } from '@/types/project'\nimport { useProjectAssets, type Character, type Location } from '@/lib/query/hooks'\n\n// 编辑弹窗状态类型\ninterface EditingAppearance {\n    characterId: string\n    characterName: string\n    appearanceId: string  // UUID\n    description: string\n    descriptionIndex?: number\n    introduction?: string | null  // 角色介绍\n}\n\ninterface EditingLocation {\n    locationId: string\n    locationName: string\n    description: string\n}\n\ninterface ImageEditModal {\n    locationId: string\n    imageIndex: number\n    locationName: string\n}\n\ninterface CharacterImageEditModal {\n    characterId: string\n    appearanceId: string\n    imageIndex: number\n    characterName: string\n}\n\ninterface UseAssetModalsProps {\n    projectId: string\n}\n\nexport function useAssetModals({\n    projectId\n}: UseAssetModalsProps) {\n    // 🔥 直接订阅缓存 - 消除 props drilling\n    const { data: assets } = useProjectAssets(projectId)\n    const characters = assets?.characters ?? []\n    const locations = assets?.locations ?? []\n\n    // 获取形象列表（内置实现）\n    const getAppearances = useCallback((character: Character): CharacterAppearance[] => {\n        return character.appearances || []\n    }, [])\n\n    // 角色编辑弹窗\n    const [editingAppearance, setEditingAppearance] = useState<EditingAppearance | null>(null)\n    // 场景编辑弹窗\n    const [editingLocation, setEditingLocation] = useState<EditingLocation | null>(null)\n    // 新增弹窗\n    const [showAddCharacter, setShowAddCharacter] = useState(false)\n    const [showAddLocation, setShowAddLocation] = useState(false)\n    // 图片编辑弹窗\n    const [imageEditModal, setImageEditModal] = useState<ImageEditModal | null>(null)\n    const [characterImageEditModal, setCharacterImageEditModal] = useState<CharacterImageEditModal | null>(null)\n    // 全局资产设定弹窗\n    const [showAssetSettingModal, setShowAssetSettingModal] = useState(false)\n\n    // 编辑特定描述索引的角色形象\n    const handleEditCharacterDescription = (characterId: string, appearanceIndex: number, descriptionIndex: number) => {\n        const character = characters.find(c => c.id === characterId)\n        if (!character) return\n        const appearances = getAppearances(character)\n        const appearance = appearances.find(a => a.appearanceIndex === appearanceIndex)\n        if (!appearance) return\n\n        const descriptions = appearance.descriptions || [appearance.description || '']\n        const description = descriptions[descriptionIndex] || appearance.description || ''\n\n        setEditingAppearance({\n            characterId,\n            characterName: character.name,\n            appearanceId: appearance.id,\n            description: description,\n            descriptionIndex\n        })\n    }\n\n    // 编辑特定描述索引的场景\n    const handleEditLocationDescription = (locationId: string, imageIndex: number) => {\n        const location = locations.find(l => l.id === locationId)\n        if (!location) return\n\n        const image = location.images?.find(img => img.imageIndex === imageIndex)\n        const description = image?.description || ''\n\n        setEditingLocation({\n            locationId,\n            locationName: location.name,\n            description: description\n        })\n    }\n\n    // 编辑角色形象\n    const handleEditAppearance = (characterId: string, characterName: string, appearance: CharacterAppearance, introduction?: string | null) => {\n        setEditingAppearance({\n            characterId,\n            characterName,\n            appearanceId: appearance.id,\n            description: appearance.description || '',\n            introduction\n        })\n    }\n\n    // 编辑场景\n    const handleEditLocation = (location: Location) => {\n        const firstImage = location.images?.[0]\n        setEditingLocation({\n            locationId: location.id,\n            locationName: location.name,\n            description: firstImage?.description || ''\n        })\n    }\n\n    // 打开场景图片编辑弹窗\n    const handleOpenLocationImageEdit = (locationId: string, imageIndex: number) => {\n        const location = locations.find(l => l.id === locationId)\n        if (!location) return\n\n        setImageEditModal({\n            locationId,\n            imageIndex,\n            locationName: location.name\n        })\n    }\n\n    // 打开人物图片编辑弹窗\n    const handleOpenCharacterImageEdit = (characterId: string, appearanceId: string, imageIndex: number, characterName: string) => {\n        setCharacterImageEditModal({\n            characterId,\n            appearanceId,\n            imageIndex,\n            characterName\n        })\n    }\n\n    // 关闭所有弹窗\n    const closeEditingAppearance = () => setEditingAppearance(null)\n    const closeEditingLocation = () => setEditingLocation(null)\n    const closeAddCharacter = () => setShowAddCharacter(false)\n    const closeAddLocation = () => setShowAddLocation(false)\n    const closeImageEditModal = () => setImageEditModal(null)\n    const closeCharacterImageEditModal = () => setCharacterImageEditModal(null)\n    const closeAssetSettingModal = () => setShowAssetSettingModal(false)\n\n    return {\n        // 🔥 暴露数据供组件使用\n        characters,\n        locations,\n        getAppearances,\n        // 状态\n        editingAppearance,\n        editingLocation,\n        showAddCharacter,\n        showAddLocation,\n        imageEditModal,\n        characterImageEditModal,\n        showAssetSettingModal,\n        // Setters\n        setEditingAppearance,\n        setEditingLocation,\n        setShowAddCharacter,\n        setShowAddLocation,\n        setImageEditModal,\n        setCharacterImageEditModal,\n        setShowAssetSettingModal,\n        // Handlers\n        handleEditCharacterDescription,\n        handleEditLocationDescription,\n        handleEditAppearance,\n        handleEditLocation,\n        handleOpenLocationImageEdit,\n        handleOpenCharacterImageEdit,\n        // Close helpers\n        closeEditingAppearance,\n        closeEditingLocation,\n        closeAddCharacter,\n        closeAddLocation,\n        closeImageEditModal,\n        closeCharacterImageEditModal,\n        closeAssetSettingModal\n    }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetsCopyFromHub.ts",
    "content": "'use client'\n\nimport { useCallback, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { isAbortError } from '@/lib/error-utils'\nimport { useCopyProjectAssetFromGlobal } from '@/lib/query/hooks'\n\ntype ToastType = 'success' | 'warning' | 'error'\n\ntype ShowToast = (message: string, type?: ToastType, duration?: number) => void\n\nexport type GlobalCopyTarget = {\n  type: 'character' | 'location' | 'voice'\n  targetId: string\n}\n\ninterface UseAssetsCopyFromHubParams {\n  projectId: string\n  onRefresh: () => void | Promise<void>\n  showToast: ShowToast\n}\n\nconst getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)\n\nexport function useAssetsCopyFromHub({ projectId, onRefresh, showToast }: UseAssetsCopyFromHubParams) {\n  const t = useTranslations('assets')\n  const copyFromGlobalAsset = useCopyProjectAssetFromGlobal(projectId)\n  const [copyFromGlobalTarget, setCopyFromGlobalTarget] = useState<GlobalCopyTarget | null>(null)\n  const [isGlobalCopyInFlight, setIsGlobalCopyInFlight] = useState(false)\n\n  const handleCopyFromGlobal = useCallback((characterId: string) => {\n    setCopyFromGlobalTarget({ type: 'character', targetId: characterId })\n  }, [])\n\n  const handleCopyLocationFromGlobal = useCallback((locationId: string) => {\n    setCopyFromGlobalTarget({ type: 'location', targetId: locationId })\n  }, [])\n\n  const handleVoiceSelectFromHub = useCallback((characterId: string) => {\n    setCopyFromGlobalTarget({ type: 'voice', targetId: characterId })\n  }, [])\n\n  const handleCloseCopyPicker = useCallback(() => {\n    setCopyFromGlobalTarget(null)\n  }, [])\n\n  const handleConfirmCopyFromGlobal = useCallback(async (globalAssetId: string) => {\n    if (!copyFromGlobalTarget) return\n\n    setIsGlobalCopyInFlight(true)\n    try {\n      await copyFromGlobalAsset.mutateAsync({\n        type: copyFromGlobalTarget.type,\n        targetId: copyFromGlobalTarget.targetId,\n        globalAssetId,\n      })\n\n      const successMsg = copyFromGlobalTarget.type === 'character'\n        ? t('assetLibrary.copySuccessCharacter')\n        : copyFromGlobalTarget.type === 'location'\n          ? t('assetLibrary.copySuccessLocation')\n          : t('assetLibrary.copySuccessVoice')\n      showToast(successMsg, 'success')\n      setCopyFromGlobalTarget(null)\n      await Promise.resolve(onRefresh())\n    } catch (error: unknown) {\n      if (!isAbortError(error)) {\n        showToast(t('assetLibrary.copyFailed', { error: getErrorMessage(error) }), 'error')\n      }\n    } finally {\n      setIsGlobalCopyInFlight(false)\n    }\n  }, [copyFromGlobalAsset, copyFromGlobalTarget, onRefresh, showToast, t])\n\n  return {\n    copyFromGlobalTarget,\n    isGlobalCopyInFlight,\n    handleCopyFromGlobal,\n    handleCopyLocationFromGlobal,\n    handleVoiceSelectFromHub,\n    handleConfirmCopyFromGlobal,\n    handleCloseCopyPicker,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetsGlobalActions.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { useAnalyzeProjectGlobalAssets } from '@/lib/query/hooks'\n\ntype ToastType = 'success' | 'warning' | 'error'\n\ntype ShowToast = (message: string, type?: ToastType, duration?: number) => void\ntype TranslateValues = Record<string, string | number | Date>\ntype Translate = (key: string, values?: TranslateValues) => string\n\ninterface UseAssetsGlobalActionsParams {\n  projectId: string\n  triggerGlobalAnalyze?: boolean\n  onGlobalAnalyzeComplete?: () => void\n  onRefresh: () => void | Promise<void>\n  showToast: ShowToast\n  t: Translate\n}\n\nconst getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)\n\nexport function useAssetsGlobalActions({\n  projectId,\n  triggerGlobalAnalyze = false,\n  onGlobalAnalyzeComplete,\n  onRefresh,\n  showToast,\n  t,\n}: UseAssetsGlobalActionsParams) {\n  const analyzeGlobalAssets = useAnalyzeProjectGlobalAssets(projectId)\n  const [isGlobalAnalyzing, setIsGlobalAnalyzing] = useState(false)\n  const hasTriggeredGlobalAnalyze = useRef(false)\n\n  const globalAnalyzingState = useMemo(() => {\n    if (!isGlobalAnalyzing) return null\n    return resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'text',\n      hasOutput: false,\n    })\n  }, [isGlobalAnalyzing])\n\n  const handleGlobalAnalyze = useCallback(async () => {\n    if (isGlobalAnalyzing) return\n\n    try {\n      setIsGlobalAnalyzing(true)\n      showToast(t('toolbar.globalAnalyzing'), 'warning', 60000)\n\n      const data = await analyzeGlobalAssets.mutateAsync()\n      await Promise.resolve(onRefresh())\n\n      showToast(\n        t('toolbar.globalAnalyzeSuccess', {\n          characters: data.stats?.newCharacters || 0,\n          locations: data.stats?.newLocations || 0,\n        }),\n        'success',\n        5000,\n      )\n    } catch (error: unknown) {\n      _ulogError('Global analyze error:', error)\n      showToast(`${t('toolbar.globalAnalyzeFailed')}: ${getErrorMessage(error)}`, 'error', 5000)\n    } finally {\n      setIsGlobalAnalyzing(false)\n    }\n  }, [analyzeGlobalAssets, isGlobalAnalyzing, onRefresh, showToast, t])\n\n  useEffect(() => {\n    if (!triggerGlobalAnalyze || hasTriggeredGlobalAnalyze.current || isGlobalAnalyzing) {\n      return\n    }\n\n    hasTriggeredGlobalAnalyze.current = true\n    _ulogInfo('[AssetsStage] 通过 props 触发全局分析')\n\n    const timer = window.setTimeout(() => {\n      void (async () => {\n        await handleGlobalAnalyze()\n        onGlobalAnalyzeComplete?.()\n      })()\n    }, 500)\n\n    return () => window.clearTimeout(timer)\n  }, [handleGlobalAnalyze, isGlobalAnalyzing, onGlobalAnalyzeComplete, triggerGlobalAnalyze])\n\n  return {\n    isGlobalAnalyzing,\n    globalAnalyzingState,\n    handleGlobalAnalyze,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetsImageEdit.ts",
    "content": "'use client'\n\nimport { useCallback } from 'react'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { isAbortError } from '@/lib/error-utils'\nimport {\n  useModifyProjectCharacterImage,\n  useModifyProjectLocationImage,\n  useUndoProjectCharacterImage,\n  useUndoProjectLocationImage,\n  useUpdateProjectAppearanceDescription,\n  useUpdateProjectLocationDescription,\n} from '@/lib/query/hooks'\n\ntype ToastType = 'success' | 'warning' | 'error'\n\ntype ShowToast = (message: string, type?: ToastType, duration?: number) => void\ntype TranslateValues = Record<string, string | number | Date>\ntype Translate = (key: string, values?: TranslateValues) => string\n\ninterface EditingAppearanceState {\n  characterId: string\n  appearanceId: string\n  descriptionIndex?: number\n}\n\ninterface EditingLocationState {\n  locationId: string\n}\n\ninterface LocationImageEditState {\n  locationId: string\n  imageIndex: number\n  locationName: string\n}\n\ninterface CharacterImageEditState {\n  characterId: string\n  appearanceId: string\n  imageIndex: number\n  characterName: string\n}\n\ninterface UseAssetsImageEditParams {\n  projectId: string\n  t: Translate\n  showToast: ShowToast\n  onRefresh: () => void | Promise<void>\n  editingAppearance: EditingAppearanceState | null\n  editingLocation: EditingLocationState | null\n  imageEditModal: LocationImageEditState | null\n  characterImageEditModal: CharacterImageEditState | null\n  closeEditingAppearance: () => void\n  closeEditingLocation: () => void\n  closeImageEditModal: () => void\n  closeCharacterImageEditModal: () => void\n}\n\nconst getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)\n\nexport function useAssetsImageEdit({\n  projectId,\n  t,\n  showToast,\n  onRefresh,\n  editingAppearance,\n  editingLocation,\n  imageEditModal,\n  characterImageEditModal,\n  closeEditingAppearance,\n  closeEditingLocation,\n  closeImageEditModal,\n  closeCharacterImageEditModal,\n}: UseAssetsImageEditParams) {\n  const modifyCharacterImage = useModifyProjectCharacterImage(projectId)\n  const modifyLocationImage = useModifyProjectLocationImage(projectId)\n  const undoCharacterImage = useUndoProjectCharacterImage(projectId)\n  const undoLocationImage = useUndoProjectLocationImage(projectId)\n  const updateAppearanceDescription = useUpdateProjectAppearanceDescription(projectId)\n  const updateLocationDescription = useUpdateProjectLocationDescription(projectId)\n\n  const handleUndoCharacter = useCallback(async (characterId: string, appearanceId: string) => {\n    if (!confirm(t('image.undoConfirm'))) return\n    try {\n      await undoCharacterImage.mutateAsync({ characterId, appearanceId })\n      showToast(t('image.undoSuccess'), 'success')\n      await Promise.resolve(onRefresh())\n    } catch (error: unknown) {\n      if (isAbortError(error)) {\n        await Promise.resolve(onRefresh())\n        return\n      }\n      showToast(`${t('image.undoFailed')}: ${getErrorMessage(error)}`, 'error')\n    }\n  }, [onRefresh, showToast, t, undoCharacterImage])\n\n  const handleUndoLocation = useCallback(async (locationId: string) => {\n    if (!confirm(t('image.undoConfirm'))) return\n    try {\n      await undoLocationImage.mutateAsync(locationId)\n      showToast(t('image.undoSuccess'), 'success')\n      await Promise.resolve(onRefresh())\n    } catch (error: unknown) {\n      if (isAbortError(error)) {\n        await Promise.resolve(onRefresh())\n        return\n      }\n      showToast(`${t('image.undoFailed')}: ${getErrorMessage(error)}`, 'error')\n    }\n  }, [onRefresh, showToast, t, undoLocationImage])\n\n  const handleLocationImageEdit = useCallback(async (modifyPrompt: string, extraImageUrls?: string[]) => {\n    if (!imageEditModal) return\n    const { locationId, imageIndex, locationName } = imageEditModal\n\n    closeImageEditModal()\n\n    _ulogInfo(`[场景编辑] 开始编辑 ${locationName}, locationId=${locationId}, imageIndex=${imageIndex}`)\n\n    modifyLocationImage.mutate(\n      { locationId, imageIndex, modifyPrompt, extraImageUrls },\n      {\n        onSuccess: (data) => {\n          const result = (data || {}) as { descriptionUpdated?: boolean }\n          _ulogInfo(`[场景编辑] ✅ 完成: ${locationName}`)\n          const descNote = result.descriptionUpdated ? t('stage.updateSuccess') : ''\n          showToast(`${locationName} ${t('image.editSuccess')}${descNote}`, 'success')\n        },\n        onError: (error: unknown) => {\n          _ulogInfo(`[场景编辑] ❌ 失败: ${locationName}`, error)\n          if (isAbortError(error)) return\n          showToast(`${t('image.editFailed')}: ${getErrorMessage(error)}`, 'error')\n        },\n      },\n    )\n  }, [closeImageEditModal, imageEditModal, modifyLocationImage, showToast, t])\n\n  const handleCharacterImageEdit = useCallback(async (modifyPrompt: string, extraImageUrls?: string[]) => {\n    if (!characterImageEditModal) return\n    const { characterId, appearanceId, imageIndex, characterName } = characterImageEditModal\n\n    closeCharacterImageEditModal()\n\n    _ulogInfo(`[角色编辑] 开始编辑 ${characterName}, characterId=${characterId}, appearanceId=${appearanceId}, imageIndex=${imageIndex}`)\n\n    modifyCharacterImage.mutate(\n      { characterId, appearanceId, imageIndex, modifyPrompt, extraImageUrls },\n      {\n        onSuccess: (data) => {\n          const result = (data || {}) as { descriptionUpdated?: boolean }\n          _ulogInfo(`[角色编辑] ✅ 完成: ${characterName}`)\n          const descNote = result.descriptionUpdated ? t('stage.updateSuccess') : ''\n          showToast(`${characterName} ${t('image.editSuccess')}${descNote}`, 'success')\n        },\n        onError: (error: unknown) => {\n          _ulogInfo(`[角色编辑] ❌ 失败: ${characterName}`, error)\n          if (isAbortError(error)) return\n          showToast(`${t('image.editFailed')}: ${getErrorMessage(error)}`, 'error')\n        },\n      },\n    )\n  }, [characterImageEditModal, closeCharacterImageEditModal, modifyCharacterImage, showToast, t])\n\n  const handleUpdateAppearanceDescription = useCallback(async (newDescription: string) => {\n    if (!editingAppearance) return\n    const { characterId, appearanceId, descriptionIndex } = editingAppearance\n    try {\n      await updateAppearanceDescription.mutateAsync({\n        characterId,\n        appearanceId,\n        description: newDescription,\n        descriptionIndex,\n      })\n      closeEditingAppearance()\n      await Promise.resolve(onRefresh())\n    } catch (error: unknown) {\n      if (!isAbortError(error)) {\n        alert(`${t('character.updateFailed')}: ${getErrorMessage(error)}`)\n      }\n    }\n  }, [closeEditingAppearance, editingAppearance, onRefresh, t, updateAppearanceDescription])\n\n  const handleUpdateLocationDescription = useCallback(async (newDescription: string) => {\n    if (!editingLocation) return\n    try {\n      await updateLocationDescription.mutateAsync({\n        locationId: editingLocation.locationId,\n        description: newDescription,\n      })\n      closeEditingLocation()\n      await Promise.resolve(onRefresh())\n    } catch (error: unknown) {\n      if (!isAbortError(error)) {\n        alert(`${t('location.updateFailed')}: ${getErrorMessage(error)}`)\n      }\n    }\n  }, [closeEditingLocation, editingLocation, onRefresh, t, updateLocationDescription])\n\n  return {\n    handleUndoCharacter,\n    handleUndoLocation,\n    handleLocationImageEdit,\n    handleCharacterImageEdit,\n    handleUpdateAppearanceDescription,\n    handleUpdateLocationDescription,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useBatchGeneration.helpers.ts",
    "content": "import type { CharacterAppearance } from '@/types/project'\nimport type { Character, Location } from '@/lib/query/hooks'\n\nconst MANUAL_REGENERATE_TIMEOUT_MS = 90_000\n\nexport type ManualRegenerationBaseline = {\n  signature: string\n  startedAt: number\n}\n\nfunction createCharacterGroupSignature(appearance: CharacterAppearance) {\n  return JSON.stringify({\n    imageUrl: appearance.imageUrl || null,\n    imageUrls: appearance.imageUrls || [],\n    imageErrorMessage: appearance.imageErrorMessage || null,\n  })\n}\n\nfunction createLocationGroupSignature(location: Location) {\n  const normalizedImages = (location.images || []).map((image) => ({\n    imageIndex: image.imageIndex,\n    imageUrl: image.imageUrl || null,\n    imageErrorMessage: image.imageErrorMessage || null,\n  }))\n  return JSON.stringify({\n    images: normalizedImages,\n  })\n}\n\nexport function createManualKeyBaseline(\n  key: string,\n  characters: Character[],\n  locations: Location[],\n): ManualRegenerationBaseline | null {\n  const characterMatch = /^character-(.+)-(\\d+)-(group|\\d+)$/.exec(key)\n  if (characterMatch) {\n    const [, characterId, appearanceIndexRaw, suffix] = characterMatch\n    const appearanceIndex = Number.parseInt(appearanceIndexRaw, 10)\n    const character = characters.find((item) => item.id === characterId)\n    const appearance = character?.appearances?.find((item) => item.appearanceIndex === appearanceIndex)\n    if (!character || !appearance) return null\n    const groupSignature = createCharacterGroupSignature(appearance)\n    if (suffix === 'group') return { signature: groupSignature, startedAt: Date.now() }\n    const imageIndex = Number.parseInt(suffix, 10)\n    if (!Number.isFinite(imageIndex)) return null\n    const imageUrl = appearance.imageUrls?.[imageIndex] || null\n    return {\n      signature: JSON.stringify({\n        imageUrl,\n        imageErrorMessage: appearance.imageErrorMessage || null,\n      }),\n      startedAt: Date.now(),\n    }\n  }\n\n  const locationMatch = /^location-(.+)-(group|\\d+)$/.exec(key)\n  if (locationMatch) {\n    const [, locationId, suffix] = locationMatch\n    const location = locations.find((item) => item.id === locationId)\n    if (!location) return null\n    const groupSignature = createLocationGroupSignature(location)\n    if (suffix === 'group') return { signature: groupSignature, startedAt: Date.now() }\n    const imageIndex = Number.parseInt(suffix, 10)\n    if (!Number.isFinite(imageIndex)) return null\n    const image = location.images?.find((item) => item.imageIndex === imageIndex)\n    return {\n      signature: JSON.stringify({\n        imageUrl: image?.imageUrl || null,\n        imageErrorMessage: image?.imageErrorMessage || null,\n      }),\n      startedAt: Date.now(),\n    }\n  }\n\n  return null\n}\n\nexport function isAppearanceTaskRunning(appearance: CharacterAppearance) {\n  return Boolean((appearance as CharacterAppearance & { imageTaskRunning?: boolean })['imageTaskRunning'])\n}\n\nexport function shouldResolveManualKey(\n  key: string,\n  characters: Character[],\n  locations: Location[],\n  baselines: Map<string, ManualRegenerationBaseline>,\n  now: number,\n) {\n  const baseline = baselines.get(key)\n  if (!baseline) return true\n  const current = createManualKeyBaseline(key, characters, locations)\n  if (!current) return true\n\n  if (now - baseline.startedAt > MANUAL_REGENERATE_TIMEOUT_MS) {\n    return true\n  }\n\n  const characterMatch = /^character-(.+)-(\\d+)-(group|\\d+)$/.exec(key)\n  if (characterMatch) {\n    const [, characterId, appearanceIndexRaw] = characterMatch\n    const appearanceIndex = Number.parseInt(appearanceIndexRaw, 10)\n    const character = characters.find((item) => item.id === characterId)\n    const appearance = character?.appearances?.find((item) => item.appearanceIndex === appearanceIndex)\n    if (!appearance) return true\n    if (isAppearanceTaskRunning(appearance)) return true\n    if (appearance.imageErrorMessage) return true\n    return current.signature !== baseline.signature\n  }\n\n  const locationMatch = /^location-(.+)-(group|\\d+)$/.exec(key)\n  if (locationMatch) {\n    const [, locationId, suffix] = locationMatch\n    const location = locations.find((item) => item.id === locationId)\n    if (!location) return true\n    const imageIndex = Number.parseInt(suffix, 10)\n    if (suffix === 'group') {\n      const hasRunningTask = !!location.images?.some((item) => item.imageTaskRunning)\n      const hasError = !!location.images?.some((item) => !!item.imageErrorMessage)\n      if (hasRunningTask || hasError) return true\n      return current.signature !== baseline.signature\n    }\n    if (!Number.isFinite(imageIndex)) return true\n    const image = location.images?.find((item) => item.imageIndex === imageIndex)\n    if (!image) return true\n    if (image.imageTaskRunning || image.imageErrorMessage) return true\n    return current.signature !== baseline.signature\n  }\n\n  return true\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useBatchGeneration.ts",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\n\n/**\n * useBatchGeneration - 批量生成资产图片\n * 从 AssetsStage.tsx 提取\n * \n * 🔥 V6.5 重构：直接订阅 useProjectAssets，消除 props drilling\n * 🔥 V6.6 重构：内部使用 mutation hooks，移除 onGenerateImage prop\n */\n\nimport { useState, useCallback, useMemo, useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { CharacterAppearance } from '@/types/project'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\nimport { useProjectAssets, useRefreshProjectAssets, useGenerateProjectCharacterImage, useGenerateProjectLocationImage, type Character } from '@/lib/query/hooks'\nimport {\n    createManualKeyBaseline,\n    isAppearanceTaskRunning,\n    shouldResolveManualKey,\n    type ManualRegenerationBaseline,\n} from './useBatchGeneration.helpers'\n\ninterface UseBatchGenerationProps {\n    projectId: string\n    // 🔥 V6.6：移除 onGenerateImage，内部使用 mutation hooks\n    handleGenerateImage?: (type: 'character' | 'location', id: string, appearanceId?: string, count?: number) => Promise<void> | void\n}\n\nexport function useBatchGeneration({\n    projectId,\n    handleGenerateImage: externalHandleGenerateImage\n}: UseBatchGenerationProps) {\n    const t = useTranslations('assets')\n    // 🔥 直接订阅缓存 - 消除 props drilling\n    const { data: assets } = useProjectAssets(projectId)\n    const characters = useMemo(() => assets?.characters ?? [], [assets?.characters])\n    const locations = useMemo(() => assets?.locations ?? [], [assets?.locations])\n    const { count: characterGenerationCount } = useImageGenerationCount('character')\n    const { count: locationGenerationCount } = useImageGenerationCount('location')\n\n    // 🔥 使用刷新函数\n    const refreshAssets = useRefreshProjectAssets(projectId)\n\n    // 🔥 V6.6：内部 mutation hooks\n    const generateCharacterImage = useGenerateProjectCharacterImage(projectId)\n    const generateLocationImage = useGenerateProjectLocationImage(projectId)\n\n    // 🔥 内部图片生成函数\n    const internalHandleGenerateImage = useCallback(async (\n        type: 'character' | 'location',\n        id: string,\n        appearanceId?: string,\n        count?: number,\n    ) => {\n        if (type === 'character' && appearanceId) {\n            await generateCharacterImage.mutateAsync({ characterId: id, appearanceId, count })\n        } else if (type === 'location') {\n            await generateLocationImage.mutateAsync({ locationId: id, count })\n        }\n    }, [generateCharacterImage, generateLocationImage])\n\n    // 使用外部传入的函数或内部实现\n    const handleGenerateImage = externalHandleGenerateImage || internalHandleGenerateImage\n\n    const [isBatchSubmittingAll, setIsBatchSubmittingAll] = useState(false)\n    const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 })\n    const [pendingRegenerationKeys, setPendingRegenerationKeys] = useState<Set<string>>(new Set())\n    const [pendingRegenerationBaselines, setPendingRegenerationBaselines] = useState<Map<string, ManualRegenerationBaseline>>(new Map())\n\n    // 获取形象列表（内置实现，不再依赖外部传入）\n    const getAppearances = useCallback((character: Character): CharacterAppearance[] => {\n        return character.appearances || []\n    }, [])\n\n    const activeTaskKeys = useMemo(() => {\n        const generated = new Set<string>()\n\n        for (const character of characters) {\n            for (const appearance of character.appearances || []) {\n                if (!isAppearanceTaskRunning(appearance)) continue\n                const groupKey = `character-${character.id}-${appearance.appearanceIndex}-group`\n                generated.add(groupKey)\n                const imageCount = Math.max(1, appearance.imageUrls?.length || 0)\n                for (let index = 0; index < imageCount; index += 1) {\n                    generated.add(`character-${character.id}-${appearance.appearanceIndex}-${index}`)\n                }\n            }\n        }\n\n        for (const location of locations) {\n            const hasRunningTask = !!location.images?.some((img) => img.imageTaskRunning)\n            if (!hasRunningTask) continue\n            generated.add(`location-${location.id}-group`)\n            for (const image of location.images || []) {\n                if (image.imageTaskRunning) {\n                    generated.add(`location-${location.id}-${image.imageIndex}`)\n                }\n            }\n        }\n\n        for (const key of pendingRegenerationKeys) {\n            generated.add(key)\n        }\n\n        return generated\n    }, [characters, locations, pendingRegenerationKeys])\n\n    useEffect(() => {\n        if (pendingRegenerationKeys.size === 0) return\n\n        const now = Date.now()\n        setPendingRegenerationKeys((prev) => {\n            let changed = false\n            const next = new Set(prev)\n            for (const key of prev) {\n                if (shouldResolveManualKey(key, characters, locations, pendingRegenerationBaselines, now)) {\n                    next.delete(key)\n                    changed = true\n                }\n            }\n            return changed ? next : prev\n        })\n        setPendingRegenerationBaselines((prev) => {\n            if (prev.size === 0) return prev\n            let changed = false\n            const next = new Map(prev)\n            for (const key of Array.from(next.keys())) {\n                if (!pendingRegenerationKeys.has(key)) {\n                    next.delete(key)\n                    changed = true\n                    continue\n                }\n                if (shouldResolveManualKey(key, characters, locations, prev, now)) {\n                    next.delete(key)\n                    changed = true\n                }\n            }\n            return changed ? next : prev\n        })\n    }, [characters, locations, pendingRegenerationBaselines, pendingRegenerationKeys])\n\n    // 生成全部资产图片（仅缺失图片的）\n    const handleGenerateAllImages = async () => {\n        const tasks: Array<{\n            type: 'character' | 'location'\n            id: string\n            appearanceId?: string\n            appearanceIndex?: number\n            key: string\n        }> = []\n\n        // 收集角色资产\n        characters.forEach(char => {\n            const appearances = getAppearances(char)\n            appearances.forEach(app => {\n                if (!app.imageUrl && !app.imageUrls?.length) {\n                    tasks.push({\n                        type: 'character',\n                        id: char.id,\n                        appearanceId: app.id,\n                        appearanceIndex: app.appearanceIndex,\n                        key: `character-${char.id}-${app.appearanceIndex}-group`\n                    })\n                }\n            })\n        })\n\n        // 收集场景资产\n        locations.forEach(loc => {\n            const hasImage = loc.images?.some(img => img.imageUrl)\n            if (!hasImage) {\n                tasks.push({\n                    type: 'location',\n                    id: loc.id,\n                    key: `location-${loc.id}-group`\n                })\n            }\n        })\n\n        if (tasks.length === 0) {\n            alert(t('toolbar.generateAllNoop'))\n            return\n        }\n\n        setIsBatchSubmittingAll(true)\n        setBatchProgress({ current: 0, total: tasks.length })\n\n        const allKeys = new Set(tasks.map(t => t.key))\n        setPendingRegenerationKeys(prev => new Set([...prev, ...allKeys]))\n        setPendingRegenerationBaselines(prev => {\n            const next = new Map(prev)\n            for (const key of allKeys) {\n                const baseline = createManualKeyBaseline(key, characters, locations)\n                if (baseline) {\n                    next.set(key, baseline)\n                }\n            }\n            return next\n        })\n\n        try {\n            await Promise.all(\n                tasks.map(async (task) => {\n                    let submitted = false\n                    try {\n                        await handleGenerateImage(\n                            task.type,\n                            task.id,\n                            task.appearanceId,\n                            task.type === 'character' ? characterGenerationCount : locationGenerationCount,\n                        )\n                        submitted = true\n                        setBatchProgress(prev => ({ ...prev, current: prev.current + 1 }))\n                    } catch (error) {\n                        _ulogError(`Failed to generate ${task.type} ${task.id}:`, error)\n                        setBatchProgress(prev => ({ ...prev, current: prev.current + 1 }))\n                    } finally {\n                        if (submitted) return\n                        setPendingRegenerationKeys(prev => {\n                            const next = new Set(prev)\n                            next.delete(task.key)\n                            return next\n                        })\n                        setPendingRegenerationBaselines(prev => {\n                            if (!prev.has(task.key)) return prev\n                            const next = new Map(prev)\n                            next.delete(task.key)\n                            return next\n                        })\n                    }\n                })\n            )\n        } finally {\n            setIsBatchSubmittingAll(false)\n            setBatchProgress({ current: 0, total: 0 })\n            refreshAssets()\n        }\n    }\n\n    // 重新生成全部资产图片（包含已有图片的）\n    const handleRegenerateAllImages = async () => {\n        if (!confirm(t('toolbar.regenerateAllConfirm'))) return\n\n        const tasks: Array<{\n            type: 'character' | 'location'\n            id: string\n            appearanceId?: string\n            appearanceIndex?: number\n            key: string\n        }> = []\n\n        characters.forEach(char => {\n            const appearances = getAppearances(char)\n            appearances.forEach(app => {\n                tasks.push({\n                    type: 'character',\n                    id: char.id,\n                    appearanceId: app.id,\n                    appearanceIndex: app.appearanceIndex,\n                    key: `character-${char.id}-${app.appearanceIndex}-group`\n                })\n            })\n        })\n\n        locations.forEach(loc => {\n            tasks.push({\n                type: 'location',\n                id: loc.id,\n                key: `location-${loc.id}-group`\n            })\n        })\n\n        if (tasks.length === 0) {\n            alert(t('toolbar.noAssetsToGenerate'))\n            return\n        }\n\n        setIsBatchSubmittingAll(true)\n        setBatchProgress({ current: 0, total: tasks.length })\n\n        const allKeys = new Set(tasks.map(t => t.key))\n        setPendingRegenerationKeys(prev => new Set([...prev, ...allKeys]))\n        setPendingRegenerationBaselines(prev => {\n            const next = new Map(prev)\n            for (const key of allKeys) {\n                const baseline = createManualKeyBaseline(key, characters, locations)\n                if (baseline) {\n                    next.set(key, baseline)\n                }\n            }\n            return next\n        })\n\n        try {\n            await Promise.all(\n                tasks.map(async (task) => {\n                    let submitted = false\n                    try {\n                        await handleGenerateImage(\n                            task.type,\n                            task.id,\n                            task.appearanceId,\n                            task.type === 'character' ? characterGenerationCount : locationGenerationCount,\n                        )\n                        submitted = true\n                        setBatchProgress(prev => ({ ...prev, current: prev.current + 1 }))\n                    } catch (error) {\n                        _ulogError(`Failed to generate ${task.type} ${task.id}:`, error)\n                        setBatchProgress(prev => ({ ...prev, current: prev.current + 1 }))\n                    } finally {\n                        if (submitted) return\n                        setPendingRegenerationKeys(prev => {\n                            const next = new Set(prev)\n                            next.delete(task.key)\n                            return next\n                        })\n                        setPendingRegenerationBaselines(prev => {\n                            if (!prev.has(task.key)) return prev\n                            const next = new Map(prev)\n                            next.delete(task.key)\n                            return next\n                        })\n                    }\n                })\n            )\n        } finally {\n            setIsBatchSubmittingAll(false)\n            setBatchProgress({ current: 0, total: 0 })\n            refreshAssets()\n        }\n    }\n\n    // 清除单个本地兜底状态（仅用于提交失败场景）\n    const clearTransientTaskKey = useCallback((key: string) => {\n        setPendingRegenerationKeys(prev => {\n            const next = new Set(prev)\n            next.delete(key)\n            return next\n        })\n        setPendingRegenerationBaselines(prev => {\n            if (!prev.has(key)) return prev\n            const next = new Map(prev)\n            next.delete(key)\n            return next\n        })\n    }, [])\n\n    const registerTransientTaskKey = useCallback((key: string) => {\n        setPendingRegenerationKeys(prev => new Set([...prev, key]))\n        setPendingRegenerationBaselines(prev => {\n            const baseline = createManualKeyBaseline(key, characters, locations)\n            if (!baseline) return prev\n            const next = new Map(prev)\n            next.set(key, baseline)\n            return next\n        })\n    }, [characters, locations])\n\n    return {\n        // 🔥 暴露数据供组件使用\n        characters,\n        locations,\n        getAppearances,\n        // 状态\n        isBatchSubmitting: isBatchSubmittingAll,\n        batchProgress,\n        activeTaskKeys,\n        registerTransientTaskKey,\n        setTransientRegenerationKeys: setPendingRegenerationKeys,\n        clearTransientTaskKey,\n        // 操作\n        handleGenerateAllImages,\n        handleRegenerateAllImages\n    }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useCharacterActions.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\n/**\n * useCharacterActions - 角色资产操作 Hook\n * 从 AssetsStage 提取，负责角色的 CRUD 和图片生成操作\n * \n * 🔥 V6.5 重构：直接订阅 useProjectAssets，消除 props drilling\n */\n\nimport { useCallback } from 'react'\nimport { CharacterAppearance } from '@/types/project'\nimport { isAbortError } from '@/lib/error-utils'\nimport {\n    useProjectAssets,\n    useRefreshProjectAssets,\n    useRegenerateSingleCharacterImage,\n    useRegenerateCharacterGroup,\n    useDeleteProjectCharacter,\n    useDeleteProjectAppearance,\n    useSelectProjectCharacterImage,\n    useConfirmProjectCharacterSelection,\n    useUpdateProjectAppearanceDescription,\n    type Character\n} from '@/lib/query/hooks'\n\ninterface UseCharacterActionsProps {\n    projectId: string\n    showToast?: (message: string, type: 'success' | 'warning' | 'error') => void\n}\n\nfunction getErrorMessage(error: unknown, fallback: string): string {\n    if (error instanceof Error) return error.message\n    if (typeof error === 'object' && error !== null) {\n        const message = (error as { message?: unknown }).message\n        if (typeof message === 'string') return message\n    }\n    return fallback\n}\n\nexport function useCharacterActions({\n    projectId,\n    showToast\n}: UseCharacterActionsProps) {\n    const t = useTranslations('assets')\n    // 🔥 直接订阅缓存 - 消除 props drilling\n    const { data: assets } = useProjectAssets(projectId)\n    const characters = assets?.characters ?? []\n\n    // 🔥 使用刷新函数 - mutations 完成后刷新缓存\n    const refreshAssets = useRefreshProjectAssets(projectId)\n\n    // 🔥 V6.7: 使用重新生成mutation hooks\n    const regenerateSingleImage = useRegenerateSingleCharacterImage(projectId)\n    const regenerateGroup = useRegenerateCharacterGroup(projectId)\n    const deleteCharacterMutation = useDeleteProjectCharacter(projectId)\n    const deleteAppearanceMutation = useDeleteProjectAppearance(projectId)\n    const selectCharacterImageMutation = useSelectProjectCharacterImage(projectId)\n    const confirmCharacterSelectionMutation = useConfirmProjectCharacterSelection(projectId)\n    const updateAppearanceDescriptionMutation = useUpdateProjectAppearanceDescription(projectId)\n\n    // 获取形象列表\n    const getAppearances = useCallback((character: Character): CharacterAppearance[] => {\n        return character.appearances || []\n    }, [])\n\n    // 删除角色\n    const handleDeleteCharacter = useCallback(async (characterId: string) => {\n        if (!confirm(t('character.deleteConfirm'))) return\n        try {\n            await deleteCharacterMutation.mutateAsync(characterId)\n        } catch (error: unknown) {\n            if (!isAbortError(error)) {\n                alert(t('character.deleteFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n            }\n        }\n    }, [deleteCharacterMutation, t])\n\n    // 删除单个形象\n    const handleDeleteAppearance = useCallback(async (characterId: string, appearanceId: string) => {\n        if (!confirm(t('character.deleteAppearanceConfirm'))) return\n        try {\n            await deleteAppearanceMutation.mutateAsync({ characterId, appearanceId })\n            // 🔥 刷新缓存\n            refreshAssets()\n        } catch (error: unknown) {\n            if (!isAbortError(error)) {\n                alert(t('character.deleteFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n            }\n        }\n    }, [deleteAppearanceMutation, refreshAssets, t])\n\n    // 处理角色图片选择\n    const handleSelectCharacterImage = useCallback(async (\n        characterId: string,\n        appearanceId: string,\n        imageIndex: number | null\n    ) => {\n        try {\n            await selectCharacterImageMutation.mutateAsync({\n                characterId,\n                appearanceId,\n                imageIndex,\n            })\n        } catch (error: unknown) {\n            if (isAbortError(error)) {\n                _ulogInfo('请求被中断（可能是页面刷新），后端仍在执行')\n                return\n            }\n            alert(t('image.selectFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n        }\n    }, [selectCharacterImageMutation, t])\n\n    // 确认选择并删除其他候选图片\n    const handleConfirmSelection = useCallback(async (characterId: string, appearanceId: string) => {\n        try {\n            await confirmCharacterSelectionMutation.mutateAsync({ characterId, appearanceId })\n            showToast?.(`✓ ${t('image.confirmSuccess')}`, 'success')\n        } catch (error: unknown) {\n            if (isAbortError(error)) {\n                _ulogInfo('请求被中断（可能是页面刷新），后端仍在执行')\n                return\n            }\n            showToast?.(t('image.confirmFailed', { error: getErrorMessage(error, t('common.unknownError')) }), 'error')\n        }\n    }, [confirmCharacterSelectionMutation, showToast, t])\n\n    // 单张重新生成角色图片 - 🔥 V6.7: 使用mutation hook\n    const handleRegenerateSingleCharacter = useCallback(async (\n        characterId: string,\n        appearanceId: string,\n        imageIndex: number\n    ) => {\n        try {\n            await regenerateSingleImage.mutateAsync({ characterId, appearanceId, imageIndex })\n        } catch (error: unknown) {\n            if (!isAbortError(error)) {\n                alert(t('image.regenerateFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n            }\n            throw error\n        }\n    }, [regenerateSingleImage, t])\n\n    // 整组重新生成角色图片 - 🔥 V6.7: 使用mutation hook\n    const handleRegenerateCharacterGroup = useCallback(async (\n        characterId: string,\n        appearanceId: string,\n        count?: number,\n    ) => {\n        try {\n            await regenerateGroup.mutateAsync({ characterId, appearanceId, count })\n        } catch (error: unknown) {\n            if (!isAbortError(error)) {\n                alert(t('image.regenerateFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n            }\n            throw error\n        }\n    }, [regenerateGroup, t])\n\n    // 更新形象描述 - 🔥 仍需保存到服务器\n    const handleUpdateAppearanceDescription = useCallback(async (\n        characterId: string,\n        appearanceId: string,\n        newDescription: string,\n        descriptionIndex?: number\n    ) => {\n        try {\n            await updateAppearanceDescriptionMutation.mutateAsync({\n                characterId,\n                appearanceId,\n                description: newDescription,\n                descriptionIndex,\n            })\n            refreshAssets()\n        } catch (error: unknown) {\n            if (!isAbortError(error)) {\n                _ulogError('更新描述失败:', getErrorMessage(error, t('common.unknownError')))\n            }\n        }\n    }, [refreshAssets, updateAppearanceDescriptionMutation, t])\n\n    return {\n        // 🔥 暴露 characters 供组件使用（可选，组件也可以自己订阅）\n        characters,\n        getAppearances,\n        handleDeleteCharacter,\n        handleDeleteAppearance,\n        handleSelectCharacterImage,\n        handleConfirmSelection,\n        handleRegenerateSingleCharacter,\n        handleRegenerateCharacterGroup,\n        handleUpdateAppearanceDescription\n    }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useLocationActions.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\n/**\n * useLocationActions - 场景资产操作 Hook\n * 从 AssetsStage 提取，负责场景的 CRUD 和图片生成操作\n * \n * 🔥 V6.5 重构：直接订阅 useProjectAssets，消除 props drilling\n */\n\nimport { useCallback } from 'react'\nimport { isAbortError } from '@/lib/error-utils'\nimport {\n    useProjectAssets,\n    useRefreshProjectAssets,\n    useRegenerateSingleLocationImage,\n    useRegenerateLocationGroup,\n    useDeleteProjectLocation,\n    useSelectProjectLocationImage,\n    useConfirmProjectLocationSelection,\n    useUpdateProjectLocationDescription,\n} from '@/lib/query/hooks'\n\ninterface UseLocationActionsProps {\n    projectId: string\n    showToast?: (message: string, type: 'success' | 'warning' | 'error') => void\n}\n\nfunction getErrorMessage(error: unknown, fallback: string): string {\n    if (error instanceof Error) return error.message\n    if (typeof error === 'object' && error !== null) {\n        const message = (error as { message?: unknown }).message\n        if (typeof message === 'string') return message\n    }\n    return fallback\n}\n\nexport function useLocationActions({\n    projectId,\n    showToast\n}: UseLocationActionsProps) {\n    const t = useTranslations('assets')\n    // 🔥 直接订阅缓存 - 消除 props drilling\n    const { data: assets } = useProjectAssets(projectId)\n    const locations = assets?.locations ?? []\n\n    // 🔥 使用刷新函数 - mutations 完成后刷新缓存\n    const refreshAssets = useRefreshProjectAssets(projectId)\n\n    // 🔥 V6.7: 使用重新生成mutation hooks\n    const regenerateSingleImage = useRegenerateSingleLocationImage(projectId)\n    const regenerateGroup = useRegenerateLocationGroup(projectId)\n    const deleteLocationMutation = useDeleteProjectLocation(projectId)\n    const selectLocationImageMutation = useSelectProjectLocationImage(projectId)\n    const confirmLocationSelectionMutation = useConfirmProjectLocationSelection(projectId)\n    const updateLocationDescriptionMutation = useUpdateProjectLocationDescription(projectId)\n\n    // 删除场景\n    const handleDeleteLocation = useCallback(async (locationId: string) => {\n        if (!confirm(t('location.deleteConfirm'))) return\n        try {\n            await deleteLocationMutation.mutateAsync(locationId)\n        } catch (error: unknown) {\n            if (!isAbortError(error)) {\n                alert(t('location.deleteFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n            }\n        }\n    }, [deleteLocationMutation, t])\n\n    // 处理场景图片选择\n    const handleSelectLocationImage = useCallback(async (locationId: string, imageIndex: number | null) => {\n        try {\n            await selectLocationImageMutation.mutateAsync({ locationId, imageIndex })\n        } catch (error: unknown) {\n            if (isAbortError(error)) {\n                _ulogInfo('请求被中断（可能是页面刷新），后端仍在执行')\n                return\n            }\n            alert(t('image.selectFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n        }\n    }, [selectLocationImageMutation, t])\n\n    // 确认选择并删除其他候选图片\n    const handleConfirmLocationSelection = useCallback(async (locationId: string) => {\n        try {\n            await confirmLocationSelectionMutation.mutateAsync({ locationId })\n            showToast?.(`✓ ${t('image.confirmSuccess')}`, 'success')\n        } catch (error: unknown) {\n            if (isAbortError(error)) {\n                _ulogInfo('请求被中断（可能是页面刷新），后端仍在执行')\n                return\n            }\n            showToast?.(t('image.confirmFailed', { error: getErrorMessage(error, t('common.unknownError')) }), 'error')\n        }\n    }, [confirmLocationSelectionMutation, showToast, t])\n\n    // 单张重新生成场景图片 - 🔥 V6.7: 使用mutation hook\n    const handleRegenerateSingleLocation = useCallback(async (locationId: string, imageIndex: number) => {\n        try {\n            await regenerateSingleImage.mutateAsync({ locationId, imageIndex })\n        } catch (error: unknown) {\n            if (!isAbortError(error)) {\n                alert(t('image.regenerateFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n            }\n            throw error\n        }\n    }, [regenerateSingleImage, t])\n\n    // 整组重新生成场景图片 - 🔥 V6.7: 使用mutation hook\n    const handleRegenerateLocationGroup = useCallback(async (locationId: string, count?: number) => {\n        try {\n            await regenerateGroup.mutateAsync({ locationId, count })\n        } catch (error: unknown) {\n            if (!isAbortError(error)) {\n                alert(t('image.regenerateFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n            }\n            throw error\n        }\n    }, [regenerateGroup, t])\n\n    // 更新场景描述 - 🔥 保存到服务器\n    const handleUpdateLocationDescription = useCallback(async (\n        locationId: string,\n        newDescription: string\n    ) => {\n        try {\n            await updateLocationDescriptionMutation.mutateAsync({\n                locationId,\n                description: newDescription,\n            })\n            refreshAssets()\n        } catch (error: unknown) {\n            if (!isAbortError(error)) {\n                _ulogError('更新描述失败:', getErrorMessage(error, t('common.unknownError')))\n            }\n        }\n    }, [refreshAssets, updateLocationDescriptionMutation, t])\n\n    return {\n        // 🔥 暴露 locations 供组件使用\n        locations,\n        handleDeleteLocation,\n        handleSelectLocationImage,\n        handleConfirmLocationSelection,\n        handleRegenerateSingleLocation,\n        handleRegenerateLocationGroup,\n        handleUpdateLocationDescription\n    }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useProfileManagement.ts",
    "content": "/**\n * 角色档案管理 Hook\n * 处理未确认档案的显示和确认逻辑\n * \n * 🔥 V6.5 重构：直接订阅 useProjectAssets，消除 props drilling\n */\n\n'use client'\n\nimport { useState, useCallback, useMemo } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { CharacterProfileData, parseProfileData } from '@/types/character-profile'\nimport {\n    useProjectAssets,\n    useRefreshProjectAssets,\n    useDeleteProjectCharacter,\n    useConfirmProjectCharacterProfile,\n    useBatchConfirmProjectCharacterProfiles,\n} from '@/lib/query/hooks'\n\ninterface UseProfileManagementProps {\n    projectId: string\n    showToast?: (message: string, type: 'success' | 'warning' | 'error') => void\n}\n\nexport function useProfileManagement({\n    projectId,\n    showToast\n}: UseProfileManagementProps) {\n    const t = useTranslations('assets')\n    // 🔥 直接订阅缓存 - 消除 props drilling\n    const { data: assets } = useProjectAssets(projectId)\n    const characters = useMemo(() => assets?.characters ?? [], [assets?.characters])\n\n    // 🔥 使用刷新函数\n    const refreshAssets = useRefreshProjectAssets(projectId)\n    const deleteCharacterMutation = useDeleteProjectCharacter(projectId)\n    const confirmCharacterProfileMutation = useConfirmProjectCharacterProfile(projectId)\n    const batchConfirmProfilesMutation = useBatchConfirmProjectCharacterProfiles(projectId)\n\n    // 🔥 修复：使用 Set 支持同时确认多个角色（即时反馈用；刷新后由 profileConfirmTaskRunning 接替）\n    const [confirmingCharacterIds, setConfirmingCharacterIds] = useState<Set<string>>(new Set())\n    const [deletingCharacterId, setDeletingCharacterId] = useState<string | null>(null)\n    const [batchConfirmingLocal, setBatchConfirmingLocal] = useState(false)\n    const [editingProfile, setEditingProfile] = useState<{\n        characterId: string\n        characterName: string\n        profileData: CharacterProfileData\n    } | null>(null)\n\n    // 获取未确认的角色\n    const unconfirmedCharacters = useMemo(() =>\n        characters.filter(char => char.profileData && !char.profileConfirmed),\n        [characters]\n    )\n\n    // 🔥 合并任务系统状态 + 本地即时反馈状态，判断角色是否在确认中\n    const isConfirmingCharacter = useCallback((id: string) => {\n        // 本地即时反馈\n        if (confirmingCharacterIds.has(id)) return true\n        // 任务系统持久化状态（刷新后仍可恢复）\n        const character = characters.find(c => c.id === id)\n        return !!character?.profileConfirmTaskRunning\n    }, [confirmingCharacterIds, characters])\n\n    // 🔥 batchConfirming 合并本地 + 任务系统状态\n    const batchConfirming = useMemo(() => {\n        if (batchConfirmingLocal) return true\n        // 如果有任何未确认角色正在运行档案确认任务，视为批量确认中\n        return unconfirmedCharacters.some(char => char.profileConfirmTaskRunning)\n    }, [batchConfirmingLocal, unconfirmedCharacters])\n\n    // 打开编辑对话框\n    const handleEditProfile = useCallback((characterId: string, characterName: string) => {\n        const character = characters.find(c => c.id === characterId)\n        if (!character?.profileData) return\n\n        const profileData = parseProfileData(character.profileData)\n        if (!profileData) {\n            showToast?.(t('characterProfile.parseFailed'), 'error')\n            return\n        }\n\n        setEditingProfile({ characterId, characterName, profileData })\n    }, [characters, showToast, t])\n\n    // 确认单个角色\n    const handleConfirmProfile = useCallback(async (\n        characterId: string,\n        updatedProfileData?: CharacterProfileData\n    ) => {\n        // 🔥 添加到确认中集合\n        setConfirmingCharacterIds(prev => new Set(prev).add(characterId))\n        try {\n            await confirmCharacterProfileMutation.mutateAsync({\n                characterId,\n                profileData: updatedProfileData,\n                generateImage: true,\n            })\n\n            showToast?.(t('characterProfile.confirmSuccessGenerating'), 'success')\n            refreshAssets()\n        } catch (error: unknown) {\n            const message = error instanceof Error ? error.message : t('common.unknownError')\n            showToast?.(t('characterProfile.confirmFailed', { error: message }), 'error')\n        } finally {\n            // 🔥 从确认中集合移除\n            setConfirmingCharacterIds(prev => {\n                const newSet = new Set(prev)\n                newSet.delete(characterId)\n                return newSet\n            })\n            setEditingProfile(null)\n        }\n    }, [confirmCharacterProfileMutation, refreshAssets, showToast, t])\n\n    // 批量确认所有角色\n    const handleBatchConfirm = useCallback(async () => {\n        if (unconfirmedCharacters.length === 0) {\n            showToast?.(t('characterProfile.noPendingCharacters'), 'warning')\n            return\n        }\n\n        if (!confirm(t('characterProfile.batchConfirmPrompt', { count: unconfirmedCharacters.length }))) {\n            return\n        }\n\n        setBatchConfirmingLocal(true)\n        try {\n            const result = await batchConfirmProfilesMutation.mutateAsync()\n            const confirmedCount = result.count ?? 0\n            showToast?.(t('characterProfile.batchConfirmSuccess', { count: confirmedCount }), 'success')\n            refreshAssets()\n        } catch (error: unknown) {\n            const message = error instanceof Error ? error.message : t('common.unknownError')\n            showToast?.(t('characterProfile.batchConfirmFailed', { error: message }), 'error')\n        } finally {\n            setBatchConfirmingLocal(false)\n        }\n    }, [batchConfirmProfilesMutation, refreshAssets, showToast, t, unconfirmedCharacters.length])\n\n    // 删除角色档案（同时删除角色）\n    const handleDeleteProfile = useCallback(async (characterId: string) => {\n        if (!confirm(t('characterProfile.deleteConfirm'))) {\n            return\n        }\n\n        setDeletingCharacterId(characterId)\n        try {\n            await deleteCharacterMutation.mutateAsync(characterId)\n            showToast?.(t('characterProfile.deleteSuccess'), 'success')\n            refreshAssets()\n        } catch (error: unknown) {\n            const message = error instanceof Error ? error.message : t('common.unknownError')\n            showToast?.(t('characterProfile.deleteFailed', { error: message }), 'error')\n        } finally {\n            setDeletingCharacterId(null)\n        }\n    }, [deleteCharacterMutation, refreshAssets, showToast, t])\n\n    return {\n        // 🔥 暴露 characters 供组件使用\n        characters,\n        unconfirmedCharacters,\n        confirmingCharacterIds,\n        isConfirmingCharacter,\n        deletingCharacterId,\n        batchConfirming,\n        editingProfile,\n        handleEditProfile,\n        handleConfirmProfile,\n        handleBatchConfirm,\n        handleDeleteProfile,\n        setEditingProfile\n    }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useTTSGeneration.ts",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\n/**\n * useTTSGeneration - TTS 和音色相关逻辑\n * 从 AssetsStage.tsx 提取\n * \n * 🔥 V6.5 重构：直接订阅 useProjectAssets，消除 props drilling\n */\n\nimport { useState } from 'react'\nimport {\n    useProjectAssets,\n    useRefreshProjectAssets,\n    useUpdateProjectCharacterVoiceSettings,\n    useSaveProjectDesignedVoice,\n} from '@/lib/query/hooks'\n\ninterface VoiceDesignCharacter {\n    id: string\n    name: string\n    hasExistingVoice: boolean\n}\n\ninterface UseTTSGenerationProps {\n    projectId: string\n}\n\nfunction getErrorMessage(error: unknown, fallback: string): string {\n    if (error instanceof Error) return error.message\n    if (typeof error === 'object' && error !== null) {\n        const message = (error as { message?: unknown }).message\n        if (typeof message === 'string') return message\n    }\n    return fallback\n}\n\nexport function useTTSGeneration({\n    projectId\n}: UseTTSGenerationProps) {\n    const t = useTranslations('assets')\n    // 🔥 直接订阅缓存 - 消除 props drilling\n    const { data: assets } = useProjectAssets(projectId)\n    const characters = assets?.characters ?? []\n\n    // 🔥 使用刷新函数\n    const refreshAssets = useRefreshProjectAssets(projectId)\n    const updateVoiceSettingsMutation = useUpdateProjectCharacterVoiceSettings(projectId)\n    const saveDesignedVoiceMutation = useSaveProjectDesignedVoice(projectId)\n\n    const [voiceDesignCharacter, setVoiceDesignCharacter] = useState<VoiceDesignCharacter | null>(null)\n\n    // 音色变更回调 - 🔥 保存到服务器而不是本地更新\n    const handleVoiceChange = async (characterId: string, voiceType: string, voiceId: string, customVoiceUrl?: string) => {\n        try {\n            await updateVoiceSettingsMutation.mutateAsync({\n                characterId,\n                voiceType: voiceType as 'qwen-designed' | 'uploaded' | 'custom' | null,\n                voiceId,\n                customVoiceUrl,\n            })\n\n            // 🔥 刷新缓存\n            refreshAssets()\n        } catch (error: unknown) {\n            _ulogError('更新音色失败:', getErrorMessage(error, t('common.unknownError')))\n        }\n    }\n\n    // 打开 AI 声音设计对话框\n    const handleOpenVoiceDesign = (characterId: string, characterName: string) => {\n        const character = characters.find(c => c.id === characterId)\n        setVoiceDesignCharacter({\n            id: characterId,\n            name: characterName,\n            hasExistingVoice: !!character?.customVoiceUrl\n        })\n    }\n\n    // 保存 AI 设计的声音\n    const handleVoiceDesignSave = async (voiceId: string, audioBase64: string) => {\n        if (!voiceDesignCharacter) return\n\n        try {\n            await saveDesignedVoiceMutation.mutateAsync({\n                characterId: voiceDesignCharacter.id,\n                voiceId,\n                audioBase64,\n            })\n            refreshAssets()\n            alert(t('tts.voiceDesignSaved', { name: voiceDesignCharacter.name }))\n        } catch (error: unknown) {\n            alert(t('tts.saveVoiceDesignFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n        } finally {\n            setVoiceDesignCharacter(null)\n        }\n    }\n\n    // 关闭声音设计对话框\n    const handleCloseVoiceDesign = () => {\n        setVoiceDesignCharacter(null)\n    }\n\n    return {\n        // 🔥 暴露 characters 供组件使用\n        characters,\n        voiceDesignCharacter,\n        handleVoiceChange,\n        handleOpenVoiceDesign,\n        handleVoiceDesignSave,\n        handleCloseVoiceDesign\n    }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardActions.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\nimport { getImageGenerationCountOptions } from '@/lib/image-generation/count'\n\ntype LocationCardActionsProps =\n  | {\n    mode: 'selection'\n    selectedIndex: number | null\n    isConfirmingSelection: boolean\n    confirmingSelectionState: TaskPresentationState | null\n    onConfirmSelection?: () => void\n  }\n  | {\n    mode: 'compact'\n    currentImageUrl: string | null | undefined\n    isTaskRunning: boolean\n    hasDescription: boolean\n    generationCount: number\n    onGenerationCountChange: (value: number) => void\n    onGenerate: (count?: number) => void\n  }\n\nexport default function LocationCardActions(props: LocationCardActionsProps) {\n  const t = useTranslations('assets')\n\n  if (props.mode === 'selection') {\n    return (\n      <>\n        <div className=\"mt-3 text-xs text-[var(--glass-text-tertiary)] text-center\">\n          {t('image.selectTip')}\n        </div>\n\n        {props.selectedIndex !== null && props.onConfirmSelection && (\n          <div className=\"mt-4 flex justify-end\">\n            <button\n              onClick={props.onConfirmSelection}\n              disabled={props.isConfirmingSelection}\n              className=\"px-4 py-2 text-sm bg-[var(--glass-tone-success-fg)] text-white rounded-lg hover:bg-[var(--glass-tone-success-fg)] transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n            >\n              {props.isConfirmingSelection ? (\n                <TaskStatusInline state={props.confirmingSelectionState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n              ) : (\n                <>\n                  <AppIcon name=\"check\" className=\"w-4 h-4\" />\n                  {t('image.confirmOption', { number: props.selectedIndex + 1 })}\n                  <span className=\"text-xs opacity-75\">{t('image.deleteOthersHint')}</span>\n                </>\n              )}\n            </button>\n          </div>\n        )}\n      </>\n    )\n  }\n\n  return (\n    !props.currentImageUrl && !props.isTaskRunning && (\n      <ImageGenerationInlineCountButton\n        prefix={<span>{t('image.generateCountPrefix')}</span>}\n        suffix={<span>{t('image.generateCountSuffix')}</span>}\n        value={props.generationCount}\n        options={getImageGenerationCountOptions('location')}\n        onValueChange={props.onGenerationCountChange}\n        onClick={() => props.onGenerate(props.generationCount)}\n        disabled={!props.hasDescription}\n        ariaLabel={t('image.selectCount')}\n        className=\"glass-btn-base glass-btn-primary flex w-full items-center justify-center gap-1 py-1 text-xs disabled:opacity-50\"\n        selectClassName=\"appearance-none bg-transparent border-0 pl-0 pr-3 text-xs font-semibold text-current outline-none cursor-pointer leading-none transition-colors\"\n      />\n    )\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardHeader.tsx",
    "content": "'use client'\n\nimport type { ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\n\ntype LocationCardHeaderProps =\n  | {\n    mode: 'selection'\n    locationName: string\n    summary?: string | null\n    selectedIndex: number | null\n    statusText?: string | null\n    actions: ReactNode\n  }\n  | {\n    mode: 'compact'\n    locationName: string\n    summary?: string | null\n    actions: ReactNode\n  }\n\nexport default function LocationCardHeader(props: LocationCardHeaderProps) {\n  const t = useTranslations('assets')\n\n  if (props.mode === 'selection') {\n    return (\n      <div className=\"flex items-start justify-between mb-4\">\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-center gap-2 mb-1\">\n            <span className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">{props.locationName}</span>\n          </div>\n          {props.summary && (\n            <div className=\"text-xs text-[var(--glass-text-secondary)] mb-1\" title={props.summary}>\n              {props.summary}\n            </div>\n          )}\n          <div className=\"text-xs text-[var(--glass-text-tertiary)]\">\n            {props.statusText ?? (\n              props.selectedIndex !== null\n                ? t('image.optionSelected', { number: props.selectedIndex + 1 })\n                : t('image.selectFirst')\n            )}\n          </div>\n        </div>\n        <div className=\"flex items-center gap-1 ml-2\">{props.actions}</div>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"space-y-1\">\n      <div className=\"flex items-center justify-between gap-1\">\n        <div className=\"text-xs font-semibold text-[var(--glass-text-primary)] truncate\" title={props.locationName}>\n          {props.locationName}\n        </div>\n        <div className=\"flex items-center gap-1\">{props.actions}</div>\n      </div>\n      {props.summary && (\n        <div className=\"text-xs text-[var(--glass-text-tertiary)] truncate\" title={props.summary}>\n          {props.summary}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationImageList.tsx",
    "content": "'use client'\n\nimport type { ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { resolveErrorDisplay } from '@/lib/errors/display'\nimport TaskStatusOverlay from '@/components/task/TaskStatusOverlay'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { AppIcon } from '@/components/ui/icons'\nimport ImageGenerationSlotOverlay from '@/components/image-generation/ImageGenerationSlotOverlay'\nimport {\n  countGeneratedImageSlots,\n  resolveGroupedImageSlotPhase,\n} from '@/lib/image-generation/slot-state'\n\ntype SelectionImage = {\n  id: string\n  imageIndex: number\n  imageUrl: string | null\n  isSelected?: boolean\n  lastError?: { code: string; message: string } | null\n  imageErrorMessage?: string | null\n}\n\ntype LocationImageListProps =\n  | {\n    mode: 'selection'\n    locationId: string\n    locationName: string\n    images: SelectionImage[]\n    selectedImageId?: string | null\n    selectedIndex: number | null\n    isGroupTaskRunning: boolean\n    isImageTaskRunning: (imageIndex: number) => boolean\n    displayTaskPresentation: TaskPresentationState | null\n    onImageClick: (imageUrl: string) => void\n    onSelectImage?: (locationId: string, imageIndex: number | null) => void\n  }\n  | {\n    mode: 'single'\n    locationName: string\n    currentImageUrl: string | null | undefined\n    selectedIndex: number | null\n    hasMultipleImages: boolean\n    isTaskRunning: boolean\n    displayTaskPresentation: TaskPresentationState | null\n    imageErrorMessage?: string | null\n    onImageClick: (imageUrl: string) => void\n    overlayActions: ReactNode\n  }\n\nexport default function LocationImageList(props: LocationImageListProps) {\n  const t = useTranslations('assets')\n\n  if (props.mode === 'selection') {\n    const generatedCount = countGeneratedImageSlots(props.images)\n    const hasPendingEmptySlots = props.isGroupTaskRunning && generatedCount < props.images.length\n\n    return (\n      <div className=\"grid grid-cols-3 gap-3\">\n        {props.images.map((img) => {\n          const isThisSelected = props.selectedImageId\n            ? img.id === props.selectedImageId\n            : img.isSelected\n          const slotTaskRunning =\n            props.isImageTaskRunning(img.imageIndex) ||\n            (props.isGroupTaskRunning && !img.imageUrl)\n          const phase = resolveGroupedImageSlotPhase(\n            { imageUrl: img.imageUrl },\n            {\n              isGroupRunning: props.isGroupTaskRunning,\n              isSlotRunning: slotTaskRunning,\n              hasPendingEmptySlots,\n            },\n          )\n          const imageError = resolveErrorDisplay(img.lastError || {\n            code: img.imageErrorMessage || null,\n            message: img.imageErrorMessage || null,\n          })\n          return (\n            <div key={img.id} className=\"relative group/thumb\">\n              <div\n                onClick={() => {\n                  if (img.imageUrl) {\n                    props.onImageClick(img.imageUrl)\n                  }\n                }}\n                className={`rounded-lg overflow-hidden border-2 transition-all relative ${img.imageUrl ? 'cursor-pointer' : 'cursor-default'} ${isThisSelected\n                  ? 'border-[var(--glass-stroke-success)] ring-2 ring-[var(--glass-focus-ring)]'\n                  : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'\n                  }`}\n              >\n                {img.imageUrl ? (\n                  <MediaImageWithLoading\n                    src={img.imageUrl}\n                    alt={t('image.optionAlt', { name: props.locationName, number: img.imageIndex + 1 })}\n                    containerClassName=\"w-full min-h-[88px]\"\n                    className=\"w-full h-auto object-contain\"\n                  />\n                ) : (\n                  <div className=\"flex min-h-[88px] items-center justify-center bg-[var(--glass-bg-muted)]\">\n                    {imageError && phase !== 'generating' && phase !== 'regenerating' ? (\n                      <div className=\"flex flex-col items-center justify-center px-3 py-6 text-center\">\n                        <AppIcon name=\"alert\" className=\"mb-2 h-6 w-6 text-[var(--glass-tone-danger-fg)]\" />\n                        <span className=\"text-xs font-medium text-[var(--glass-tone-danger-fg)]\">{t('common.generateFailed')}</span>\n                      </div>\n                    ) : (\n                      <div className=\"flex flex-col items-center justify-center gap-2 px-3 py-6 text-[var(--glass-text-tertiary)]\">\n                        <div className=\"h-12 w-12 animate-pulse rounded-xl bg-[var(--glass-bg-surface-strong)]\" />\n                        <span className=\"text-xs\">{t('image.generatingPlaceholder')}</span>\n                      </div>\n                    )}\n                  </div>\n                )}\n\n                {phase === 'generating' && (\n                  <ImageGenerationSlotOverlay label={t('image.generating')} />\n                )}\n\n                {phase === 'regenerating' && (\n                  <ImageGenerationSlotOverlay label={t('image.regenerating')} />\n                )}\n\n                <div\n                  className={`absolute bottom-2 left-2 flex items-center gap-1 text-white text-xs px-2 py-0.5 rounded ${isThisSelected ? 'bg-[var(--glass-tone-success-fg)]' : 'bg-[var(--glass-overlay)]'\n                    }`}\n                >\n                  <span>{t('image.optionNumber', { number: img.imageIndex + 1 })}</span>\n                  {isThisSelected && (\n                    <AppIcon name=\"checkTiny\" className=\"h-3 w-3\" />\n                  )}\n                </div>\n\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    if (phase !== 'generating' && phase !== 'regenerating' && img.imageUrl) {\n                      props.onSelectImage?.(props.locationId, isThisSelected ? null : img.imageIndex)\n                    }\n                  }}\n                  disabled={phase === 'generating' || phase === 'regenerating' || !img.imageUrl}\n                  className={`absolute top-2 right-2 w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-sm ${isThisSelected\n                    ? 'bg-[var(--glass-tone-success-fg)] text-white'\n                    : 'bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-accent-from)] hover:text-white'\n                    } disabled:opacity-50`}\n                  title={isThisSelected ? t('image.cancelSelection') : t('image.useThis')}\n                >\n                  <AppIcon name=\"check\" className=\"w-4 h-4\" />\n                </button>\n              </div>\n            </div>\n          )\n        })}\n      </div>\n    )\n  }\n\n  const locationErrorDisplay = resolveErrorDisplay({\n    code: props.imageErrorMessage || null,\n    message: props.imageErrorMessage || null,\n  })\n\n  return (\n    <div className=\"rounded-lg overflow-hidden border-2 border-[var(--glass-stroke-base)] relative\">\n      {props.currentImageUrl ? (\n        <div className=\"relative w-full\">\n          <MediaImageWithLoading\n            src={props.currentImageUrl}\n            alt={props.locationName}\n            containerClassName=\"w-full min-h-[120px]\"\n            className=\"w-full h-auto object-contain cursor-pointer hover:opacity-90 transition-opacity\"\n            onClick={() => props.onImageClick(props.currentImageUrl!)}\n          />\n          {props.selectedIndex !== null && props.hasMultipleImages && (\n            <div className=\"absolute bottom-2 left-2 bg-[var(--glass-tone-success-fg)] text-white text-xs px-2 py-0.5 rounded\">\n              {t('image.optionNumber', { number: props.selectedIndex + 1 })}\n            </div>\n          )}\n        </div>\n      ) : (\n        <div className=\"w-full h-full bg-[var(--glass-bg-muted)] flex items-center justify-center\">\n          {locationErrorDisplay && !props.isTaskRunning ? (\n            <div className=\"flex flex-col items-center justify-center py-8 px-4 text-center\">\n              <AppIcon name=\"alert\" className=\"w-8 h-8 text-[var(--glass-tone-danger-fg)] mb-2\" />\n              <div className=\"text-[var(--glass-tone-danger-fg)] text-xs font-medium mb-1\">{t('common.generateFailed')}</div>\n              <div className=\"text-[var(--glass-tone-danger-fg)] text-xs max-w-full break-words\">{locationErrorDisplay.message}</div>\n            </div>\n          ) : (\n            <AppIcon name=\"globe2\" className=\"w-8 h-8 text-[var(--glass-text-tertiary)]\" />\n          )}\n        </div>\n      )}\n      {props.isTaskRunning && (\n        <TaskStatusOverlay state={props.displayTaskPresentation} />\n      )}\n      {!props.isTaskRunning && (\n        <div className=\"absolute top-2 left-2 flex gap-1\">\n          {props.overlayActions}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/PromptEditorPanel.tsx",
    "content": "import TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { useTranslations } from 'next-intl'\nimport type { PromptStageRuntime } from './hooks/usePromptStageActions'\n\ninterface PromptEditorPanelProps {\n  runtime: PromptStageRuntime\n}\n\nexport default function PromptEditorPanel({ runtime }: PromptEditorPanelProps) {\n  const tStoryboard = useTranslations('storyboard')\n  const tNovelPromotion = useTranslations('novelPromotion')\n  const {\n    onAppendContent,\n    appendContent,\n    setAppendContent,\n    isAppending,\n    appendTaskRunningState,\n    handleAppendSubmit,\n    isAnyTaskRunning,\n    onNext,\n  } = runtime\n\n  return (\n    <>\n      {onAppendContent && (\n        <div className=\"mt-8 p-6 bg-[var(--glass-bg-muted)] rounded-lg border-2 border-dashed border-[var(--glass-stroke-strong)]\">\n          <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)] mb-3\">{tStoryboard('prompts.appendTitle')}</h3>\n          <p className=\"text-sm text-[var(--glass-text-secondary)] mb-4\">\n            {tStoryboard('prompts.appendDescription')}\n          </p>\n          <textarea\n            value={appendContent}\n            onChange={(e) => setAppendContent(e.target.value)}\n            placeholder={tStoryboard('panelActions.pasteSrtPlaceholder')}\n            disabled={isAppending}\n            className=\"w-full h-48 p-4 border border-[var(--glass-stroke-strong)] rounded-lg resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] disabled:bg-[var(--glass-bg-muted)] disabled:cursor-not-allowed font-mono text-sm\"\n          />\n          <div className=\"flex justify-end mt-4\">\n            <button\n              onClick={handleAppendSubmit}\n              disabled={isAppending || !appendContent.trim()}\n              className=\"glass-btn-base px-6 py-3 bg-[var(--glass-tone-success-fg)] text-white hover:bg-[var(--glass-tone-success-fg)] disabled:opacity-50 disabled:cursor-not-allowed flex items-center\"\n            >\n              {isAppending ? (\n                <TaskStatusInline state={appendTaskRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n              ) : (\n                tStoryboard('prompts.appendSubmit')\n              )}\n            </button>\n          </div>\n        </div>\n      )}\n\n      <div className=\"flex justify-end items-center pt-4\">\n        <button\n          onClick={onNext}\n          disabled={isAnyTaskRunning}\n          className=\"glass-btn-base px-6 py-2 bg-[var(--glass-accent-from)] text-white hover:bg-[var(--glass-accent-to)] disabled:opacity-50 disabled:cursor-not-allowed\"\n        >\n          {tNovelPromotion('buttons.enterVideoGeneration')}\n        </button>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/PromptListCardView.tsx",
    "content": "import { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport TaskStatusOverlay from '@/components/task/TaskStatusOverlay'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport {\n  parseImagePrompt,\n  type LocationAssetWithImages,\n  type PromptStageRuntime,\n} from './hooks/usePromptStageActions'\n\ninterface PromptListCardViewProps {\n  runtime: PromptStageRuntime\n}\nexport default function PromptListCardView({ runtime }: PromptListCardViewProps) {\n  const t = useTranslations('storyboard')\n  const tCommon = useTranslations('common')\n\n  const {\n    shots,\n    onGenerateImage,\n    isBatchSubmitting,\n    assetLibraryCharacters,\n    assetLibraryLocations,\n    styleLabel,\n    editingPrompt,\n    editValue,\n    aiModifyInstruction,\n    selectedAssets,\n    showAssetPicker,\n    aiModifyingShots,\n    textareaRef,\n    shotExtraAssets,\n    getGenerateButtonToneClass,\n    getShotRunningState,\n    isShotTaskRunning,\n    handleStartEdit,\n    handleSaveEdit,\n    handleCancelEdit,\n    handleModifyInstructionChange,\n    handleSelectAsset,\n    handleAiModify,\n    handleEditValueChange,\n    handleRemoveSelectedAsset,\n    setPreviewImage,\n  } = runtime\n\n  return (\n    <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n      {shots.map((shot) => {\n        const shotRunningState = getShotRunningState(shot)\n        const isEditing = editingPrompt?.shotId === shot.id && editingPrompt?.field === 'imagePrompt'\n        const promptContent = shot.imagePrompt ? parseImagePrompt(shot.imagePrompt).content : ''\n\n        return (\n          <div key={shot.id} className=\"card-base overflow-hidden\">\n            <div className=\"aspect-video bg-[var(--glass-bg-muted)] flex items-center justify-center relative\">\n              {shot.imageUrl ? (\n                <MediaImageWithLoading\n                  src={shot.imageUrl}\n                  alt={`${t('panel.shot')}${shot.shotId}`}\n                  containerClassName=\"w-full h-full\"\n                  className=\"w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity\"\n                  onClick={() => setPreviewImage(shot.imageUrl)}\n                />\n              ) : (\n                <AppIcon name=\"video\" className=\"w-16 h-16 text-[var(--glass-text-tertiary)]\" />\n              )}\n              <div className=\"absolute top-2 left-2 bg-[var(--glass-overlay)] text-white px-2 py-1 rounded text-xs font-medium\">\n                #{shot.shotId}\n              </div>\n              {shot.imageUrl && (\n                <button\n                  onClick={(event) => {\n                    event.stopPropagation()\n                    onGenerateImage(shot.id, shotExtraAssets[shot.id])\n                  }}\n                  disabled={isBatchSubmitting}\n                  className=\"absolute top-2 right-2 bg-[var(--glass-overlay)] hover:bg-[var(--glass-text-primary)] text-white p-2 rounded-full transition-all disabled:opacity-50 disabled:cursor-not-allowed z-10\"\n                  title={t('panel.regenerateImage')}\n                >\n                  <AppIcon name=\"refresh\" className=\"w-4 h-4\" />\n                </button>\n              )}\n              {isShotTaskRunning(shot) && <TaskStatusOverlay state={shotRunningState} />}\n            </div>\n\n            <div className=\"p-5 space-y-4\">\n              {shot.imagePrompt && (\n                <div className=\"space-y-2 border-b pb-4\">\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded-md text-sm font-medium\">\n                      <AppIcon name=\"imageEdit\" className=\"w-4 h-4\" />\n                      {styleLabel}\n                    </span>\n                  </div>\n\n                  <div className=\"text-sm\">\n                    <div className=\"flex items-center justify-between mb-2\">\n                      <span className=\"font-semibold text-[var(--glass-text-primary)] text-base\">{t('prompts.imagePrompt')}</span>\n                      {!isEditing && (\n                        <button\n                          onClick={() => handleStartEdit(shot.id, 'imagePrompt', shot.imagePrompt || '')}\n                          className=\"text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-text-primary)] p-1.5 hover:bg-[var(--glass-tone-info-bg)] rounded transition-colors\"\n                          title={t('prompts.imagePrompt')}\n                        >\n                          <AppIcon name=\"edit\" className=\"w-4 h-4\" />\n                        </button>\n                      )}\n                    </div>\n\n                    {isEditing ? (\n                      <div className=\"space-y-3\">\n                        <div>\n                          <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('prompts.currentPrompt')}</label>\n                          <textarea\n                            value={editValue}\n                            onChange={(event) => handleEditValueChange(event.target.value)}\n                            className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] text-sm resize-none\"\n                            rows={4}\n                            autoFocus\n                          />\n                        </div>\n\n                        <div className=\"border-t pt-3\">\n                          <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">\n                            {t('prompts.aiInstruction')} <span className=\"text-[var(--glass-text-tertiary)]\">{t('prompts.supportReference')}</span>\n                          </label>\n                          <div className=\"relative\">\n                            <textarea\n                              ref={textareaRef}\n                              value={aiModifyInstruction}\n                              onChange={(event) => handleModifyInstructionChange(event.target.value)}\n                              placeholder={t('prompts.instructionPlaceholder')}\n                              className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] text-sm resize-none\"\n                              rows={2}\n                            />\n\n                            {showAssetPicker && (\n                              <div className=\"absolute z-10 mt-1 w-full bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-strong)] rounded-lg shadow-lg max-h-48 overflow-y-auto\">\n                                <div className=\"p-2\">\n                                  <div className=\"text-xs font-medium text-[var(--glass-text-tertiary)] mb-2\">{t('prompts.selectAsset')}</div>\n\n                                  {assetLibraryCharacters.length > 0 && (\n                                    <div className=\"mb-2\">\n                                      <div className=\"text-xs text-[var(--glass-text-tertiary)] mb-1\">{t('prompts.character')}</div>\n                                      {assetLibraryCharacters.map((character) => (\n                                        <button\n                                          key={character.id}\n                                          onClick={() => handleSelectAsset({ id: character.id, name: character.name, description: character.description, type: 'character' })}\n                                          className=\"w-full text-left px-2 py-1.5 hover:bg-[var(--glass-tone-info-bg)] rounded text-sm\"\n                                        >\n                                          {character.name}\n                                        </button>\n                                      ))}\n                                    </div>\n                                  )}\n\n                                  {assetLibraryLocations.length > 0 && (\n                                    <div>\n                                      <div className=\"text-xs text-[var(--glass-text-tertiary)] mb-1\">{t('prompts.location')}</div>\n                                      {assetLibraryLocations.map((location) => {\n                                        const locationAsset = location as LocationAssetWithImages\n                                        const selectedImage = locationAsset.selectedImageId\n                                          ? locationAsset.images?.find((image) => image.id === locationAsset.selectedImageId)\n                                          : locationAsset.images?.find((image) => image.isSelected) || locationAsset.images?.find((image) => image.imageUrl) || locationAsset.images?.[0]\n                                        const description = selectedImage?.description || locationAsset.description || ''\n\n                                        return (\n                                          <button\n                                            key={location.id}\n                                            onClick={() => handleSelectAsset({ id: location.id, name: location.name, description, type: 'location' })}\n                                            className=\"w-full text-left px-2 py-1.5 hover:bg-[var(--glass-tone-info-bg)] rounded text-sm\"\n                                          >\n                                            {location.name}\n                                          </button>\n                                        )\n                                      })}\n                                    </div>\n                                  )}\n                                </div>\n                              </div>\n                            )}\n                          </div>\n\n                          {selectedAssets.length > 0 && (\n                            <div className=\"flex flex-wrap gap-2 mt-3 p-2.5 bg-[var(--glass-bg-muted)]/50 rounded-lg border border-[var(--glass-stroke-base)]\">\n                              <div className=\"text-xs text-[var(--glass-text-tertiary)] font-medium w-full mb-1\">{t('prompts.referencedAssets')}</div>\n                              {selectedAssets.map((asset, index) => (\n                                <span\n                                  key={asset.id}\n                                  className={`group inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${asset.type === 'character'\n                                    ? 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] border border-[var(--glass-stroke-strong)] hover:bg-[var(--glass-bg-muted)] hover:border-[var(--glass-stroke-focus)]'\n                                    : 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] border border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] hover:border-[var(--glass-stroke-focus)]'\n                                    }`}\n                                >\n                                  <span>{asset.name}</span>\n                                  <button\n                                    onClick={() => handleRemoveSelectedAsset(index, asset.name)}\n                                    className=\"ml-0.5 hover:bg-[var(--glass-bg-surface-strong)] rounded p-0.5 transition-colors\"\n                                    title={t('prompts.removeAsset')}\n                                  >\n                                    <AppIcon name=\"closeSolid\" className=\"w-3 h-3\" />\n                                  </button>\n                                </span>\n                              ))}\n                            </div>\n                          )}\n\n                          <button\n                            onClick={handleAiModify}\n                            disabled={editingPrompt ? aiModifyingShots.has(editingPrompt.shotId) || !aiModifyInstruction.trim() : true}\n                            className=\"glass-btn-base glass-btn-primary mt-2 w-full px-3 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2\"\n                            title={t('prompts.aiModifyTip')}\n                          >\n                            {editingPrompt && aiModifyingShots.has(editingPrompt.shotId) ? (\n                              <TaskStatusInline\n                                state={resolveTaskPresentationState({ phase: 'processing', intent: 'modify', resource: 'text', hasOutput: true })}\n                                className=\"text-white [&>span]:text-white [&_svg]:text-white\"\n                              />\n                            ) : (\n                              t('prompts.aiModify')\n                            )}\n                          </button>\n                        </div>\n\n                        <div className=\"flex gap-2 pt-2 border-t\">\n                          <button\n                            onClick={handleSaveEdit}\n                            className=\"flex-1 px-4 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg text-sm font-medium hover:bg-[var(--glass-accent-to)] transition-colors\"\n                          >\n                            {t('prompts.save')}\n                          </button>\n                          <button\n                            onClick={handleCancelEdit}\n                            className=\"flex-1 px-4 py-2 bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] rounded-lg text-sm font-medium hover:bg-[var(--glass-bg-muted)] transition-colors\"\n                          >\n                            {tCommon('cancel')}\n                          </button>\n                        </div>\n                      </div>\n                    ) : (\n                      <p className=\"text-[var(--glass-text-secondary)] leading-relaxed\">{promptContent}</p>\n                    )}\n                  </div>\n                </div>\n              )}\n\n              <div className=\"grid grid-cols-2 gap-x-4 gap-y-2 text-sm\">\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"text-[var(--glass-text-tertiary)] font-medium\">SRT:</span>\n                  <span className=\"text-[var(--glass-text-primary)]\">{shot.srtStart}-{shot.srtEnd}</span>\n                  <span className=\"text-[var(--glass-text-tertiary)]\">({shot.srtDuration?.toFixed(1)}s)</span>\n                </div>\n                {shot.scale && (\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-[var(--glass-text-tertiary)] font-medium\">{t('panel.shotType')}</span>\n                    <span className=\"text-[var(--glass-text-primary)]\">{shot.scale}</span>\n                  </div>\n                )}\n                {shot.locations && (\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-[var(--glass-text-tertiary)] font-medium\">{t('panel.location')}</span>\n                    <span className=\"text-[var(--glass-text-primary)]\">{shot.locations}</span>\n                  </div>\n                )}\n                {shot.module && (\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-[var(--glass-text-tertiary)] font-medium\">{t('panel.mode')}</span>\n                    <span className=\"text-[var(--glass-text-primary)]\">{shot.module}</span>\n                  </div>\n                )}\n              </div>\n\n              <button\n                onClick={() => onGenerateImage(shot.id, shotExtraAssets[shot.id])}\n                disabled={isShotTaskRunning(shot) || isBatchSubmitting}\n                className={`glass-btn-base w-full py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed ${getGenerateButtonToneClass(shot)}`}\n              >\n                {shot.imageUrl ? t('group.hasSynced') : isShotTaskRunning(shot) ? (\n                  <TaskStatusInline state={shotRunningState} className=\"justify-center text-white [&>span]:text-white [&_svg]:text-white\" />\n                ) : t('assets.location.generateImage')}\n              </button>\n            </div>\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/PromptListPanel.tsx",
    "content": "import { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { AppIcon } from '@/components/ui/icons'\nimport type { PromptStageRuntime } from './hooks/usePromptStageActions'\nimport PromptListCardView from './PromptListCardView'\nimport PromptListTableView from './PromptListTableView'\n\ninterface PromptListPanelProps {\n  runtime: PromptStageRuntime\n}\n\nexport default function PromptListPanel({ runtime }: PromptListPanelProps) {\n  const t = useTranslations('storyboard')\n  const tCommon = useTranslations('common')\n\n  const {\n    viewMode,\n    onViewModeChange,\n    onGenerateAllImages,\n    isAnyTaskRunning,\n    runningCount,\n    batchTaskRunningState,\n    onBack,\n    shots,\n  } = runtime\n\n  return (\n    <>\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center space-x-4\">\n          {onBack && (\n            <button\n              onClick={onBack}\n              disabled={isAnyTaskRunning}\n              className=\"glass-btn-base px-4 py-2 bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)] text-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5\"\n            >\n              <AppIcon name=\"chevronLeft\" className=\"w-4 h-4\" />\n              <span>{tCommon('back')}</span>\n            </button>\n          )}\n          <span className=\"text-sm text-[var(--glass-text-secondary)]\">\n            {t('header.panels')}: {shots.length}\n            {runningCount > 0 && (\n              <span className=\"ml-2 text-[var(--glass-tone-info-fg)] font-medium\">\n                ({runningCount} {t('group.generating')})\n              </span>\n            )}\n          </span>\n          <button\n            onClick={onGenerateAllImages}\n            disabled={isAnyTaskRunning}\n            className=\"glass-btn-base px-4 py-2 bg-[var(--glass-tone-success-fg)] text-white hover:bg-[var(--glass-tone-success-fg)] text-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center\"\n          >\n            {isAnyTaskRunning ? (\n              <TaskStatusInline state={batchTaskRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n            ) : (\n              t('group.generateAll')\n            )}\n          </button>\n        </div>\n\n        <div className=\"flex items-center space-x-2\">\n          <button\n            onClick={() => onViewModeChange('card')}\n            className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${viewMode === 'card' ? 'bg-[var(--glass-accent-from)] text-white' : 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'}`}\n          >\n            {tCommon('preview')}\n          </button>\n          <button\n            onClick={() => onViewModeChange('table')}\n            className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${viewMode === 'table' ? 'bg-[var(--glass-accent-from)] text-white' : 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'}`}\n          >\n            {t('common.status')}\n          </button>\n        </div>\n      </div>\n\n      {viewMode === 'card' ? (\n        <PromptListCardView runtime={runtime} />\n      ) : (\n        <PromptListTableView runtime={runtime} />\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/PromptListTableView.tsx",
    "content": "import { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport {\n  parseImagePrompt,\n  type PromptStageRuntime,\n} from './hooks/usePromptStageActions'\n\ninterface PromptListTableViewProps {\n  runtime: PromptStageRuntime\n}\n\nexport default function PromptListTableView({ runtime }: PromptListTableViewProps) {\n  const t = useTranslations('storyboard')\n\n  const {\n    shots,\n    onGenerateImage,\n    isBatchSubmitting,\n    shotExtraAssets,\n    getGenerateButtonToneClass,\n    getShotRunningState,\n    isShotTaskRunning,\n    handleStartEdit,\n    setPreviewImage,\n  } = runtime\n\n  return (\n    <div className=\"card-base overflow-hidden\">\n      <div className=\"overflow-x-auto\">\n        <table className=\"min-w-full divide-y divide-[var(--glass-stroke-soft)]\">\n          <thead className=\"bg-[var(--glass-bg-muted)]\">\n            <tr>\n              <th className=\"px-4 py-3 text-left text-xs font-medium text-[var(--glass-text-tertiary)] uppercase tracking-wider\">{t('panel.shot')}</th>\n              <th className=\"px-4 py-3 text-left text-xs font-medium text-[var(--glass-text-tertiary)] uppercase tracking-wider\">{t('common.preview')}</th>\n              <th className=\"px-4 py-3 text-left text-xs font-medium text-[var(--glass-text-tertiary)] uppercase tracking-wider\">SRT</th>\n              <th className=\"px-4 py-3 text-left text-xs font-medium text-[var(--glass-text-tertiary)] uppercase tracking-wider\">{t('common.actions')}</th>\n            </tr>\n          </thead>\n          <tbody className=\"bg-[var(--glass-bg-surface)] divide-y divide-[var(--glass-stroke-soft)]\">\n            {shots.map((shot) => {\n              const { content } = parseImagePrompt(shot.imagePrompt)\n              const shotRunningState = getShotRunningState(shot)\n\n              return (\n                <tr key={shot.id} className=\"hover:bg-[var(--glass-bg-muted)]\">\n                  <td className=\"px-4 py-3 whitespace-nowrap text-sm font-medium text-[var(--glass-text-primary)]\">#{shot.shotId}</td>\n                  <td className=\"px-4 py-3 whitespace-nowrap\">\n                    <div className=\"w-20 h-12 bg-[var(--glass-bg-muted)] rounded overflow-hidden\">\n                      {shot.imageUrl && (\n                        <MediaImageWithLoading\n                          src={shot.imageUrl}\n                          alt={`${t('panel.shot')}${shot.shotId}`}\n                          containerClassName=\"w-full h-full\"\n                          className=\"w-full h-full object-cover cursor-pointer hover:opacity-80 transition-opacity\"\n                          onClick={() => setPreviewImage(shot.imageUrl)}\n                        />\n                      )}\n                    </div>\n                  </td>\n                  <td className=\"px-4 py-3 whitespace-nowrap text-sm text-[var(--glass-text-secondary)]\">\n                    {shot.srtStart}-{shot.srtEnd}\n                    <div className=\"text-xs text-[var(--glass-text-tertiary)]\">{shot.srtDuration?.toFixed(1)}s</div>\n                    <div className=\"text-xs mt-1 line-clamp-2\">{content}</div>\n                  </td>\n                  <td className=\"px-4 py-3 whitespace-nowrap text-sm\">\n                    <div className=\"flex items-center space-x-2\">\n                      <button\n                        onClick={() => onGenerateImage(shot.id, shotExtraAssets[shot.id])}\n                        disabled={isShotTaskRunning(shot) || isBatchSubmitting}\n                        className={`glass-btn-base px-3 py-1 text-xs disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1 ${getGenerateButtonToneClass(shot)}`}\n                      >\n                        {isShotTaskRunning(shot) ? <TaskStatusInline state={shotRunningState} /> : <span>{t('common.generate')}</span>}\n                      </button>\n                      <button\n                        onClick={() => handleStartEdit(shot.id, 'imagePrompt', shot.imagePrompt || '')}\n                        className=\"text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-text-primary)] p-1\"\n                        title={t('prompts.imagePrompt')}\n                      >\n                        <AppIcon name=\"edit\" className=\"w-3.5 h-3.5\" />\n                      </button>\n                    </div>\n                  </td>\n                </tr>\n              )\n            })}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/PromptsStageLayout.tsx",
    "content": "'use client'\n\nimport ImagePreviewModal from '@/components/ui/ImagePreviewModal'\nimport PromptListPanel from './PromptListPanel'\nimport PromptEditorPanel from './PromptEditorPanel'\nimport { usePromptStageActions, type PromptsStageShellProps } from './hooks/usePromptStageActions'\n\nexport type { PromptsStageShellProps }\n\nexport default function PromptsStageLayout(props: PromptsStageShellProps) {\n  const runtime = usePromptStageActions(props)\n\n  return (\n    <div className=\"space-y-6\">\n      {runtime.previewImage && (\n        <ImagePreviewModal\n          imageUrl={runtime.previewImage}\n          onClose={() => runtime.setPreviewImage(null)}\n        />\n      )}\n\n      <PromptListPanel runtime={runtime} />\n      <PromptEditorPanel runtime={runtime} />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/PromptsStageShell.tsx",
    "content": "'use client'\n\nimport PromptsStageLayout, { type PromptsStageShellProps } from './PromptsStageLayout'\n\nexport type { PromptsStageShellProps }\n\nexport default function PromptsStageShell(props: PromptsStageShellProps) {\n  return <PromptsStageLayout {...props} />\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/hooks/usePromptStageActions.tsx",
    "content": "'use client'\n\nexport {\n  usePromptStageActions,\n  getErrorMessage,\n  parseImagePrompt,\n  type PromptsStageShellProps,\n  type LocationAssetWithImages,\n  type PromptStageRuntime,\n} from '../runtime/promptStageRuntimeCore'\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/runtime/hooks/usePromptAiModifyFlow.ts",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { shouldShowError } from '@/lib/error-utils'\nimport { getErrorMessage } from '../promptStageRuntime.utils'\nimport type { PromptAssetReference, PromptShotEditState } from '../promptStageRuntime.types'\n\ninterface ModifyShotPromptResult {\n  modifiedImagePrompt: string\n}\n\nexport interface PromptAiModifier {\n  mutateAsync: (payload: {\n    currentPrompt: string\n    currentVideoPrompt: string\n    modifyInstruction: string\n    referencedAssets: PromptAssetReference[]\n  }) => Promise<ModifyShotPromptResult>\n}\n\ninterface UsePromptAiModifyFlowParams {\n  editingPrompt: { shotId: string; field: 'imagePrompt' } | null\n  shotEditStates: Record<string, PromptShotEditState>\n  onUpdatePrompt: (shotId: string, field: 'imagePrompt', value: string) => Promise<void>\n  onGenerateImage: (shotId: string, extraReferenceAssetIds?: string[]) => Promise<void> | void\n  aiModifyShotPrompt: PromptAiModifier\n  setShotExtraAssets: React.Dispatch<React.SetStateAction<Record<string, string[]>>>\n  setEditingPrompt: React.Dispatch<React.SetStateAction<{ shotId: string; field: 'imagePrompt' } | null>>\n  setShotEditStates: React.Dispatch<React.SetStateAction<Record<string, PromptShotEditState>>>\n  t: (key: string, values?: Record<string, string | number>) => string\n}\n\nexport function usePromptAiModifyFlow({\n  editingPrompt,\n  shotEditStates,\n  onUpdatePrompt,\n  onGenerateImage,\n  aiModifyShotPrompt,\n  setShotExtraAssets,\n  setEditingPrompt,\n  setShotEditStates,\n  t,\n}: UsePromptAiModifyFlowParams) {\n  const [aiModifyingShots, setAiModifyingShots] = useState<Set<string>>(new Set())\n\n  const handleAiModify = async () => {\n    if (!editingPrompt) return\n    const shotId = editingPrompt.shotId\n    const currentState = shotEditStates[shotId]\n    if (!currentState || !currentState.aiModifyInstruction.trim()) {\n      alert(t('prompts.enterInstruction'))\n      return\n    }\n\n    const snapshotEditValue = currentState.editValue\n    const snapshotAiInstruction = currentState.aiModifyInstruction\n    const snapshotSelectedAssets = currentState.selectedAssets\n\n    try {\n      setAiModifyingShots((previous) => new Set(previous).add(shotId))\n      const data = await aiModifyShotPrompt.mutateAsync({\n        currentPrompt: snapshotEditValue,\n        currentVideoPrompt: '',\n        modifyInstruction: snapshotAiInstruction,\n        referencedAssets: snapshotSelectedAssets,\n      })\n      await onUpdatePrompt(shotId, 'imagePrompt', data.modifiedImagePrompt)\n\n      const assetIds = snapshotSelectedAssets.map((asset) => asset.id)\n      if (assetIds.length > 0) {\n        setShotExtraAssets((previous) => ({\n          ...previous,\n          [shotId]: assetIds,\n        }))\n      }\n\n      setEditingPrompt((previous) => (previous?.shotId === shotId ? null : previous))\n      setShotEditStates((previous) => {\n        const next = { ...previous }\n        delete next[shotId]\n        return next\n      })\n\n      await onGenerateImage(shotId, assetIds.length > 0 ? assetIds : undefined)\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(t('prompts.modifyFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n      }\n    } finally {\n      setAiModifyingShots((previous) => {\n        const next = new Set(previous)\n        next.delete(shotId)\n        return next\n      })\n    }\n  }\n\n  return {\n    aiModifyingShots,\n    handleAiModify,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/runtime/hooks/usePromptAppendFlow.ts",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { shouldShowError } from '@/lib/error-utils'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { getErrorMessage } from '../promptStageRuntime.utils'\n\ninterface UsePromptAppendFlowParams {\n  onAppendContent?: (content: string) => Promise<void>\n  t: (key: string, values?: Record<string, string | number>) => string\n}\n\nexport function usePromptAppendFlow({\n  onAppendContent,\n  t,\n}: UsePromptAppendFlowParams) {\n  const [appendContent, setAppendContent] = useState('')\n  const [isAppending, setIsAppending] = useState(false)\n\n  const appendTaskRunningState = isAppending\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'text',\n      hasOutput: false,\n    })\n    : null\n\n  const handleAppendSubmit = async () => {\n    if (!onAppendContent) return\n    if (!appendContent.trim()) {\n      alert(t('prompts.enterContinuation'))\n      return\n    }\n\n    setIsAppending(true)\n    try {\n      await onAppendContent(appendContent.trim())\n      setAppendContent('')\n      alert(t('prompts.appendSuccess'))\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(t('prompts.appendFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n      }\n    } finally {\n      setIsAppending(false)\n    }\n  }\n\n  return {\n    appendContent,\n    setAppendContent,\n    isAppending,\n    appendTaskRunningState,\n    handleAppendSubmit,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/runtime/hooks/usePromptAssetMention.ts",
    "content": "'use client'\n\nimport type { RefObject } from 'react'\nimport type {\n  PromptAssetReference,\n  PromptEditingTarget,\n  PromptShotEditState,\n} from '../promptStageRuntime.types'\n\ninterface UsePromptAssetMentionParams {\n  editingPrompt: PromptEditingTarget | null\n  shotEditStates: Record<string, PromptShotEditState>\n  setShotEditStates: React.Dispatch<React.SetStateAction<Record<string, PromptShotEditState>>>\n  textareaRef: RefObject<HTMLTextAreaElement | null>\n}\n\nexport function usePromptAssetMention({\n  editingPrompt,\n  shotEditStates,\n  setShotEditStates,\n  textareaRef,\n}: UsePromptAssetMentionParams) {\n  const handleModifyInstructionChange = (value: string) => {\n    if (!editingPrompt) return\n\n    const shotId = editingPrompt.shotId\n    const currentState = shotEditStates[shotId]\n    if (!currentState) return\n\n    const lastAtIndex = value.lastIndexOf('@')\n    const shouldShowPicker = lastAtIndex !== -1 && lastAtIndex === value.length - 1\n    const updatedAssets = currentState.selectedAssets.filter((asset) => value.includes(`@${asset.name}`))\n\n    setShotEditStates((previous) => ({\n      ...previous,\n      [shotId]: {\n        ...currentState,\n        aiModifyInstruction: value,\n        showAssetPicker: shouldShowPicker,\n        selectedAssets: updatedAssets,\n      },\n    }))\n  }\n\n  const handleSelectAsset = (asset: PromptAssetReference) => {\n    if (!editingPrompt) return\n\n    const shotId = editingPrompt.shotId\n    const currentState = shotEditStates[shotId]\n    if (!currentState) return\n\n    const alreadySelected = currentState.selectedAssets.find((item) => item.id === asset.id)\n    const lastAtIndex = currentState.aiModifyInstruction.lastIndexOf('@')\n    const nextInstruction = lastAtIndex === -1\n      ? currentState.aiModifyInstruction\n      : `${currentState.aiModifyInstruction.substring(0, lastAtIndex)}@${asset.name}`\n\n    setShotEditStates((previous) => ({\n      ...previous,\n      [shotId]: {\n        ...currentState,\n        aiModifyInstruction: nextInstruction,\n        selectedAssets: alreadySelected ? currentState.selectedAssets : [...currentState.selectedAssets, asset],\n        showAssetPicker: false,\n      },\n    }))\n\n    if (textareaRef.current) {\n      textareaRef.current.focus()\n    }\n  }\n\n  const handleRemoveSelectedAsset = (index: number, assetName: string) => {\n    if (!editingPrompt) return\n\n    const currentState = shotEditStates[editingPrompt.shotId]\n    if (!currentState) return\n\n    const assetMention = `@${assetName}`\n    const mentionPattern = new RegExp(assetMention.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'g')\n    const nextInstruction = currentState.aiModifyInstruction\n      .replace(mentionPattern, '')\n      .replace(/\\s+/g, ' ')\n      .trim()\n\n    setShotEditStates((previous) => ({\n      ...previous,\n      [editingPrompt.shotId]: {\n        ...currentState,\n        selectedAssets: currentState.selectedAssets.filter((_, itemIndex) => itemIndex !== index),\n        aiModifyInstruction: nextInstruction,\n      },\n    }))\n  }\n\n  return {\n    handleModifyInstructionChange,\n    handleSelectAsset,\n    handleRemoveSelectedAsset,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/runtime/hooks/usePromptDraftByShot.ts",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport type {\n  PromptEditingTarget,\n  PromptShotEditState,\n} from '../promptStageRuntime.types'\n\nexport function usePromptDraftByShot() {\n  const [editingPrompt, setEditingPrompt] = useState<PromptEditingTarget | null>(null)\n  const [shotEditStates, setShotEditStates] = useState<Record<string, PromptShotEditState>>({})\n\n  const currentEditState = editingPrompt ? shotEditStates[editingPrompt.shotId] : null\n  const editValue = currentEditState?.editValue || ''\n  const aiModifyInstruction = currentEditState?.aiModifyInstruction || ''\n  const selectedAssets = currentEditState?.selectedAssets || []\n  const showAssetPicker = currentEditState?.showAssetPicker || false\n\n  const removeShotEditState = (shotId: string) => {\n    setShotEditStates((previous) => {\n      const next = { ...previous }\n      delete next[shotId]\n      return next\n    })\n  }\n\n  const clearCurrentEdit = () => {\n    if (editingPrompt) {\n      removeShotEditState(editingPrompt.shotId)\n    }\n    setEditingPrompt(null)\n  }\n\n  const handleStartEdit = (shotId: string, field: 'imagePrompt', currentValue: string) => {\n    setEditingPrompt({ shotId, field })\n    setShotEditStates((previous) => ({\n      ...previous,\n      [shotId]: {\n        editValue: currentValue,\n        aiModifyInstruction: previous[shotId]?.aiModifyInstruction || '',\n        selectedAssets: previous[shotId]?.selectedAssets || [],\n        showAssetPicker: false,\n      },\n    }))\n  }\n\n  const handleEditValueChange = (value: string) => {\n    if (!editingPrompt) return\n\n    setShotEditStates((previous) => ({\n      ...previous,\n      [editingPrompt.shotId]: {\n        ...previous[editingPrompt.shotId],\n        editValue: value,\n      },\n    }))\n  }\n\n  return {\n    editingPrompt,\n    setEditingPrompt,\n    shotEditStates,\n    setShotEditStates,\n    editValue,\n    aiModifyInstruction,\n    selectedAssets,\n    showAssetPicker,\n    handleStartEdit,\n    handleEditValueChange,\n    removeShotEditState,\n    clearCurrentEdit,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/runtime/hooks/usePromptEditorRuntime.ts",
    "content": "'use client'\n\nimport { useRef, useState } from 'react'\nimport { shouldShowError } from '@/lib/error-utils'\nimport { getErrorMessage } from '../promptStageRuntime.utils'\nimport { usePromptAiModifyFlow } from './usePromptAiModifyFlow'\nimport type { PromptAiModifier } from './usePromptAiModifyFlow'\nimport { usePromptDraftByShot } from './usePromptDraftByShot'\nimport { usePromptAssetMention } from './usePromptAssetMention'\n\ninterface UsePromptEditorRuntimeParams {\n  onUpdatePrompt: (shotId: string, field: 'imagePrompt', value: string) => Promise<void>\n  onGenerateImage: (shotId: string, extraReferenceAssetIds?: string[]) => Promise<void> | void\n  t: (key: string, values?: Record<string, string | number>) => string\n  aiModifyShotPrompt: PromptAiModifier\n}\n\nexport function usePromptEditorRuntime({\n  onUpdatePrompt,\n  onGenerateImage,\n  t,\n  aiModifyShotPrompt,\n}: UsePromptEditorRuntimeParams) {\n  const draftByShot = usePromptDraftByShot()\n  const [shotExtraAssets, setShotExtraAssets] = useState<Record<string, string[]>>({})\n  const textareaRef = useRef<HTMLTextAreaElement>(null)\n\n  const { aiModifyingShots, handleAiModify } = usePromptAiModifyFlow({\n    editingPrompt: draftByShot.editingPrompt,\n    shotEditStates: draftByShot.shotEditStates,\n    onUpdatePrompt,\n    onGenerateImage,\n    aiModifyShotPrompt,\n    setShotExtraAssets,\n    setEditingPrompt: draftByShot.setEditingPrompt,\n    setShotEditStates: draftByShot.setShotEditStates,\n    t,\n  })\n  const {\n    handleModifyInstructionChange,\n    handleSelectAsset,\n    handleRemoveSelectedAsset,\n  } = usePromptAssetMention({\n    editingPrompt: draftByShot.editingPrompt,\n    shotEditStates: draftByShot.shotEditStates,\n    setShotEditStates: draftByShot.setShotEditStates,\n    textareaRef,\n  })\n\n  const handleSaveEdit = async () => {\n    const currentEditingPrompt = draftByShot.editingPrompt\n    if (!currentEditingPrompt) return\n    const currentShotId = currentEditingPrompt.shotId\n    const currentState = draftByShot.shotEditStates[currentShotId]\n    if (!currentState) return\n\n    try {\n      await onUpdatePrompt(currentEditingPrompt.shotId, currentEditingPrompt.field, currentState.editValue)\n      if (currentState.selectedAssets.length > 0) {\n        setShotExtraAssets((previous) => ({\n          ...previous,\n          [currentEditingPrompt.shotId]: currentState.selectedAssets.map((asset) => asset.id),\n        }))\n      }\n\n      draftByShot.setEditingPrompt((previous) => (previous?.shotId === currentShotId ? null : previous))\n      draftByShot.removeShotEditState(currentShotId)\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(t('prompts.updateFailed', { error: getErrorMessage(error, t('common.unknownError')) }))\n      }\n    }\n  }\n\n  const handleCancelEdit = () => {\n    draftByShot.clearCurrentEdit()\n  }\n\n  return {\n    editingPrompt: draftByShot.editingPrompt,\n    editValue: draftByShot.editValue,\n    aiModifyInstruction: draftByShot.aiModifyInstruction,\n    selectedAssets: draftByShot.selectedAssets,\n    showAssetPicker: draftByShot.showAssetPicker,\n    aiModifyingShots,\n    textareaRef,\n    shotExtraAssets,\n    handleStartEdit: draftByShot.handleStartEdit,\n    handleSaveEdit,\n    handleCancelEdit,\n    handleModifyInstructionChange,\n    handleSelectAsset,\n    handleAiModify,\n    handleEditValueChange: draftByShot.handleEditValueChange,\n    handleRemoveSelectedAsset,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/runtime/promptStageRuntime.types.ts",
    "content": "'use client'\n\nimport type { AssetLibraryCharacter, AssetLibraryLocation, NovelPromotionShot } from '@/types/project'\n\nexport interface PromptsStageShellProps {\n  projectId: string\n  shots: NovelPromotionShot[]\n  viewMode: 'card' | 'table'\n  onViewModeChange: (mode: 'card' | 'table') => void\n  onGenerateImage: (shotId: string, extraReferenceAssetIds?: string[]) => void\n  onGenerateAllImages: () => void\n  isBatchSubmitting?: boolean\n  onBack?: () => void\n  onNext: () => void\n  onUpdatePrompt: (shotId: string, field: 'imagePrompt', value: string) => Promise<void>\n  artStyle: string\n  assetLibraryCharacters: AssetLibraryCharacter[]\n  assetLibraryLocations: AssetLibraryLocation[]\n  onAppendContent?: (content: string) => Promise<void>\n}\n\nexport type LocationAssetWithImages = AssetLibraryLocation & {\n  selectedImageId?: string | null\n  images?: Array<{\n    id: string\n    isSelected?: boolean\n    imageUrl?: string | null\n    description?: string | null\n  }>\n}\n\nexport interface PromptAssetReference {\n  id: string\n  name: string\n  description: string\n  type: 'character' | 'location'\n}\n\nexport interface PromptShotEditState {\n  editValue: string\n  aiModifyInstruction: string\n  selectedAssets: PromptAssetReference[]\n  showAssetPicker: boolean\n}\n\nexport interface PromptEditingTarget {\n  shotId: string\n  field: 'imagePrompt'\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/runtime/promptStageRuntime.utils.ts",
    "content": "'use client'\n\nimport { extractErrorMessage } from '@/lib/errors/extract'\n\nexport function getErrorMessage(error: unknown, fallback: string): string {\n  return extractErrorMessage(error, fallback)\n}\n\nexport function parseImagePrompt(imagePrompt: string | null) {\n  if (!imagePrompt) return { content: '' }\n  return { content: imagePrompt }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/prompts-stage/runtime/promptStageRuntimeCore.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { useCallback, useMemo, useState } from 'react'\nimport { ART_STYLES } from '@/lib/constants'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { useAiModifyProjectShotPrompt } from '@/lib/query/hooks'\nimport type { NovelPromotionShot } from '@/types/project'\nimport type { PromptsStageShellProps } from './promptStageRuntime.types'\nimport { usePromptEditorRuntime } from './hooks/usePromptEditorRuntime'\nimport { usePromptAppendFlow } from './hooks/usePromptAppendFlow'\n\nexport type {\n  PromptsStageShellProps,\n  LocationAssetWithImages,\n} from './promptStageRuntime.types'\nexport {\n  getErrorMessage,\n  parseImagePrompt,\n} from './promptStageRuntime.utils'\n\nexport function usePromptStageActions({\n  projectId,\n  shots,\n  viewMode,\n  onViewModeChange,\n  onGenerateImage,\n  onGenerateAllImages,\n  isBatchSubmitting = false,\n  onBack,\n  onNext,\n  onUpdatePrompt,\n  artStyle,\n  assetLibraryCharacters,\n  assetLibraryLocations,\n  onAppendContent,\n}: PromptsStageShellProps) {\n  const t = useTranslations('storyboard')\n  const aiModifyShotPrompt = useAiModifyProjectShotPrompt(projectId)\n\n  const isShotTaskRunning = useCallback((shot: NovelPromotionShot) => {\n    return Boolean((shot as NovelPromotionShot & { imageTaskRunning?: boolean }).imageTaskRunning)\n  }, [])\n\n  const styleLabel = ART_STYLES.find((style) => style.value === artStyle)?.label || t('prompts.customStyle')\n  const runningCount = shots.filter((shot) => isShotTaskRunning(shot)).length\n  const [previewImage, setPreviewImage] = useState<string | null>(null)\n\n  const editorRuntime = usePromptEditorRuntime({\n    onUpdatePrompt,\n    onGenerateImage,\n    aiModifyShotPrompt,\n    t: (key, values) => t(key as never, values as never),\n  })\n\n  const appendFlow = usePromptAppendFlow({\n    onAppendContent,\n    t: (key, values) => t(key as never, values as never),\n  })\n\n  const isAnyTaskRunning = runningCount > 0 || isBatchSubmitting\n\n  const getGenerateButtonToneClass = (shot: NovelPromotionShot) => {\n    if (shot.imageUrl) return 'glass-btn-tone-success'\n    if (isShotTaskRunning(shot)) return 'glass-btn-soft'\n    return 'glass-btn-primary'\n  }\n\n  const getShotRunningState = useCallback((shot: NovelPromotionShot) => {\n    if (!isShotTaskRunning(shot)) return null\n    return resolveTaskPresentationState({\n      phase: 'processing',\n      intent: shot.imageUrl ? 'regenerate' : 'generate',\n      resource: 'image',\n      hasOutput: !!shot.imageUrl,\n    })\n  }, [isShotTaskRunning])\n\n  const batchTaskRunningState = useMemo(() => {\n    if (!isAnyTaskRunning) return null\n    return resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'image',\n      hasOutput: true,\n    })\n  }, [isAnyTaskRunning])\n\n  return {\n    shots,\n    viewMode,\n    onViewModeChange,\n    onGenerateImage,\n    onGenerateAllImages,\n    isBatchSubmitting,\n    onBack,\n    onNext,\n    onAppendContent,\n    assetLibraryCharacters,\n    assetLibraryLocations,\n    styleLabel,\n    runningCount,\n    isAnyTaskRunning,\n    previewImage,\n    setPreviewImage,\n\n    editingPrompt: editorRuntime.editingPrompt,\n    editValue: editorRuntime.editValue,\n    aiModifyInstruction: editorRuntime.aiModifyInstruction,\n    selectedAssets: editorRuntime.selectedAssets,\n    showAssetPicker: editorRuntime.showAssetPicker,\n    aiModifyingShots: editorRuntime.aiModifyingShots,\n    textareaRef: editorRuntime.textareaRef,\n    shotExtraAssets: editorRuntime.shotExtraAssets,\n\n    appendContent: appendFlow.appendContent,\n    isAppending: appendFlow.isAppending,\n    appendTaskRunningState: appendFlow.appendTaskRunningState,\n\n    getGenerateButtonToneClass,\n    getShotRunningState,\n    batchTaskRunningState,\n    isShotTaskRunning,\n\n    handleStartEdit: editorRuntime.handleStartEdit,\n    handleSaveEdit: editorRuntime.handleSaveEdit,\n    handleCancelEdit: editorRuntime.handleCancelEdit,\n    handleModifyInstructionChange: editorRuntime.handleModifyInstructionChange,\n    handleSelectAsset: editorRuntime.handleSelectAsset,\n    handleAiModify: editorRuntime.handleAiModify,\n    handleEditValueChange: editorRuntime.handleEditValueChange,\n    handleRemoveSelectedAsset: editorRuntime.handleRemoveSelectedAsset,\n\n    setAppendContent: appendFlow.setAppendContent,\n    handleAppendSubmit: appendFlow.handleAppendSubmit,\n  }\n}\n\nexport type PromptStageRuntime = ReturnType<typeof usePromptStageActions>\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel.tsx",
    "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { Character, Location, CharacterAppearance } from '@/types/project'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { SpotlightCharCard, SpotlightLocationCard, getSelectedLocationImage } from './SpotlightCards'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface Clip {\n  id: string\n  location?: string | null\n}\n\ninterface ScriptViewAssetsPanelProps {\n  clips: Clip[]\n  assetViewMode: 'all' | string\n  setAssetViewMode: (mode: 'all' | string) => void\n  setSelectedClipId: (clipId: string) => void\n  characters: Character[]\n  locations: Location[]\n  activeCharIds: string[]\n  activeLocationIds: string[]\n  selectedAppearanceKeys: Set<string>\n  onUpdateClipAssets: (\n    type: 'character' | 'location',\n    action: 'add' | 'remove',\n    id: string,\n    optionLabel?: string,\n  ) => Promise<void>\n  onOpenAssetLibrary?: () => void\n  assetsLoading: boolean\n  assetsLoadingState: TaskPresentationState | null\n  allAssetsHaveImages: boolean\n  globalCharIds: string[]\n  globalLocationIds: string[]\n  missingAssetsCount: number\n  onGenerateStoryboard?: () => void\n  isSubmittingStoryboardBuild: boolean\n  getSelectedAppearances: (char: Character) => CharacterAppearance[]\n  tScript: (key: string, values?: Record<string, unknown>) => string\n  tAssets: (key: string, values?: Record<string, unknown>) => string\n  tNP: (key: string, values?: Record<string, unknown>) => string\n  tCommon: (key: string, values?: Record<string, unknown>) => string\n}\n\nfunction setsEqual<T>(left: Set<T>, right: Set<T>): boolean {\n  if (left.size !== right.size) return false\n  for (const item of left) {\n    if (!right.has(item)) return false\n  }\n  return true\n}\n\nfunction parseAppearanceKey(key: string): { characterId: string; appearanceName: string } | null {\n  const separatorIndex = key.indexOf('::')\n  if (separatorIndex <= 0) return null\n  const characterId = key.slice(0, separatorIndex)\n  const appearanceName = key.slice(separatorIndex + 2)\n  if (!characterId || !appearanceName) return null\n  return { characterId, appearanceName }\n}\n\nfunction parseLocationNames(raw: string | null | undefined): string[] {\n  if (!raw) return []\n  try {\n    const parsed = JSON.parse(raw)\n    if (Array.isArray(parsed)) {\n      return parsed\n        .map((item) => (typeof item === 'string' ? item.trim() : ''))\n        .filter((item) => !!item)\n    }\n  } catch {\n    // fallback to comma-separated format\n  }\n  return raw\n    .split(',')\n    .map((item) => item.trim())\n    .filter((item) => !!item)\n}\n\nfunction fuzzyMatchLocationName(clipLocName: string, libraryLocName: string): boolean {\n  const clipLower = clipLocName.toLowerCase().trim()\n  const libraryLower = libraryLocName.toLowerCase().trim()\n  if (!clipLower || !libraryLower) return false\n  if (clipLower === libraryLower) return true\n  if (clipLower.includes(libraryLower)) return true\n  if (libraryLower.includes(clipLower)) return true\n  return false\n}\n\nfunction readTrimmedLabel(value: string | undefined, fallback: string): string {\n  const trimmed = typeof value === 'string' ? value.trim() : ''\n  return trimmed || fallback\n}\n\nfunction getAppearancePreviewUrl(appearance: CharacterAppearance): string | null {\n  if (appearance.imageUrl) return appearance.imageUrl\n\n  const selectedIndex = appearance.selectedIndex\n  if (\n    typeof selectedIndex === 'number' &&\n    selectedIndex >= 0 &&\n    selectedIndex < appearance.imageUrls.length\n  ) {\n    const selectedUrl = appearance.imageUrls[selectedIndex]\n    if (selectedUrl) return selectedUrl\n  }\n\n  const firstAvailable = appearance.imageUrls.find((url) => !!url)\n  return firstAvailable || null\n}\n\nexport default function ScriptViewAssetsPanel({\n  clips,\n  assetViewMode,\n  setAssetViewMode,\n  setSelectedClipId,\n  characters,\n  locations,\n  activeCharIds,\n  activeLocationIds,\n  selectedAppearanceKeys,\n  onUpdateClipAssets,\n  onOpenAssetLibrary,\n  assetsLoading,\n  assetsLoadingState,\n  allAssetsHaveImages,\n  globalCharIds,\n  globalLocationIds,\n  missingAssetsCount,\n  onGenerateStoryboard,\n  isSubmittingStoryboardBuild,\n  getSelectedAppearances,\n  tScript,\n  tAssets,\n  tNP,\n  tCommon,\n}: ScriptViewAssetsPanelProps) {\n  const [showAddChar, setShowAddChar] = useState(false)\n  const [showAddLoc, setShowAddLoc] = useState(false)\n  const [mounted, setMounted] = useState(false)\n  const [initialAppearanceKeys, setInitialAppearanceKeys] = useState<Set<string>>(new Set())\n  const [pendingAppearanceKeys, setPendingAppearanceKeys] = useState<Set<string>>(new Set())\n  const [pendingAppearanceLabels, setPendingAppearanceLabels] = useState<Record<string, string>>({})\n  const [pendingLocationIds, setPendingLocationIds] = useState<Set<string>>(new Set())\n  const [pendingLocationLabels, setPendingLocationLabels] = useState<Record<string, string>>({})\n  const [initialLocationLabels, setInitialLocationLabels] = useState<Record<string, string>>({})\n  const [isSavingCharacterSelection, setIsSavingCharacterSelection] = useState(false)\n  const [isSavingLocationSelection, setIsSavingLocationSelection] = useState(false)\n  const hasInitializedCharDraftRef = useRef(false)\n  const hasInitializedLocDraftRef = useRef(false)\n  const charEditorTriggerRef = useRef<HTMLButtonElement | null>(null)\n  const charEditorPopoverRef = useRef<HTMLDivElement | null>(null)\n  const locEditorTriggerRef = useRef<HTMLButtonElement | null>(null)\n  const locEditorPopoverRef = useRef<HTMLDivElement | null>(null)\n\n  useEffect(() => {\n    setMounted(true)\n  }, [])\n\n  useEffect(() => {\n    if (!showAddChar) {\n      hasInitializedCharDraftRef.current = false\n      return\n    }\n    if (hasInitializedCharDraftRef.current) return\n    const nextKeys = new Set(selectedAppearanceKeys)\n    const nextLabels: Record<string, string> = {}\n    nextKeys.forEach((key) => {\n      const parsed = parseAppearanceKey(key)\n      if (parsed) {\n        nextLabels[key] = parsed.appearanceName\n      }\n    })\n\n    // 用当前右侧面板实际展示的“已选角色/形象”做兜底，确保编辑弹层能正确显示选中态\n    activeCharIds.forEach((characterId) => {\n      const character = characters.find((item) => item.id === characterId)\n      if (!character) return\n      const appearances = getSelectedAppearances(character)\n      appearances.forEach((appearance) => {\n        const appearanceName = appearance.changeReason || tAssets('character.primary')\n        const appearanceKey = `${character.id}::${appearanceName}`\n        nextKeys.add(appearanceKey)\n        if (!nextLabels[appearanceKey]) {\n          nextLabels[appearanceKey] = appearanceName\n        }\n      })\n    })\n\n    const baselineKeys = new Set(nextKeys)\n    setInitialAppearanceKeys(baselineKeys)\n    setPendingAppearanceKeys(baselineKeys)\n    setPendingAppearanceLabels(nextLabels)\n    hasInitializedCharDraftRef.current = true\n  }, [activeCharIds, characters, getSelectedAppearances, selectedAppearanceKeys, showAddChar, tAssets])\n\n  useEffect(() => {\n    if (!showAddLoc) {\n      hasInitializedLocDraftRef.current = false\n      return\n    }\n    if (hasInitializedLocDraftRef.current) return\n    const nextIds = new Set(activeLocationIds)\n    const nextLabels: Record<string, string> = {}\n\n    activeLocationIds.forEach((locationId) => {\n      const location = locations.find((item) => item.id === locationId)\n      if (location) nextLabels[locationId] = location.name\n    })\n\n    if (assetViewMode !== 'all') {\n      const currentClip = clips.find((clip) => clip.id === assetViewMode)\n      const rawLocationNames = parseLocationNames(currentClip?.location)\n      activeLocationIds.forEach((locationId) => {\n        const location = locations.find((item) => item.id === locationId)\n        if (!location) return\n        const matchedRawName = rawLocationNames.find((name) => fuzzyMatchLocationName(name, location.name))\n        if (matchedRawName) {\n          nextLabels[locationId] = matchedRawName\n        }\n      })\n    }\n\n    setPendingLocationIds(nextIds)\n    setPendingLocationLabels(nextLabels)\n    setInitialLocationLabels(nextLabels)\n    hasInitializedLocDraftRef.current = true\n  }, [activeLocationIds, assetViewMode, clips, locations, showAddLoc])\n\n  useEffect(() => {\n    if (!showAddChar && !showAddLoc) return\n\n    const handlePointerDownOutside = (event: MouseEvent) => {\n      const target = event.target as Node\n\n      if (showAddChar) {\n        const isInCharPopover = charEditorPopoverRef.current?.contains(target)\n        const isInCharTrigger = charEditorTriggerRef.current?.contains(target)\n        if (!isInCharPopover && !isInCharTrigger) {\n          setShowAddChar(false)\n        }\n      }\n\n      if (showAddLoc) {\n        const isInLocPopover = locEditorPopoverRef.current?.contains(target)\n        const isInLocTrigger = locEditorTriggerRef.current?.contains(target)\n        if (!isInLocPopover && !isInLocTrigger) {\n          setShowAddLoc(false)\n        }\n      }\n    }\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') {\n        if (showAddChar) setShowAddChar(false)\n        if (showAddLoc) setShowAddLoc(false)\n      }\n    }\n\n    document.addEventListener('mousedown', handlePointerDownOutside, true)\n    document.addEventListener('keydown', handleKeyDown)\n    return () => {\n      document.removeEventListener('mousedown', handlePointerDownOutside, true)\n      document.removeEventListener('keydown', handleKeyDown)\n    }\n  }, [showAddChar, showAddLoc])\n\n  const isAllClipsMode = assetViewMode === 'all'\n\n  const hasCharacterLabelChanges = !isAllClipsMode && Array.from(pendingAppearanceKeys).some((key) => {\n    const parsed = parseAppearanceKey(key)\n    if (!parsed) return false\n    const nextLabel = readTrimmedLabel(pendingAppearanceLabels[key], parsed.appearanceName)\n    return nextLabel !== parsed.appearanceName\n  })\n\n  const hasLocationLabelChanges = !isAllClipsMode && Array.from(pendingLocationIds).some((locationId) => {\n    const location = locations.find((item) => item.id === locationId)\n    if (!location) return false\n    const baseLabel = initialLocationLabels[locationId] || location.name\n    const nextLabel = readTrimmedLabel(pendingLocationLabels[locationId], location.name)\n    return nextLabel !== baseLabel\n  })\n\n  const hasCharacterSelectionChanges = !setsEqual(initialAppearanceKeys, pendingAppearanceKeys) || hasCharacterLabelChanges\n  const hasLocationSelectionChanges = !setsEqual(new Set(activeLocationIds), pendingLocationIds) || hasLocationLabelChanges\n\n  const handleConfirmCharacterSelection = async () => {\n    if (isSavingCharacterSelection) return\n    setIsSavingCharacterSelection(true)\n    try {\n      const currentKeys = new Set(initialAppearanceKeys)\n      const desiredKeys = new Set<string>()\n      const desiredItems: Array<{ characterId: string; appearanceName: string; targetKey: string }> = []\n\n      pendingAppearanceKeys.forEach((rawKey) => {\n        const parsed = parseAppearanceKey(rawKey)\n        if (!parsed) return\n        const appearanceName = isAllClipsMode\n          ? parsed.appearanceName\n          : readTrimmedLabel(pendingAppearanceLabels[rawKey], parsed.appearanceName)\n        const targetKey = `${parsed.characterId}::${appearanceName}`\n        if (desiredKeys.has(targetKey)) return\n        desiredKeys.add(targetKey)\n        desiredItems.push({\n          characterId: parsed.characterId,\n          appearanceName,\n          targetKey,\n        })\n      })\n\n      for (const key of currentKeys) {\n        if (desiredKeys.has(key)) continue\n        const parsed = parseAppearanceKey(key)\n        if (!parsed) continue\n        await onUpdateClipAssets('character', 'remove', parsed.characterId, parsed.appearanceName)\n      }\n\n      for (const item of desiredItems) {\n        if (currentKeys.has(item.targetKey)) continue\n        await onUpdateClipAssets('character', 'add', item.characterId, item.appearanceName)\n      }\n\n      setShowAddChar(false)\n    } finally {\n      setIsSavingCharacterSelection(false)\n    }\n  }\n\n  const handleConfirmLocationSelection = async () => {\n    if (isSavingLocationSelection) return\n    setIsSavingLocationSelection(true)\n    try {\n      const currentIds = new Set(activeLocationIds)\n\n      for (const locationId of currentIds) {\n        if (pendingLocationIds.has(locationId)) continue\n        await onUpdateClipAssets('location', 'remove', locationId)\n      }\n\n      for (const locationId of pendingLocationIds) {\n        const location = locations.find((item) => item.id === locationId)\n        if (!location) continue\n\n        const nextLabel = isAllClipsMode\n          ? location.name\n          : readTrimmedLabel(pendingLocationLabels[locationId], location.name)\n        const baseLabel = initialLocationLabels[locationId] || location.name\n        const changedLabel = currentIds.has(locationId) && nextLabel !== baseLabel\n\n        if (changedLabel) {\n          await onUpdateClipAssets('location', 'remove', locationId)\n          await onUpdateClipAssets('location', 'add', locationId, nextLabel)\n          continue\n        }\n\n        if (!currentIds.has(locationId)) {\n          await onUpdateClipAssets('location', 'add', locationId, nextLabel)\n        }\n      }\n\n      setShowAddLoc(false)\n    } finally {\n      setIsSavingLocationSelection(false)\n    }\n  }\n\n  return (\n    <div className=\"col-span-12 lg:col-span-4 flex flex-col min-h-[300px] lg:h-full gap-4\">\n      <div className=\"flex flex-col gap-2 px-2\">\n        <h2 className=\"text-xl font-bold text-[var(--glass-text-primary)] flex items-center gap-2\">\n          <span className=\"w-1.5 h-6 bg-[var(--glass-accent-from)] rounded-full\" /> {tScript('inSceneAssets')}\n        </h2>\n        <div className=\"flex items-center gap-2 overflow-x-auto pb-1 custom-scrollbar\">\n          <button\n            onClick={() => setAssetViewMode('all')}\n            className={`glass-btn-base px-3 py-1.5 text-xs font-medium rounded-full whitespace-nowrap transition-all ${assetViewMode === 'all'\n              ? 'glass-btn-primary'\n              : 'glass-btn-secondary text-[var(--glass-text-secondary)]'\n              }`}\n          >\n            {tScript('assetView.allClips')}\n          </button>\n          {clips.map((clip, idx) => (\n            <button\n              key={clip.id}\n              onClick={() => {\n                setAssetViewMode(clip.id)\n                setSelectedClipId(clip.id)\n              }}\n              className={`glass-btn-base px-3 py-1.5 text-xs font-medium rounded-full whitespace-nowrap transition-all ${assetViewMode === clip.id\n                ? 'glass-btn-primary'\n                : 'glass-btn-secondary text-[var(--glass-text-secondary)]'\n                }`}\n            >\n              {tScript('segment.title', { index: idx + 1 })}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"flex-1 glass-surface-modal overflow-y-auto p-4 custom-scrollbar flex flex-col gap-6\">\n        {assetsLoading && characters.length === 0 && locations.length === 0 && (\n          <div className=\"flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)] animate-pulse\">\n            <TaskStatusInline state={assetsLoadingState} />\n          </div>\n        )}\n\n        <div className=\"relative\">\n          <div className=\"flex justify-between items-center mb-3\">\n            <h3 className=\"text-sm font-bold text-[var(--glass-text-secondary)] flex items-center gap-2\">\n              {tScript('asset.activeCharacters')} ({characters.filter((c) => activeCharIds.includes(c.id)).reduce((sum, char) => sum + getSelectedAppearances(char).length, 0)})\n            </h3>\n            <button\n              ref={charEditorTriggerRef}\n              onClick={() => {\n                setShowAddChar((prev) => !prev)\n                setShowAddLoc(false)\n              }}\n              className=\"inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors\"\n            >\n              <AppIcon name=\"edit\" className=\"h-4 w-4\" />\n            </button>\n          </div>\n\n          {showAddChar && mounted && createPortal(\n            <div ref={charEditorPopoverRef} className=\"fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl\">\n              <div className=\"shrink-0 text-xs text-[var(--glass-text-tertiary)]\">{tCommon('edit')} · {tScript('asset.activeCharacters')}</div>\n              <div className=\"mt-3 flex-1 min-h-0 space-y-4 overflow-y-auto pr-1 custom-scrollbar\">\n                {isAllClipsMode && (\n                  <div className=\"rounded-lg border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]/40 p-2 text-[11px] text-[var(--glass-text-tertiary)]\">\n                    当前为“全部片段”视图，文案要求仅在单片段视图可编辑\n                  </div>\n                )}\n                {characters.map((c) => {\n                  const appearances = c.appearances || []\n                  const sortedAppearances = [...appearances].sort((a, b) => a.appearanceIndex - b.appearanceIndex)\n                  return (\n                    <div key={c.id} className=\"space-y-2\">\n                      <div className=\"text-xs font-semibold text-[var(--glass-text-primary)]\">{c.name}</div>\n                      <div className=\"grid grid-cols-3 gap-2\">\n                        {sortedAppearances.map((appearance) => {\n                          const currentAppearanceName = appearance.changeReason || tAssets('character.primary')\n                          const appearanceKey = `${c.id}::${currentAppearanceName}`\n                          const isThisAppearanceSelected = pendingAppearanceKeys.has(appearanceKey)\n                          const previewUrl = getAppearancePreviewUrl(appearance)\n                          return (\n                            <div key={`${c.id}-${appearance.appearanceIndex}`} className=\"space-y-1\">\n                              <button\n                                onClick={() => {\n                                  setPendingAppearanceKeys((prev) => {\n                                    const next = new Set(prev)\n                                    if (isThisAppearanceSelected) {\n                                      next.delete(appearanceKey)\n                                    } else {\n                                      next.add(appearanceKey)\n                                    }\n                                    return next\n                                  })\n                                  setPendingAppearanceLabels((prev) => {\n                                    const next = { ...prev }\n                                    if (isThisAppearanceSelected) {\n                                      delete next[appearanceKey]\n                                    } else if (!next[appearanceKey]) {\n                                      next[appearanceKey] = currentAppearanceName\n                                    }\n                                    return next\n                                  })\n                                }}\n                                className={`relative w-full rounded-lg overflow-hidden border-2 ${isThisAppearanceSelected ? 'border-[var(--glass-stroke-success)]' : 'border-transparent hover:border-[var(--glass-stroke-focus)]'}`}\n                              >\n                                <div className=\"aspect-square bg-[var(--glass-bg-muted)]\">\n                                  {previewUrl ? (\n                                    <MediaImageWithLoading\n                                      src={previewUrl}\n                                      alt={`${c.name}-${currentAppearanceName}`}\n                                      containerClassName=\"h-full w-full\"\n                                      className=\"h-full w-full object-cover\"\n                                    />\n                                  ) : null}\n                                </div>\n                                {isThisAppearanceSelected && (\n                                  <span className=\"absolute right-1.5 top-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-[var(--glass-tone-success-fg)] text-white shadow-md\">\n                                    <AppIcon name=\"checkMicro\" className=\"h-3 w-3\" />\n                                  </span>\n                                )}\n                              </button>\n                              {isThisAppearanceSelected && (\n                                <input\n                                  value={pendingAppearanceLabels[appearanceKey] || currentAppearanceName}\n                                  disabled={isAllClipsMode}\n                                  onChange={(event) => {\n                                    const value = event.target.value\n                                    setPendingAppearanceLabels((prev) => ({ ...prev, [appearanceKey]: value }))\n                                  }}\n                                  className=\"w-full rounded border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-2 py-1 text-xs text-[var(--glass-text-secondary)] outline-none focus:border-[var(--glass-stroke-focus)] disabled:cursor-not-allowed disabled:opacity-60\"\n                                />\n                              )}\n                            </div>\n                          )\n                        })}\n                      </div>\n                    </div>\n                  )\n                })}\n              </div>\n              <div className=\"mt-3 flex shrink-0 items-center justify-end gap-2 border-t border-[var(--glass-stroke-base)] pt-3\">\n                <button\n                  onClick={() => setShowAddChar(false)}\n                  disabled={isSavingCharacterSelection}\n                  className=\"glass-btn-base glass-btn-secondary rounded-lg px-3 py-1.5 text-xs text-[var(--glass-text-secondary)]\"\n                >\n                  {tCommon('cancel')}\n                </button>\n                <button\n                  onClick={() => void handleConfirmCharacterSelection()}\n                  disabled={isSavingCharacterSelection || !hasCharacterSelectionChanges}\n                  className=\"glass-btn-base glass-btn-primary rounded-lg px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-50\"\n                >\n                  {tCommon('confirm')}\n                </button>\n              </div>\n            </div>,\n            document.body,\n          )}\n\n          {activeCharIds.length === 0 ? (\n            <div className=\"text-center text-[var(--glass-text-tertiary)] text-sm py-4\">{tScript('screenplay.noCharacter')}</div>\n          ) : (\n            <div className=\"grid grid-cols-3 gap-3\">\n              {characters\n                .filter((c) => activeCharIds.includes(c.id))\n                .flatMap((char) => {\n                  const selectedApps = getSelectedAppearances(char)\n                  if (selectedApps.length === 0) {\n                    return (\n                      <SpotlightCharCard\n                        key={`${char.id}-missing`}\n                        char={char}\n                        appearance={undefined}\n                        isActive={true}\n                        onClick={() => { }}\n                        onOpenAssetLibrary={onOpenAssetLibrary}\n                        onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, tScript('asset.defaultAppearance'))}\n                      />\n                    )\n                  }\n                  return selectedApps.map((appearance) => (\n                    <SpotlightCharCard\n                      key={`${char.id}-${appearance.id}`}\n                      char={char}\n                      appearance={appearance}\n                      isActive={true}\n                      onClick={() => { }}\n                      onOpenAssetLibrary={onOpenAssetLibrary}\n                      onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, appearance.changeReason || tScript('asset.defaultAppearance'))}\n                    />\n                  ))\n                })}\n            </div>\n          )}\n        </div>\n\n        <div className=\"relative\">\n          <div className=\"flex justify-between items-center mb-3\">\n            <h3 className=\"text-sm font-bold text-[var(--glass-text-secondary)]\">{tScript('asset.activeLocations')} ({activeLocationIds.length})</h3>\n            <button\n              ref={locEditorTriggerRef}\n              onClick={() => {\n                setShowAddLoc((prev) => !prev)\n                setShowAddChar(false)\n              }}\n              className=\"inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors\"\n            >\n              <AppIcon name=\"edit\" className=\"h-4 w-4\" />\n            </button>\n          </div>\n\n          {showAddLoc && mounted && createPortal(\n            <div ref={locEditorPopoverRef} className=\"fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl\">\n              <div className=\"shrink-0 text-xs text-[var(--glass-text-tertiary)]\">{tCommon('edit')} · {tScript('asset.activeLocations')}</div>\n              <div className=\"mt-3 flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar\">\n                {isAllClipsMode && (\n                  <div className=\"mb-3 rounded-lg border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]/40 p-2 text-[11px] text-[var(--glass-text-tertiary)]\">\n                    当前为“全部片段”视图，场景文案要求仅在单片段视图可编辑\n                  </div>\n                )}\n                <div className=\"grid grid-cols-2 gap-2\">\n                  {locations.map((location) => {\n                    const isSelected = pendingLocationIds.has(location.id)\n                    const previewImage = getSelectedLocationImage(location)?.imageUrl || null\n                    return (\n                      <div key={location.id} className=\"space-y-1\">\n                        <button\n                          onClick={() => {\n                            setPendingLocationIds((prev) => {\n                              const next = new Set(prev)\n                              if (isSelected) {\n                                next.delete(location.id)\n                              } else {\n                                next.add(location.id)\n                              }\n                              return next\n                            })\n                            setPendingLocationLabels((prev) => {\n                              const next = { ...prev }\n                              if (isSelected) {\n                                delete next[location.id]\n                              } else if (!next[location.id]) {\n                                next[location.id] = location.name\n                              }\n                              return next\n                            })\n                          }}\n                          className={`relative w-full overflow-hidden rounded-lg border-2 text-left transition-colors ${isSelected ? 'border-[var(--glass-stroke-success)]' : 'border-transparent hover:border-[var(--glass-stroke-focus)]'}`}\n                        >\n                          <div className=\"aspect-video bg-[var(--glass-bg-muted)]\">\n                            {previewImage ? (\n                              <MediaImageWithLoading\n                                src={previewImage}\n                                alt={location.name}\n                                containerClassName=\"h-full w-full\"\n                                className=\"h-full w-full object-cover\"\n                              />\n                            ) : null}\n                          </div>\n                          <div className=\"truncate px-2 py-1 text-xs font-medium text-[var(--glass-text-secondary)]\">\n                            {location.name}\n                          </div>\n                          {isSelected && (\n                            <span className=\"absolute right-1.5 top-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-[var(--glass-tone-success-fg)] text-white shadow-md\">\n                              <AppIcon name=\"checkMicro\" className=\"h-3 w-3\" />\n                            </span>\n                          )}\n                        </button>\n                        {isSelected && (\n                          <input\n                            value={pendingLocationLabels[location.id] || location.name}\n                            disabled={isAllClipsMode}\n                            onChange={(event) => {\n                              const value = event.target.value\n                              setPendingLocationLabels((prev) => ({ ...prev, [location.id]: value }))\n                            }}\n                            className=\"w-full rounded border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-2 py-1 text-xs text-[var(--glass-text-secondary)] outline-none focus:border-[var(--glass-stroke-focus)] disabled:cursor-not-allowed disabled:opacity-60\"\n                          />\n                        )}\n                      </div>\n                    )\n                  })}\n                </div>\n              </div>\n              <div className=\"mt-3 flex shrink-0 items-center justify-end gap-2 border-t border-[var(--glass-stroke-base)] pt-3\">\n                <button\n                  onClick={() => setShowAddLoc(false)}\n                  disabled={isSavingLocationSelection}\n                  className=\"glass-btn-base glass-btn-secondary rounded-lg px-3 py-1.5 text-xs text-[var(--glass-text-secondary)]\"\n                >\n                  {tCommon('cancel')}\n                </button>\n                <button\n                  onClick={() => void handleConfirmLocationSelection()}\n                  disabled={isSavingLocationSelection || !hasLocationSelectionChanges}\n                  className=\"glass-btn-base glass-btn-primary rounded-lg px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-50\"\n                >\n                  {tCommon('confirm')}\n                </button>\n              </div>\n            </div>,\n            document.body,\n          )}\n\n          {activeLocationIds.length === 0 ? (\n            <div className=\"text-center text-[var(--glass-text-tertiary)] text-sm py-4\">{tScript('screenplay.noLocation')}</div>\n          ) : (\n            <div className=\"grid grid-cols-2 gap-3\">\n              {locations.filter((l) => activeLocationIds.includes(l.id)).map((loc) => (\n                <SpotlightLocationCard\n                  key={loc.id}\n                  location={loc}\n                  isActive={true}\n                  onClick={() => { }}\n                  onOpenAssetLibrary={onOpenAssetLibrary}\n                  onRemove={() => void onUpdateClipAssets('location', 'remove', loc.id)}\n                />\n              ))}\n            </div>\n          )}\n        </div>\n      </div>\n\n      <div className=\"mt-4 mb-4\">\n        {!allAssetsHaveImages && globalCharIds.length + globalLocationIds.length > 0 && (\n          <div className=\"mb-3 p-4 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-2xl shadow-sm\">\n            <p className=\"text-sm font-medium text-[var(--glass-text-primary)]\">{tScript('generate.missingAssets', { count: missingAssetsCount })}</p>\n            <p className=\"text-xs text-[var(--glass-text-tertiary)] mt-0.5\">\n              {tScript('generate.missingAssetsTip')}\n              <button onClick={onOpenAssetLibrary} className=\"text-[var(--glass-tone-info-fg)] hover:underline mx-1\">\n                {tNP('buttons.assetLibrary')}\n              </button>\n              {tScript('generate.missingAssetsTipLink')}\n            </p>\n          </div>\n        )}\n        <button\n          onClick={onGenerateStoryboard}\n          disabled={isSubmittingStoryboardBuild || clips.length === 0 || !allAssetsHaveImages}\n          className=\"w-full py-4 text-lg font-bold bg-[var(--glass-accent-from)] text-white rounded-2xl\"\n        >\n          {isSubmittingStoryboardBuild ? tScript('generate.generating') : tScript('generate.startGenerate')}\n        </button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewContainer.tsx",
    "content": "export { default } from './ScriptViewCore'\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewCore.tsx",
    "content": "export { default } from './ScriptViewRuntime'\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewRuntime.tsx",
    "content": "'use client'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\n\nimport { useTranslations } from 'next-intl'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport type { Character, Location } from '@/types/project'\nimport { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport {\n  fuzzyMatchLocation as fuzzyMatchLocationFromModule,\n  getAllClipsAssets as getAllClipsAssetsFromModule,\n  parseClipAssets as parseClipAssetsFromModule,\n} from './clip-asset-utils'\nimport ScriptViewScriptPanel from './ScriptViewScriptPanel'\nimport ScriptViewAssetsPanel from './ScriptViewAssetsPanel'\nimport {\n  getPrimaryAppearance,\n  getSelectedAppearances,\n  processCharacterInClip,\n  processLocationInClip,\n} from './asset-state-utils'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\n\ninterface Clip {\n  id: string\n  clipIndex?: number\n  summary: string\n  content: string\n  screenplay?: string | null\n  characters: string | null\n  location: string | null\n}\n\ninterface Panel {\n  panelIndex: number\n  characters?: string | null\n  location?: string | null\n}\n\ninterface Storyboard {\n  id: string\n  clipId?: string\n  panels?: Panel[]\n}\n\ninterface ScriptViewProps {\n  projectId: string\n  episodeId?: string\n  clips: Clip[]\n  storyboards?: Storyboard[]\n  onClipEdit?: (clipId: string) => void\n  onClipUpdate?: (clipId: string, data: Partial<Clip>) => void\n  onClipDelete?: (clipId: string) => void\n  onGenerateStoryboard?: () => void\n  isSubmittingStoryboardBuild?: boolean\n  assetsLoading?: boolean\n  onOpenAssetLibrary?: () => void\n}\n\nfunction toTranslationValues(values?: Record<string, unknown>) {\n  return values as never\n}\n\nexport default function ScriptView({\n  projectId,\n  clips,\n  onClipEdit,\n  onClipUpdate,\n  onClipDelete,\n  onGenerateStoryboard,\n  isSubmittingStoryboardBuild = false,\n  assetsLoading = false,\n  onOpenAssetLibrary,\n}: ScriptViewProps) {\n  const t = useTranslations('smartImport')\n  const tAssets = useTranslations('assets')\n  const tNP = useTranslations('novelPromotion')\n  const tScript = useTranslations('scriptView')\n  const tCommon = useTranslations('common')\n\n  const assetsLoadingState = assetsLoading\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'image',\n      hasOutput: false,\n    })\n    : null\n\n  const { data: assets } = useProjectAssets(projectId)\n  const characters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters])\n  const locations: Location[] = useMemo(() => assets?.locations ?? [], [assets?.locations])\n\n  const [activeCharIds, setActiveCharIds] = useState<string[]>([])\n  const [activeLocationIds, setActiveLocationIds] = useState<string[]>([])\n  const [selectedAppearanceKeys, setSelectedAppearanceKeys] = useState<Set<string>>(new Set())\n\n  const isManuallyEditingRef = useRef(false)\n  const manualEditTimeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n  const [assetViewMode, setAssetViewMode] = useState<'all' | string>('all')\n  const [selectedClipId, setSelectedClipId] = useState<string | null>(null)\n  const [savingClips, setSavingClips] = useState<Set<string>>(new Set())\n\n  useEffect(() => {\n    if (clips.length > 0 && !selectedClipId) {\n      setSelectedClipId(clips[0].id)\n    }\n  }, [clips, selectedClipId])\n\n  const fuzzyMatchLocation = (clipLocName: string, libraryLocName: string): boolean =>\n    fuzzyMatchLocationFromModule(clipLocName, libraryLocName)\n\n  const parseClipAssets = (clip: Clip) => parseClipAssetsFromModule(clip)\n  const getAllClipsAssets = useCallback(() => getAllClipsAssetsFromModule(clips), [clips])\n\n  useEffect(() => {\n    if (isManuallyEditingRef.current) {\n      _ulogInfo('[ScriptView] skip sync while manual editing')\n      return\n    }\n\n    let charNames = new Set<string>()\n    let locNames = new Set<string>()\n    let charAppearanceSet = new Set<string>()\n\n    if (assetViewMode === 'all') {\n      const all = getAllClipsAssets()\n      charNames = all.allCharNames\n      locNames = all.allLocNames\n      charAppearanceSet = all.allCharAppearanceSet\n    } else {\n      const clip = clips.find((c) => c.id === assetViewMode)\n      if (clip) {\n        const parsed = parseClipAssets(clip)\n        charNames = parsed.charNames\n        locNames = parsed.locNames\n        charAppearanceSet = parsed.charAppearanceSet\n      }\n    }\n\n    const matchedCharIds: string[] = []\n    const newSelectedKeys = new Set<string>()\n\n    characters.forEach((c) => {\n      const aliases = c.name.split('/').map((a) => a.trim())\n      const matched = aliases.some((alias) => charNames.has(alias)) || charNames.has(c.name)\n      if (!matched) return\n\n      matchedCharIds.push(c.id)\n      const matchedAlias =\n        aliases.find((alias) =>\n          Array.from(charAppearanceSet).some((key) => key.startsWith(`${alias}::`)),\n        ) ||\n        (Array.from(charAppearanceSet).some((key) => key.startsWith(`${c.name}::`))\n          ? c.name\n          : null)\n\n      if (!matchedAlias) return\n      charAppearanceSet.forEach((key) => {\n        if (!key.startsWith(`${matchedAlias}::`)) return\n        const appearanceName = key.split('::')[1]\n        newSelectedKeys.add(`${c.id}::${appearanceName}`)\n      })\n    })\n\n    const matchedLocIds = locations\n      .filter((l) => Array.from(locNames).some((clipLocName) => fuzzyMatchLocation(clipLocName, l.name)))\n      .map((l) => l.id)\n\n    setActiveCharIds(matchedCharIds)\n    setActiveLocationIds(matchedLocIds)\n    setSelectedAppearanceKeys(newSelectedKeys)\n  }, [assetViewMode, characters, clips, getAllClipsAssets, locations])\n\n  const handleUpdateClipAssets = async (\n    type: 'character' | 'location',\n    action: 'add' | 'remove',\n    id: string,\n    optionLabel?: string,\n  ) => {\n    if (!onClipUpdate) return\n\n    const isAllMode = assetViewMode === 'all'\n    const targetClipId = !isAllMode ? assetViewMode : selectedClipId\n    if (!isAllMode && !targetClipId) return\n\n    isManuallyEditingRef.current = true\n    if (manualEditTimeoutRef.current) {\n      clearTimeout(manualEditTimeoutRef.current)\n    }\n    manualEditTimeoutRef.current = setTimeout(() => {\n      isManuallyEditingRef.current = false\n      _ulogInfo('[ScriptView] manual editing lock released')\n    }, 1500)\n\n    if (type === 'character') {\n      const targetChar = characters.find((c) => c.id === id)\n      if (!targetChar) return\n\n      const primaryLabel = tAssets('character.primary')\n      const finalAppearanceName =\n        optionLabel ||\n        (targetChar.appearances?.find((appearance) => appearance.appearanceIndex === PRIMARY_APPEARANCE_INDEX)?.changeReason ||\n          primaryLabel)\n\n      if (isAllMode && action === 'remove') {\n        for (const clip of clips) {\n          const newValue = processCharacterInClip({\n            clip,\n            action: 'remove',\n            targetChar,\n            appearanceName: optionLabel,\n            characters,\n            tAssets: (key) => tAssets(key),\n          })\n          if (newValue !== null) {\n            await onClipUpdate(clip.id, { characters: newValue })\n          }\n        }\n\n        const appearanceKey = `${id}::${finalAppearanceName}`\n        const newKeys = new Set(selectedAppearanceKeys)\n        newKeys.delete(appearanceKey)\n        setSelectedAppearanceKeys(newKeys)\n\n        const remainingAppearances = Array.from(newKeys).filter((k) => k.startsWith(`${id}::`))\n        if (remainingAppearances.length === 0) {\n          setActiveCharIds(activeCharIds.filter((aid) => aid !== id))\n        }\n        return\n      }\n\n      const clip = clips.find((c) => c.id === targetClipId)\n      if (!clip) return\n\n      const newValue = processCharacterInClip({\n        clip,\n        action,\n        targetChar,\n        appearanceName: optionLabel,\n        characters,\n        tAssets: (key) => tAssets(key),\n      })\n\n      const appearanceKey = `${id}::${finalAppearanceName}`\n      const newKeys = new Set(selectedAppearanceKeys)\n      if (action === 'add') {\n        newKeys.add(appearanceKey)\n        if (!activeCharIds.includes(id)) {\n          setActiveCharIds([...activeCharIds, id])\n        }\n      } else {\n        newKeys.delete(appearanceKey)\n        const remainingAppearances = Array.from(newKeys).filter((k) => k.startsWith(`${id}::`))\n        if (remainingAppearances.length === 0) {\n          setActiveCharIds(activeCharIds.filter((aid) => aid !== id))\n        }\n      }\n      setSelectedAppearanceKeys(newKeys)\n\n      if (newValue !== null) {\n        await onClipUpdate(targetClipId!, { characters: newValue })\n      }\n      return\n    }\n\n    const targetLoc = locations.find((l) => l.id === id)\n    if (!targetLoc) return\n\n    if (isAllMode && action === 'remove') {\n      for (const clip of clips) {\n        const newValue = processLocationInClip({\n          clip,\n          action: 'remove',\n          targetLoc,\n          fuzzyMatchLocation,\n        })\n        if (newValue !== null) {\n          await onClipUpdate(clip.id, { location: newValue })\n        }\n      }\n      setActiveLocationIds(activeLocationIds.filter((lid) => lid !== id))\n      return\n    }\n\n    const clip = clips.find((c) => c.id === targetClipId)\n    if (!clip) return\n\n    const newValue = processLocationInClip({\n      clip,\n      action,\n      targetLoc,\n      locationName: optionLabel,\n      fuzzyMatchLocation,\n    })\n\n    const newActiveIds =\n      action === 'add' ? [...activeLocationIds, id] : activeLocationIds.filter((lid) => lid !== id)\n    setActiveLocationIds(newActiveIds)\n\n    if (newValue !== null) {\n      await onClipUpdate(targetClipId!, { location: newValue })\n    }\n  }\n\n  const handleClipUpdateWithSaving = async (clipId: string, data: Partial<Clip>) => {\n    if (!onClipUpdate) return\n    setSavingClips((prev) => new Set(prev).add(clipId))\n    try {\n      await onClipUpdate(clipId, data)\n    } finally {\n      setTimeout(() => {\n        setSavingClips((prev) => {\n          const next = new Set(prev)\n          next.delete(clipId)\n          return next\n        })\n      }, 500)\n    }\n  }\n\n  const { allCharNames: globalCharNames, allLocNames: globalLocNames } = getAllClipsAssets()\n\n  const globalCharIds = characters\n    .filter((c) => {\n      const aliases = c.name.split('/').map((a) => a.trim())\n      return aliases.some((alias) => globalCharNames.has(alias)) || globalCharNames.has(c.name)\n    })\n    .map((c) => c.id)\n\n  const globalLocationIds = locations\n    .filter((l) => Array.from(globalLocNames).some((clipLocName) => fuzzyMatchLocation(clipLocName, l.name)))\n    .map((l) => l.id)\n\n  const globalActiveChars = characters.filter((c) => globalCharIds.includes(c.id))\n  const globalActiveLocations = locations.filter((l) => globalLocationIds.includes(l.id))\n\n  const charsWithoutImage = globalActiveChars.filter((char) => {\n    const appearance = getPrimaryAppearance(char)\n    const imageUrl = appearance?.imageUrl || appearance?.imageUrls?.[0]\n    return !imageUrl\n  })\n\n  const locationsWithoutImage = globalActiveLocations.filter((loc) => {\n    const image = (loc.selectedImageId\n      ? loc.images?.find((img) => img.id === loc.selectedImageId)\n      : undefined) || loc.images?.find((img) => img.isSelected) || loc.images?.find((img) => img.imageUrl)\n    return !image?.imageUrl\n  })\n\n  const allAssetsHaveImages = charsWithoutImage.length === 0 && locationsWithoutImage.length === 0\n  const missingAssetsCount = charsWithoutImage.length + locationsWithoutImage.length\n\n  return (\n    <div className=\"w-full grid grid-cols-12 gap-6 min-h-[400px] lg:h-[calc(100vh-180px)] animate-fadeIn\">\n      <ScriptViewScriptPanel\n        clips={clips}\n        selectedClipId={selectedClipId}\n        onSelectClip={setSelectedClipId}\n        savingClips={savingClips}\n        onClipEdit={onClipEdit}\n        onClipDelete={onClipDelete}\n        onClipUpdate={handleClipUpdateWithSaving}\n        t={(key, values) => t(key, toTranslationValues(values))}\n        tScript={(key, values) => tScript(key, toTranslationValues(values))}\n      />\n\n      <ScriptViewAssetsPanel\n        clips={clips}\n        assetViewMode={assetViewMode}\n        setAssetViewMode={setAssetViewMode}\n        setSelectedClipId={setSelectedClipId}\n        characters={characters}\n        locations={locations}\n        activeCharIds={activeCharIds}\n        activeLocationIds={activeLocationIds}\n        selectedAppearanceKeys={selectedAppearanceKeys}\n        onUpdateClipAssets={handleUpdateClipAssets}\n        onOpenAssetLibrary={onOpenAssetLibrary}\n        assetsLoading={assetsLoading}\n        assetsLoadingState={assetsLoadingState}\n        allAssetsHaveImages={allAssetsHaveImages}\n        globalCharIds={globalCharIds}\n        globalLocationIds={globalLocationIds}\n        missingAssetsCount={missingAssetsCount}\n        onGenerateStoryboard={onGenerateStoryboard}\n        isSubmittingStoryboardBuild={isSubmittingStoryboardBuild}\n        getSelectedAppearances={(char) => getSelectedAppearances(char, selectedAppearanceKeys)}\n        tScript={(key, values) => tScript(key, toTranslationValues(values))}\n        tAssets={(key, values) => tAssets(key, toTranslationValues(values))}\n        tNP={(key, values) => tNP(key, toTranslationValues(values))}\n        tCommon={(key, values) => tCommon(key, toTranslationValues(values))}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewScriptPanel.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { logWarn as _ulogWarn } from '@/lib/logging/core'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface Clip {\n  id: string\n  clipIndex?: number\n  summary: string\n  content: string\n  screenplay?: string | null\n  characters: string | null\n  location: string | null\n}\n\ntype ScreenplayContentItem =\n  | { type: 'action'; text: string }\n  | { type: 'dialogue'; character: string; lines: string }\n  | { type: 'voiceover'; text: string }\n\ninterface ScreenplayScene {\n  scene_number?: number\n  heading?: {\n    int_ext?: string\n    location?: string\n    time?: string\n  }\n  description?: string\n  content?: ScreenplayContentItem[]\n}\n\ninterface ScreenplayData {\n  scenes: ScreenplayScene[]\n}\n\nfunction parseScreenplay(value: string | null | undefined): ScreenplayData | null {\n  if (!value) return null\n  try {\n    const parsed = JSON.parse(value)\n    if (!parsed || typeof parsed !== 'object') return null\n    const scenes = (parsed as { scenes?: unknown }).scenes\n    if (!Array.isArray(scenes)) return null\n    return parsed as ScreenplayData\n  } catch (error) {\n    _ulogWarn('解析剧本JSON失败:', error)\n    return null\n  }\n}\n\ninterface ScriptViewScriptPanelProps {\n  clips: Clip[]\n  selectedClipId: string | null\n  onSelectClip: (clipId: string) => void\n  savingClips: Set<string>\n  onClipEdit?: (clipId: string) => void\n  onClipDelete?: (clipId: string) => void\n  onClipUpdate?: (clipId: string, data: Partial<Clip>) => void\n  t: (key: string, values?: Record<string, unknown>) => string\n  tScript: (key: string, values?: Record<string, unknown>) => string\n}\n\nfunction EditableText({\n  text,\n  onSave,\n  className = '',\n  tScript,\n}: {\n  text: string\n  onSave: (val: string) => void\n  className?: string\n  tScript: (key: string, values?: Record<string, unknown>) => string\n}) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [value, setValue] = useState(text)\n\n  useEffect(() => {\n    setValue(text)\n  }, [text])\n\n  const handleBlur = () => {\n    setIsEditing(false)\n    if (value !== text) {\n      onSave(value)\n    }\n  }\n\n  if (isEditing) {\n    return (\n      <textarea\n        autoFocus\n        value={value}\n        onChange={(e) => setValue(e.target.value)}\n        onBlur={handleBlur}\n        className={`w-full bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-focus)] rounded p-1 outline-none focus:ring-2 focus:ring-[var(--glass-focus-ring-strong)] ${className}`}\n        style={{ resize: 'none', minHeight: '1.5em' }}\n      />\n    )\n  }\n\n  return (\n    <div\n      onClick={(e) => {\n        e.stopPropagation()\n        setIsEditing(true)\n      }}\n      className={`cursor-text hover:bg-[var(--glass-tone-info-bg)] rounded px-1 -mx-1 transition-colors border border-transparent hover:border-[var(--glass-stroke-focus)] ${className}`}\n      title={tScript('screenplay.clickToEdit')}\n    >\n      {text}\n    </div>\n  )\n}\n\nexport default function ScriptViewScriptPanel({\n  clips,\n  selectedClipId,\n  onSelectClip,\n  savingClips,\n  onClipEdit,\n  onClipDelete,\n  onClipUpdate,\n  t,\n  tScript,\n}: ScriptViewScriptPanelProps) {\n  const handleScriptSave = async (clipId: string, newContent: string, isJson: boolean) => {\n    if (!onClipUpdate) return\n    const updateData: Partial<Clip> = isJson ? { screenplay: newContent } : { content: newContent }\n    await onClipUpdate(clipId, updateData)\n  }\n\n  return (\n    <div className=\"col-span-12 lg:col-span-8 flex flex-col min-h-[400px] lg:h-full gap-4\">\n      <div className=\"flex justify-between items-end px-2\">\n        <h2 className=\"text-xl font-bold text-[var(--glass-text-primary)] flex items-center gap-2\">\n          <span className=\"w-1.5 h-6 bg-[var(--glass-accent-from)] rounded-full\" /> {tScript('scriptBreakdown')}\n        </h2>\n        <span className=\"text-sm text-[var(--glass-text-tertiary)]\">\n          {tScript('splitCount', { count: clips.length })}\n        </span>\n      </div>\n\n      <div className=\"flex-1 glass-surface-elevated overflow-hidden flex flex-col relative w-full min-h-[300px]\">\n        <div className=\"lg:absolute lg:inset-0 overflow-y-auto p-6 space-y-4 custom-scrollbar\">\n          {clips.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center h-full text-[var(--glass-text-tertiary)]\">\n              <AppIcon name=\"fileFold\" className=\"h-10 w-10 mb-2\" />\n              <p>{tScript('noClips')}</p>\n            </div>\n          ) : (\n            clips.map((clip, idx) => {\n              const screenplay = parseScreenplay(clip.screenplay)\n\n              return (\n                <div\n                  key={clip.id}\n                  onClick={() => onSelectClip(clip.id)}\n                  className={`\n                    group p-5 border-[1.5px] rounded-2xl transition-all cursor-pointer relative bg-[var(--glass-bg-surface)]\n                    ${selectedClipId === clip.id\n                      ? 'border-[var(--glass-stroke-focus)] shadow-[0_6px_24px_rgba(0,0,0,0.06)] ring-2 ring-[var(--glass-tone-info-bg)]'\n                      : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]/40 hover:shadow-md'\n                    }\n                  `}\n                >\n                  {savingClips.has(clip.id) && (\n                    <div className=\"absolute top-2 right-2 text-xs text-[var(--glass-tone-info-fg)] flex items-center gap-1 animate-pulse\">\n                      <AppIcon name=\"upload\" className=\"w-3 h-3\" />\n                      {t('preview.saving')}\n                    </div>\n                  )}\n\n                  <div className=\"flex justify-between mb-2\">\n                    <span className=\"text-xs font-bold px-2 py-0.5 rounded text-[var(--glass-tone-info-fg)] bg-[var(--glass-tone-info-bg)]\">\n                      {tScript('segment.title', { index: idx + 1 })} {selectedClipId === clip.id && tScript('segment.selected')}\n                    </span>\n                    <div className=\"flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity\">\n                      {onClipEdit && (\n                        <button\n                          onClick={() => onClipEdit(clip.id)}\n                          className=\"text-[var(--glass-text-tertiary)] text-xs cursor-pointer hover:text-[var(--glass-tone-info-fg)]\"\n                        >\n                          {t('common.edit')}\n                        </button>\n                      )}\n                      {onClipDelete && (\n                        <button\n                          onClick={() => onClipDelete(clip.id)}\n                          className=\"text-[var(--glass-text-tertiary)] text-xs cursor-pointer hover:text-[var(--glass-tone-danger-fg)]\"\n                        >\n                          {t('common.delete')}\n                        </button>\n                      )}\n                    </div>\n                  </div>\n\n                  {screenplay && screenplay.scenes ? (\n                    <div className=\"space-y-3\">\n                      {screenplay.scenes.map((scene, sceneIdx: number) => (\n                        <div key={sceneIdx}>\n                          {/* 场景头信息 */}\n                          <div className=\"flex items-center gap-1.5 text-xs mb-2 flex-wrap\">\n                            <span className=\"font-bold text-[var(--glass-tone-info-fg)] bg-[var(--glass-tone-info-bg)] px-2 py-0.5 rounded\">\n                              {tScript('screenplay.scene', { number: scene.scene_number })}\n                            </span>\n                            <span className=\"text-[var(--glass-text-tertiary)] flex items-center gap-1\">\n                              {scene.heading?.int_ext} ·\n                              <EditableText\n                                text={scene.heading?.location || ''}\n                                onSave={(newVal) => {\n                                  const newScreenplay = JSON.parse(JSON.stringify(screenplay))\n                                  newScreenplay.scenes[sceneIdx].heading.location = newVal\n                                  void handleScriptSave(clip.id, JSON.stringify(newScreenplay), true)\n                                }}\n                                className=\"inline\"\n                                tScript={tScript}\n                              />\n                              ·\n                              <EditableText\n                                text={scene.heading?.time || ''}\n                                onSave={(newVal) => {\n                                  const newScreenplay = JSON.parse(JSON.stringify(screenplay))\n                                  newScreenplay.scenes[sceneIdx].heading.time = newVal\n                                  void handleScriptSave(clip.id, JSON.stringify(newScreenplay), true)\n                                }}\n                                className=\"inline\"\n                                tScript={tScript}\n                              />\n                            </span>\n                          </div>\n\n                          {/* 场景描述 */}\n                          {scene.description && (\n                            <div className=\"text-xs text-[var(--glass-text-secondary)] bg-[var(--glass-bg-muted)] border-l-2 border-[var(--glass-stroke-base)] px-2 py-1 rounded mb-2\">\n                              <EditableText\n                                text={scene.description}\n                                onSave={(newVal) => {\n                                  const newScreenplay = JSON.parse(JSON.stringify(screenplay))\n                                  newScreenplay.scenes[sceneIdx].description = newVal\n                                  void handleScriptSave(clip.id, JSON.stringify(newScreenplay), true)\n                                }}\n                                tScript={tScript}\n                              />\n                            </div>\n                          )}\n\n                          {/* 内容流 - 高密度胶囊文本流 */}\n                          <div className=\"flex flex-col gap-2\">\n                            {scene.content?.map((item, itemIdx: number) => {\n                              if (item.type === 'action') {\n                                return (\n                                  <div key={itemIdx} className=\"text-sm text-[var(--glass-text-secondary)] bg-[var(--glass-bg-muted)]/60 border border-[var(--glass-stroke-base)] px-2.5 py-1 rounded-lg flex items-start gap-2 w-fit max-w-full leading-[1.5]\">\n                                    <AppIcon name=\"clapperboard\" className=\"w-3.5 h-3.5 text-[var(--glass-text-tertiary)] shrink-0 mt-[2px]\" />\n                                    <EditableText\n                                      text={item.text}\n                                      onSave={(newVal) => {\n                                        const newScreenplay = JSON.parse(JSON.stringify(screenplay))\n                                        newScreenplay.scenes[sceneIdx].content[itemIdx].text = newVal\n                                        void handleScriptSave(clip.id, JSON.stringify(newScreenplay), true)\n                                      }}\n                                      tScript={tScript}\n                                    />\n                                  </div>\n                                )\n                              }\n                              if (item.type === 'dialogue') {\n                                return (\n                                  <div key={itemIdx} className=\"flex flex-wrap items-baseline gap-2\">\n                                    <span className=\"inline-flex items-center text-[13px] font-bold text-[var(--glass-tone-info-fg)] bg-[var(--glass-tone-info-bg)] border border-[var(--glass-stroke-focus)]/40 px-2.5 py-0.5 rounded-full shrink-0\">\n                                      {item.character}\n                                    </span>\n                                    <div className=\"text-[15px] text-[var(--glass-text-primary)] font-medium leading-[1.5] flex-1 min-w-0\">\n                                      <EditableText\n                                        text={item.lines}\n                                        onSave={(newVal) => {\n                                          const newScreenplay = JSON.parse(JSON.stringify(screenplay))\n                                          newScreenplay.scenes[sceneIdx].content[itemIdx].lines = newVal\n                                          void handleScriptSave(clip.id, JSON.stringify(newScreenplay), true)\n                                        }}\n                                        tScript={tScript}\n                                      />\n                                    </div>\n                                  </div>\n                                )\n                              }\n                              if (item.type === 'voiceover') {\n                                return (\n                                  <div key={itemIdx} className=\"flex flex-wrap items-baseline gap-2\">\n                                    <span className=\"inline-flex items-center text-[13px] font-bold text-[var(--glass-tone-info-fg)]/80 bg-[var(--glass-tone-info-bg)]/50 border border-[var(--glass-stroke-focus)]/20 px-2.5 py-0.5 rounded-full shrink-0 italic\">\n                                      {tScript('screenplay.narration')}\n                                    </span>\n                                    <p className=\"text-[15px] text-[var(--glass-text-secondary)] font-medium italic leading-[1.5] flex-1\">{item.text}</p>\n                                  </div>\n                                )\n                              }\n                              return null\n                            })}\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  ) : (\n                    <p className=\"text-[var(--glass-text-secondary)] text-sm leading-relaxed\">{clip.summary || clip.content}</p>\n                  )}\n                </div>\n              )\n            })\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/SpotlightCards.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { useEffect, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { MouseEvent } from 'react'\nimport type { Character, CharacterAppearance, Location } from '@/types/project'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { AppIcon } from '@/components/ui/icons'\nimport ImagePreviewModal from '@/components/ui/ImagePreviewModal'\n\n\ntype SpotlightCharCardProps = {\n  char: Character\n  appearance?: CharacterAppearance\n  isActive: boolean\n  onClick: () => void\n  onOpenAssetLibrary?: () => void\n  onRemove?: () => void\n}\n\nexport function SpotlightCharCard({\n  char,\n  appearance,\n  isActive,\n  onClick,\n  onOpenAssetLibrary,\n  onRemove,\n}: SpotlightCharCardProps) {\n  const tScript = useTranslations('scriptView')\n  const [isPlaying, setIsPlaying] = useState(false)\n  const [audioRef, setAudioRef] = useState<HTMLAudioElement | null>(null)\n  const [previewImage, setPreviewImage] = useState<string | null>(null)\n\n  const selectedIdx = appearance?.selectedIndex ?? null\n  const imageUrl = appearance?.imageUrl ||\n    (selectedIdx !== null ? appearance?.imageUrls?.[selectedIdx] : null) ||\n    (appearance?.imageUrls?.[0])\n\n  const hasVoice = !!char.customVoiceUrl\n\n  const handlePlayVoice = (e: MouseEvent) => {\n    e.stopPropagation()\n    if (!char.customVoiceUrl) return\n\n    if (isPlaying && audioRef) {\n      audioRef.pause()\n      audioRef.currentTime = 0\n      setIsPlaying(false)\n      return\n    }\n\n    const audio = new Audio(char.customVoiceUrl)\n    setAudioRef(audio)\n\n    audio.onended = () => {\n      setIsPlaying(false)\n      setAudioRef(null)\n    }\n\n    audio.onerror = () => {\n      setIsPlaying(false)\n      setAudioRef(null)\n    }\n\n    audio.play()\n    setIsPlaying(true)\n  }\n\n  useEffect(() => {\n    return () => {\n      if (audioRef) {\n        audioRef.pause()\n        audioRef.currentTime = 0\n      }\n    }\n  }, [audioRef])\n\n  return (\n    <div\n      onClick={onClick}\n      className={`\n        group relative rounded-xl cursor-pointer transition-all duration-500 ease-out\n        ${isActive\n          ? 'opacity-100 scale-100 ring-2 ring-[var(--glass-focus-ring-strong)] shadow-[var(--glass-shadow-md)] bg-[var(--glass-bg-surface)]'\n          : 'opacity-50 scale-95 grayscale hover:grayscale-0 hover:opacity-100 hover:scale-95 bg-[var(--glass-bg-muted)]'\n        }\n      `}\n    >\n      {isActive && onRemove && (\n        <button\n          onClick={(e) => {\n            e.stopPropagation()\n            if (confirm(tScript('confirm.removeCharacter'))) {\n              onRemove()\n            }\n          }}\n          className=\"absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 w-5 h-5 bg-[var(--glass-tone-danger-fg)] rounded-full text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all shadow-md hover:bg-[var(--glass-tone-danger-fg)] hover:scale-110 z-20\"\n          title={tScript('asset.removeFromClip')}\n        >\n          <AppIcon name=\"closeSm\" className=\"h-3 w-3\" />\n        </button>\n      )}\n      <div className=\"aspect-square relative rounded-t-xl overflow-hidden bg-[var(--glass-bg-muted)]\">\n        {imageUrl ? (\n          <MediaImageWithLoading\n            src={imageUrl}\n            alt={char.name}\n            containerClassName=\"w-full h-full\"\n            className=\"w-full h-full object-cover cursor-zoom-in\"\n            onClick={(e) => { e.stopPropagation(); setPreviewImage(imageUrl) }}\n          />\n        ) : (\n          <div className=\"w-full h-full flex flex-col items-center justify-center bg-[var(--glass-bg-surface-strong)] p-3\">\n            <div className=\"w-10 h-10 rounded-full bg-[var(--glass-bg-muted)] flex items-center justify-center mb-2\">\n              <AppIcon name=\"userCircle\" className=\"w-5 h-5 text-[var(--glass-text-tertiary)]\" />\n            </div>\n            {onOpenAssetLibrary && (\n              <button\n                onClick={(e) => { e.stopPropagation(); onOpenAssetLibrary() }}\n                className=\"text-[11px] text-[var(--glass-text-secondary)] font-medium hover:text-[var(--glass-tone-info-fg)] transition-colors text-center leading-tight\"\n              >\n                {tScript('asset.generateCharacter')}\n              </button>\n            )}\n          </div>\n        )}\n        {isActive && (\n          <div className=\"absolute top-2 right-2 w-2 h-2 bg-[var(--glass-tone-success-fg)] rounded-full shadow-[0_0_8px_rgba(74,222,128,0.8)] border border-white\" />\n        )}\n      </div>\n      <div className=\"p-2 text-center\">\n        <div className={`text-sm font-bold truncate ${isActive ? 'text-[var(--glass-text-primary)]' : 'text-[var(--glass-text-tertiary)]'}`}>\n          {char.name}\n        </div>\n        {appearance?.changeReason && (\n          <div className=\"text-xs text-[var(--glass-text-tertiary)] truncate\">{appearance.changeReason}</div>\n        )}\n        <button\n          onClick={hasVoice ? handlePlayVoice : undefined}\n          disabled={!hasVoice}\n          className={`mt-1.5 w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-all ${!hasVoice\n            ? 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)] cursor-not-allowed border border-dashed border-[var(--glass-stroke-base)]'\n            : isPlaying\n              ? 'bg-[var(--glass-accent-from)] text-white'\n              : 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-tone-info-bg)] hover:text-[var(--glass-tone-info-fg)]'\n            }`}\n        >\n          {!hasVoice ? (\n            <>\n              <AppIcon name=\"volumeOff\" className=\"w-3 h-3\" />\n              <span>{tScript('asset.noAudio')}</span>\n            </>\n          ) : isPlaying ? (\n            <>\n              <span className=\"flex gap-0.5\">\n                <span className=\"w-0.5 h-3 bg-[var(--glass-bg-surface)] rounded-full animate-pulse\" />\n                <span className=\"w-0.5 h-3 bg-[var(--glass-bg-surface)] rounded-full animate-pulse\" style={{ animationDelay: '0.1s' }} />\n                <span className=\"w-0.5 h-3 bg-[var(--glass-bg-surface)] rounded-full animate-pulse\" style={{ animationDelay: '0.2s' }} />\n              </span>\n              <span>{tScript('asset.playing')}</span>\n            </>\n          ) : (\n            <>\n              <AppIcon name=\"play\" className=\"w-3 h-3\" />\n              <span>{tScript('asset.listen')}</span>\n            </>\n          )}\n        </button>\n      </div>\n      {previewImage && typeof document !== 'undefined' && createPortal(\n        <ImagePreviewModal imageUrl={previewImage} onClose={() => setPreviewImage(null)} />,\n        document.body\n      )}\n    </div>\n  )\n}\n\nexport function getSelectedLocationImage(location: Location) {\n  const byId = location.selectedImageId\n    ? location.images?.find(img => img.id === location.selectedImageId)\n    : undefined\n  const byFlag = location.images?.find(img => img.isSelected)\n  const withUrl = location.images?.find(img => img.imageUrl)\n  return byId || byFlag || withUrl || location.images?.[0]\n}\n\ntype SpotlightLocationCardProps = {\n  location: Location\n  isActive: boolean\n  onClick: () => void\n  onOpenAssetLibrary?: () => void\n  onRemove?: () => void\n}\n\nexport function SpotlightLocationCard({\n  location,\n  isActive,\n  onClick,\n  onOpenAssetLibrary,\n  onRemove,\n}: SpotlightLocationCardProps) {\n  const tScript = useTranslations('scriptView')\n  const [previewImage, setPreviewImage] = useState<string | null>(null)\n  const image = getSelectedLocationImage(location)\n  const imageUrl = image?.imageUrl\n\n  return (\n    <div\n      onClick={onClick}\n      className={`\n        group relative rounded-xl cursor-pointer transition-all duration-500 ease-out\n        ${isActive\n          ? 'opacity-100 scale-100 ring-2 ring-[var(--glass-stroke-success)] shadow-[var(--glass-shadow-md)] bg-[var(--glass-bg-surface)]'\n          : 'opacity-50 scale-95 grayscale hover:grayscale-0 hover:opacity-100 hover:scale-95 bg-[var(--glass-bg-muted)]'\n        }\n      `}\n    >\n      {isActive && onRemove && (\n        <button\n          onClick={(e) => {\n            e.stopPropagation()\n            if (confirm(tScript('confirm.removeLocation'))) {\n              onRemove()\n            }\n          }}\n          className=\"absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 w-5 h-5 bg-[var(--glass-tone-danger-fg)] rounded-full text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all shadow-md hover:bg-[var(--glass-tone-danger-fg)] hover:scale-110 z-20\"\n          title={tScript('asset.removeFromClip')}\n        >\n          <AppIcon name=\"closeSm\" className=\"h-3 w-3\" />\n        </button>\n      )}\n      <div className=\"aspect-video relative rounded-t-xl overflow-hidden bg-[var(--glass-bg-muted)]\">\n        {imageUrl ? (\n          <MediaImageWithLoading\n            src={imageUrl}\n            alt={location.name}\n            containerClassName=\"w-full h-full\"\n            className=\"w-full h-full object-cover cursor-zoom-in\"\n            onClick={(e) => { e.stopPropagation(); setPreviewImage(imageUrl) }}\n          />\n        ) : (\n          <div className=\"w-full h-full flex flex-col items-center justify-center bg-[var(--glass-bg-surface-strong)] p-3\">\n            <div className=\"w-10 h-10 rounded-full bg-[var(--glass-bg-muted)] flex items-center justify-center mb-2\">\n              <AppIcon name=\"imagePreview\" className=\"w-5 h-5 text-[var(--glass-text-tertiary)]\" />\n            </div>\n            {onOpenAssetLibrary && (\n              <button\n                onClick={(e) => { e.stopPropagation(); onOpenAssetLibrary() }}\n                className=\"text-[11px] text-[var(--glass-text-secondary)] font-medium hover:text-[var(--glass-tone-info-fg)] transition-colors text-center leading-tight\"\n              >\n                {tScript('asset.generateLocation')}\n              </button>\n            )}\n          </div>\n        )}\n        {isActive && (\n          <div className=\"absolute top-2 right-2 w-2 h-2 bg-[var(--glass-tone-success-fg)] rounded-full shadow-[0_0_8px_rgba(74,222,128,0.8)] border border-white\" />\n        )}\n      </div>\n      <div className=\"p-2 text-center\">\n        <div className={`text-sm font-bold truncate ${isActive ? 'text-[var(--glass-text-primary)]' : 'text-[var(--glass-text-tertiary)]'}`}>\n          {location.name}\n        </div>\n      </div>\n      {previewImage && typeof document !== 'undefined' && createPortal(\n        <ImagePreviewModal imageUrl={previewImage} onClose={() => setPreviewImage(null)} />,\n        document.body\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/asset-state-utils.ts",
    "content": "import type { Character, CharacterAppearance, Location } from '@/types/project'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\n\ninterface ClipLike {\n  characters: string | null\n  location: string | null\n}\n\nexport function getPrimaryAppearance(char: Character): CharacterAppearance | undefined {\n  return char.appearances?.find((a) => a.appearanceIndex === PRIMARY_APPEARANCE_INDEX) || char.appearances?.[0]\n}\n\nexport function getSelectedAppearances(\n  char: Character,\n  selectedAppearanceKeys: Set<string>,\n): CharacterAppearance[] {\n  const result: CharacterAppearance[] = []\n  selectedAppearanceKeys.forEach((key) => {\n    if (key.startsWith(`${char.id}::`)) {\n      const appearanceName = key.split('::')[1]\n      const matched = char.appearances?.find(\n        (a) =>\n          a.changeReason === appearanceName ||\n          a.changeReason?.toLowerCase() === appearanceName.toLowerCase(),\n      )\n      if (matched) result.push(matched)\n    }\n  })\n\n  if (result.length === 0) {\n    const primary = getPrimaryAppearance(char)\n    if (primary) result.push(primary)\n  }\n  return result\n}\n\nexport function processCharacterInClip(params: {\n  clip: ClipLike\n  action: 'add' | 'remove'\n  targetChar: Character\n  appearanceName?: string\n  characters: Character[]\n  tAssets: (key: string) => string\n}): string | null {\n  const { clip, action, targetChar, appearanceName, characters, tAssets } = params\n  let currentItems: Array<string | { name: string; appearance?: string }> = []\n  try {\n    currentItems = JSON.parse(clip.characters || '[]')\n    if (!Array.isArray(currentItems)) throw new Error()\n  } catch {\n    currentItems = clip.characters\n      ? clip.characters.split(',').map((s) => s.trim()).filter(Boolean)\n      : []\n  }\n\n  const aliases = targetChar.name.split('/').map((a) => a.trim()).filter(Boolean)\n  const clipNameSet = new Set<string>()\n  currentItems.forEach((item) => {\n    if (typeof item === 'string') {\n      if (item.trim()) clipNameSet.add(item.trim())\n    } else if (item?.name) {\n      const n = String(item.name).trim()\n      if (n) clipNameSet.add(n)\n    }\n  })\n\n  const removeNameSet = new Set<string>()\n  if (clipNameSet.has(targetChar.name)) removeNameSet.add(targetChar.name)\n  aliases.forEach((a) => {\n    if (clipNameSet.has(a)) removeNameSet.add(a)\n  })\n  const nameMatches = (name: string) => removeNameSet.has(name) || name === targetChar.name\n  const primaryLabel = tAssets('character.primary')\n\n  const finalAppearanceName =\n    appearanceName ||\n    (targetChar.appearances?.find((appearance) => appearance.appearanceIndex === PRIMARY_APPEARANCE_INDEX)?.changeReason ||\n      tAssets('character.primary'))\n  const isPrimaryAppearance =\n    !appearanceName || appearanceName === primaryLabel\n\n  const hasSameAppearance = currentItems.some((item) => {\n    if (typeof item === 'string') {\n      return isPrimaryAppearance && nameMatches(item)\n    }\n    return nameMatches(item.name) && item.appearance === finalAppearanceName\n  })\n\n  const beforeLen = currentItems.length\n\n  if (action === 'add') {\n    if (!hasSameAppearance) {\n      currentItems.push({ name: targetChar.name, appearance: finalAppearanceName })\n    }\n  } else {\n    currentItems = currentItems.filter((item) => {\n      if (typeof item === 'string') {\n        return !nameMatches(item)\n      }\n      if (!nameMatches(item.name)) return true\n      if (!item.appearance) return !isPrimaryAppearance\n      if (item.appearance === finalAppearanceName) return false\n      if (\n        isPrimaryAppearance &&\n        item.appearance === primaryLabel\n      ) {\n        return false\n      }\n      return true\n    })\n\n    if (currentItems.length === beforeLen) {\n      const candidates = characters\n        .map((c) => {\n          const cAliases = [c.name, ...c.name.split('/').map((a) => a.trim()).filter(Boolean)]\n          if (!cAliases.includes(targetChar.name)) return null\n          const intersect = cAliases.filter((a) => clipNameSet.has(a))\n          if (intersect.length === 0) return null\n          return { intersect }\n        })\n        .filter(Boolean) as Array<{ intersect: string[] }>\n\n      if (candidates.length === 1) {\n        const fallbackRemoveSet = new Set(candidates[0].intersect)\n        currentItems = currentItems.filter((item) => {\n          if (typeof item === 'string') {\n            return !fallbackRemoveSet.has(item)\n          }\n          if (!fallbackRemoveSet.has(item.name)) return true\n          if (!item.appearance) return !isPrimaryAppearance\n          if (item.appearance === finalAppearanceName) return false\n          if (\n            isPrimaryAppearance &&\n            item.appearance === primaryLabel\n          ) {\n            return false\n          }\n          return true\n        })\n      }\n    }\n  }\n\n  const newValue = JSON.stringify(currentItems)\n  if (action === 'add' && hasSameAppearance) return null\n  if (action === 'remove' && currentItems.length === beforeLen) return null\n  return newValue\n}\n\nexport function processLocationInClip(params: {\n  clip: ClipLike\n  action: 'add' | 'remove'\n  targetLoc: Location\n  locationName?: string\n  fuzzyMatchLocation: (clipLocName: string, libraryLocName: string) => boolean\n}): string | null {\n  const { clip, action, targetLoc, locationName, fuzzyMatchLocation } = params\n  let currentNames: string[] = []\n  if (clip.location) {\n    try {\n      const parsed = JSON.parse(clip.location)\n      if (Array.isArray(parsed)) {\n        currentNames = parsed\n          .map((item) => (typeof item === 'string' ? item.trim() : ''))\n          .filter(Boolean)\n      } else {\n        currentNames = clip.location.split(',').map((s) => s.trim()).filter(Boolean)\n      }\n    } catch {\n      currentNames = clip.location.split(',').map((s) => s.trim()).filter(Boolean)\n    }\n  }\n\n  const beforeLen = currentNames.length\n  let newLocationNames: string[] = []\n\n  if (action === 'add') {\n    const finalLocationName = locationName?.trim() || targetLoc.name\n    if (!currentNames.some((n) => fuzzyMatchLocation(n, targetLoc.name))) {\n      newLocationNames = [...currentNames, finalLocationName]\n    } else {\n      return null\n    }\n  } else {\n    newLocationNames = currentNames.filter((n) => !fuzzyMatchLocation(n, targetLoc.name))\n    if (newLocationNames.length === beforeLen) return null\n  }\n\n  return newLocationNames.join(',')\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/clip-asset-utils.ts",
    "content": "type ClipAssetSource = {\n  characters?: string | null\n  location?: string | null\n}\n\nexport type ParsedClipAssets = {\n  charNames: Set<string>\n  locNames: Set<string>\n  charAppearanceSet: Set<string>\n}\n\nexport function fuzzyMatchLocation(clipLocName: string, libraryLocName: string): boolean {\n  const clipLower = clipLocName.toLowerCase().trim()\n  const libLower = libraryLocName.toLowerCase().trim()\n\n  if (clipLower === libLower) return true\n  if (clipLower.includes(libLower)) return true\n  if (libLower.includes(clipLower)) return true\n\n  const suffixPattern = /[_\\-·](内景|外景|白天|夜晚|黄昏|清晨|傍晚|雨天|晴天|阴天|室内|室外|日|夜|晨|昏)+$/gi\n  const clipClean = clipLower.replace(suffixPattern, '')\n  const libClean = libLower.replace(suffixPattern, '')\n  if (clipClean === libClean) return true\n  if (clipClean.includes(libClean) || libClean.includes(clipClean)) return true\n\n  return false\n}\n\nexport function parseClipAssets(clip: ClipAssetSource): ParsedClipAssets {\n  const charNames = new Set<string>()\n  const locNames = new Set<string>()\n  const charAppearanceSet = new Set<string>()\n\n  if (clip.characters) {\n    try {\n      const parsed = JSON.parse(clip.characters)\n      if (Array.isArray(parsed)) {\n        parsed.forEach((item) => {\n          const record =\n            item && typeof item === 'object'\n              ? (item as { name?: unknown; appearance?: unknown })\n              : null\n          const name = typeof item === 'string' ? item : record?.name\n          const appearance = typeof item === 'string' ? null : record?.appearance\n          if (name) {\n            const trimmed = String(name).trim()\n            if (trimmed) {\n              charNames.add(trimmed)\n              if (typeof appearance === 'string' && appearance.trim()) {\n                charAppearanceSet.add(`${trimmed}::${appearance}`)\n              }\n            }\n          }\n        })\n      }\n    } catch {\n      clip.characters.split(',').forEach(name => {\n        const trimmed = name.trim()\n        if (trimmed) charNames.add(trimmed)\n      })\n    }\n  }\n\n  if (clip.location) {\n    try {\n      const parsed = JSON.parse(clip.location)\n      if (Array.isArray(parsed)) {\n        parsed.forEach((loc: string) => locNames.add(loc.trim()))\n      } else {\n        clip.location.split(',').forEach(loc => {\n          const trimmed = loc.trim()\n          if (trimmed) locNames.add(trimmed)\n        })\n      }\n    } catch {\n      clip.location.split(',').forEach(loc => {\n        const trimmed = loc.trim()\n        if (trimmed) locNames.add(trimmed)\n      })\n    }\n  }\n\n  return { charNames, locNames, charAppearanceSet }\n}\n\nexport function getAllClipsAssets(clips: ClipAssetSource[]) {\n  const allCharNames = new Set<string>()\n  const allLocNames = new Set<string>()\n  const allCharAppearanceSet = new Set<string>()\n\n  clips.forEach((clip) => {\n    const { charNames, locNames, charAppearanceSet } = parseClipAssets(clip)\n    charNames.forEach(n => allCharNames.add(n))\n    locNames.forEach(n => allLocNames.add(n))\n    charAppearanceSet.forEach(k => allCharAppearanceSet.add(k))\n  })\n\n  return { allCharNames, allLocNames, allCharAppearanceSet }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/smart-import/hooks/useWizardState.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useState } from 'react'\nimport { logInfo as _ulogInfo, logWarn as _ulogWarn, logError as _ulogError } from '@/lib/logging/core'\nimport { detectEpisodeMarkers, type EpisodeMarkerResult } from '@/lib/episode-marker-detector'\nimport { countWords } from '@/lib/word-count'\nimport {\n  useListProjectEpisodes,\n  useSaveProjectEpisodesBatch,\n  useSplitProjectEpisodes,\n  useSplitProjectEpisodesByMarkers,\n} from '@/lib/query/hooks'\nimport type { DeleteConfirmState, SplitEpisode, WizardStage } from '../types'\n\ntype TranslateValues = Record<string, string | number | Date>\ntype Translate = (key: string, values?: TranslateValues) => string\n\ninterface UseWizardStateParams {\n  projectId: string\n  importStatus?: string | null\n  onImportComplete: (episodes: SplitEpisode[], triggerGlobalAnalysis?: boolean) => void\n  t: Translate\n}\n\nexport function useWizardState({ projectId, importStatus, onImportComplete, t }: UseWizardStateParams) {\n  const initialStage: WizardStage = importStatus === 'pending' ? 'preview' : 'select'\n  const [stage, setStage] = useState<WizardStage>(initialStage)\n  const [rawContent, setRawContent] = useState('')\n  const [episodes, setEpisodes] = useState<SplitEpisode[]>([])\n  const [selectedEpisode, setSelectedEpisode] = useState(0)\n  const [error, setError] = useState<string | null>(null)\n  const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState>({ show: false, index: -1, title: '' })\n  const [markerResult, setMarkerResult] = useState<EpisodeMarkerResult | null>(null)\n  const [showMarkerConfirm, setShowMarkerConfirm] = useState(false)\n  const [saving, setSaving] = useState(false)\n\n  const listProjectEpisodesMutation = useListProjectEpisodes(projectId)\n  const splitProjectEpisodesMutation = useSplitProjectEpisodes(projectId)\n  const splitProjectEpisodesByMarkersMutation = useSplitProjectEpisodesByMarkers(projectId)\n  const saveProjectEpisodesBatchMutation = useSaveProjectEpisodesBatch(projectId)\n\n  const loadSavedEpisodes = useCallback(async () => {\n    try {\n      const data = await listProjectEpisodesMutation.mutateAsync()\n      if (data.episodes && data.episodes.length > 0) {\n        const loadedEpisodes: SplitEpisode[] = data.episodes.map((ep: { episodeNumber?: number; name?: string; description?: string; novelText?: string }, idx: number) => ({\n          number: ep.episodeNumber || idx + 1,\n          title: ep.name || t('episode', { num: idx + 1 }),\n          summary: ep.description || '',\n          content: ep.novelText || '',\n          wordCount: countWords(ep.novelText || ''),\n        }))\n        setEpisodes(loadedEpisodes)\n        setStage('preview')\n      }\n    } catch (err) {\n      _ulogError('[SmartImport] 加载已保存剧集失败:', err)\n    }\n  }, [listProjectEpisodesMutation, t])\n\n  useEffect(() => {\n    if (importStatus === 'pending' && episodes.length === 0) {\n      void loadSavedEpisodes()\n    }\n  }, [episodes.length, importStatus, loadSavedEpisodes])\n\n  const performAISplit = useCallback(async () => {\n    setShowMarkerConfirm(false)\n    setStage('analyzing')\n    setError(null)\n\n    try {\n      _ulogInfo('[SmartImport] 开始调用 split API...')\n      const data = await splitProjectEpisodesMutation.mutateAsync({ content: rawContent, async: true })\n      const splitEpisodes = data.episodes || []\n      setEpisodes(splitEpisodes)\n\n      let saveSucceeded = true\n      try {\n        await saveProjectEpisodesBatchMutation.mutateAsync({\n          episodes: splitEpisodes.map((ep: SplitEpisode) => ({\n            name: ep.title,\n            description: ep.summary,\n            novelText: ep.content,\n          })),\n          clearExisting: true,\n          importStatus: 'pending',\n        })\n      } catch {\n        saveSucceeded = false\n        _ulogWarn('[SmartImport] 自动保存失败，继续显示预览')\n      }\n      if (saveSucceeded) {\n        _ulogInfo('[SmartImport] 剧集已自动保存到数据库，状态：pending')\n      }\n\n      setStage('preview')\n    } catch (err: unknown) {\n      const message = err instanceof Error ? err.message : t('errors.analyzeFailed')\n      setError(message || t('errors.analyzeFailed'))\n      setStage('select')\n    }\n  }, [rawContent, saveProjectEpisodesBatchMutation, splitProjectEpisodesMutation, t])\n\n  const handleAnalyze = useCallback(async () => {\n    _ulogInfo('[SmartImport] handleAnalyze 被调用')\n    _ulogInfo('[SmartImport] rawContent 长度:', rawContent.length)\n    _ulogInfo('[SmartImport] projectId:', projectId)\n\n    if (!rawContent.trim()) {\n      setError(t('errors.uploadFirst'))\n      return\n    }\n\n    const detection = detectEpisodeMarkers(rawContent)\n    _ulogInfo('[SmartImport] 标记检测结果:', {\n      hasMarkers: detection.hasMarkers,\n      markerType: detection.markerType,\n      confidence: detection.confidence,\n      matchCount: detection.matches.length,\n      previewSplitsCount: detection.previewSplits.length,\n    })\n\n    if (detection.hasMarkers) {\n      setMarkerResult(detection)\n      setShowMarkerConfirm(true)\n      return\n    }\n\n    _ulogInfo('[SmartImport] 未检测到标记，将使用 AI 分析')\n    await performAISplit()\n  }, [performAISplit, projectId, rawContent, t])\n\n  const handleMarkerSplit = useCallback(async () => {\n    if (!markerResult) return\n\n    setShowMarkerConfirm(false)\n    setStage('analyzing')\n    setError(null)\n\n    try {\n      const data = await splitProjectEpisodesByMarkersMutation.mutateAsync({ content: rawContent })\n      const splitEpisodes = data.episodes || []\n      setEpisodes(splitEpisodes)\n\n      let saveSucceeded = true\n      try {\n        await saveProjectEpisodesBatchMutation.mutateAsync({\n          episodes: splitEpisodes.map((ep: SplitEpisode) => ({\n            name: ep.title,\n            description: ep.summary,\n            novelText: ep.content,\n          })),\n          clearExisting: true,\n          importStatus: 'pending',\n        })\n      } catch {\n        saveSucceeded = false\n        _ulogWarn('[SmartImport] 标记分割保存失败，继续显示预览')\n      }\n      if (saveSucceeded) {\n        _ulogInfo('[SmartImport] 标记分割剧集已保存')\n      }\n\n      setStage('preview')\n    } catch (err: unknown) {\n      const message = err instanceof Error ? err.message : t('errors.analyzeFailed')\n      setError(message || t('errors.analyzeFailed'))\n      setStage('select')\n    }\n  }, [markerResult, rawContent, saveProjectEpisodesBatchMutation, splitProjectEpisodesByMarkersMutation, t])\n\n  const updateEpisodeTitle = useCallback((index: number, title: string) => {\n    setEpisodes((prev) => prev.map((ep, i) => (i === index ? { ...ep, title } : ep)))\n  }, [])\n\n  const updateEpisodeSummary = useCallback((index: number, summary: string) => {\n    setEpisodes((prev) => prev.map((ep, i) => (i === index ? { ...ep, summary } : ep)))\n  }, [])\n\n  const updateEpisodeNumber = useCallback((index: number, number: number) => {\n    setEpisodes((prev) => prev.map((ep, i) => (i === index ? { ...ep, number } : ep)))\n  }, [])\n\n  const updateEpisodeContent = useCallback((index: number, content: string) => {\n    setEpisodes((prev) => prev.map((ep, i) => (i === index ? { ...ep, content, wordCount: countWords(content) } : ep)))\n  }, [])\n\n  const deleteEpisode = useCallback((index: number) => {\n    setEpisodes((prev) => {\n      if (prev.length <= 1) return prev\n      const next = prev.filter((_, i) => i !== index)\n      setSelectedEpisode((current) => (current >= next.length ? Math.max(0, next.length - 1) : current))\n      return next\n    })\n  }, [])\n\n  const addEpisode = useCallback(() => {\n    setEpisodes((prev) => {\n      const newEpisode: SplitEpisode = {\n        number: prev.length + 1,\n        title: `${t('preview.newEpisode')} ${prev.length + 1}`,\n        summary: '',\n        content: '',\n        wordCount: 0,\n      }\n      const next = [...prev, newEpisode]\n      setSelectedEpisode(next.length - 1)\n      return next\n    })\n  }, [t])\n\n  const openDeleteConfirm = useCallback((index: number, title: string) => {\n    setDeleteConfirm({ show: true, index, title })\n  }, [])\n\n  const closeDeleteConfirm = useCallback(() => {\n    setDeleteConfirm({ show: false, index: -1, title: '' })\n  }, [])\n\n  const confirmDeleteEpisode = useCallback(() => {\n    if (deleteConfirm.index >= 0) {\n      deleteEpisode(deleteConfirm.index)\n    }\n    closeDeleteConfirm()\n  }, [closeDeleteConfirm, deleteConfirm.index, deleteEpisode])\n\n  const handleConfirm = useCallback(async (triggerGlobalAnalysis = false) => {\n    setSaving(true)\n    setError(null)\n\n    try {\n      await saveProjectEpisodesBatchMutation.mutateAsync({\n        episodes: episodes.map((ep) => ({\n          name: ep.title,\n          description: ep.summary,\n          novelText: ep.content,\n        })),\n        clearExisting: true,\n        importStatus: 'completed',\n        triggerGlobalAnalysis,\n      })\n\n      _ulogInfo('[SmartImport] 剧集已保存到数据库，状态：completed, 触发全局分析:', triggerGlobalAnalysis)\n      onImportComplete(episodes, triggerGlobalAnalysis)\n    } catch (err: unknown) {\n      _ulogError('[SmartImport] 保存失败:', err)\n      const message = err instanceof Error ? err.message : t('errors.saveFailed')\n      setError(message || t('errors.saveFailed'))\n    } finally {\n      setSaving(false)\n    }\n  }, [episodes, onImportComplete, saveProjectEpisodesBatchMutation, t])\n\n  return {\n    stage,\n    setStage,\n    rawContent,\n    setRawContent,\n    episodes,\n    selectedEpisode,\n    setSelectedEpisode,\n    error,\n    saving,\n    markerResult,\n    showMarkerConfirm,\n    deleteConfirm,\n    handleAnalyze,\n    performAISplit,\n    handleMarkerSplit,\n    setShowMarkerConfirm,\n    setMarkerResult,\n    updateEpisodeTitle,\n    updateEpisodeSummary,\n    updateEpisodeNumber,\n    updateEpisodeContent,\n    addEpisode,\n    openDeleteConfirm,\n    closeDeleteConfirm,\n    confirmDeleteEpisode,\n    handleConfirm,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/smart-import/steps/StepConfirm.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport type { SplitEpisode } from '../types'\n\ninterface StepConfirmProps {\n  episodes: SplitEpisode[]\n  saving: boolean\n  savingTaskState: TaskPresentationState | null\n  onReanalyze: () => void\n  onConfirm: () => void\n  onConfirmWithGlobalAnalysis: () => void\n}\n\nexport default function StepConfirm({\n  episodes,\n  saving,\n  savingTaskState,\n  onReanalyze,\n  onConfirm,\n  onConfirmWithGlobalAnalysis,\n}: StepConfirmProps) {\n  const t = useTranslations('smartImport')\n\n  return (\n    <div className=\"bg-[var(--glass-bg-surface)] rounded-2xl border border-[var(--glass-stroke-base)] p-6 mb-6\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h2 className=\"text-2xl font-semibold mb-2\">{t('preview.title')}</h2>\n          <p className=\"text-[var(--glass-text-secondary)]\">\n            {t('preview.episodeCount', { count: episodes.length })}，\n            {t('preview.totalWords', { count: episodes.reduce((sum, ep) => sum + ep.wordCount, 0).toLocaleString() })}\n            <span className=\"text-[var(--glass-tone-success-fg)] ml-2\">{t('preview.autoSaved')}</span>\n          </p>\n        </div>\n        <div className=\"flex gap-3\">\n          <button\n            onClick={onReanalyze}\n            className=\"px-5 py-2.5 border border-[var(--glass-stroke-strong)] rounded-lg font-medium hover:bg-[var(--glass-bg-muted)] transition-colors duration-200\"\n          >\n            {t('preview.reanalyze')}\n          </button>\n          <button\n            onClick={onConfirm}\n            disabled={saving}\n            className=\"px-5 py-2.5 bg-[var(--glass-accent-from)] text-white rounded-lg font-medium hover:bg-[var(--glass-accent-to)] transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n          >\n            {saving && <TaskStatusInline state={savingTaskState} className=\"text-white [&>span]:sr-only [&_svg]:text-white\" />}\n            {saving ? t('preview.saving') : t('preview.confirm')}\n          </button>\n          {episodes.length > 1 && (\n            <button\n              onClick={onConfirmWithGlobalAnalysis}\n              disabled={saving}\n              className=\"glass-btn-base glass-btn-primary px-5 py-2.5 rounded-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n            >\n              {saving && <TaskStatusInline state={savingTaskState} className=\"text-white [&>span]:sr-only [&_svg]:text-white\" />}\n              {t('globalAnalysis.confirmAndAnalyze')}\n            </button>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/smart-import/steps/StepMapping.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport type { DeleteConfirmState, SplitEpisode } from '../types'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface StepMappingProps {\n  episodes: SplitEpisode[]\n  selectedEpisode: number\n  onSelectEpisode: (index: number) => void\n  onUpdateEpisodeNumber: (index: number, number: number) => void\n  onUpdateEpisodeTitle: (index: number, title: string) => void\n  onUpdateEpisodeSummary: (index: number, summary: string) => void\n  onUpdateEpisodeContent: (index: number, content: string) => void\n  onAddEpisode: () => void\n  deleteConfirm: DeleteConfirmState\n  onOpenDeleteConfirm: (index: number, title: string) => void\n  onCloseDeleteConfirm: () => void\n  onConfirmDeleteEpisode: () => void\n}\n\nexport default function StepMapping({\n  episodes,\n  selectedEpisode,\n  onSelectEpisode,\n  onUpdateEpisodeNumber,\n  onUpdateEpisodeTitle,\n  onUpdateEpisodeSummary,\n  onUpdateEpisodeContent,\n  onAddEpisode,\n  deleteConfirm,\n  onOpenDeleteConfirm,\n  onCloseDeleteConfirm,\n  onConfirmDeleteEpisode,\n}: StepMappingProps) {\n  const t = useTranslations('smartImport')\n\n  return (\n    <>\n      {deleteConfirm.show && (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50\" onClick={onCloseDeleteConfirm}>\n          <div className=\"glass-surface-modal p-6 w-full max-w-sm\" onClick={e => e.stopPropagation()}>\n            <div className=\"text-center mb-6\">\n              <div className=\"w-12 h-12 bg-[var(--glass-tone-danger-bg)] rounded-full flex items-center justify-center mx-auto mb-4\">\n                <AppIcon name=\"trash\" className=\"w-6 h-6 text-[var(--glass-tone-danger-fg)]\" />\n              </div>\n              <h3 className=\"text-lg font-bold text-[var(--glass-text-primary)] mb-2\">{t('preview.deleteConfirm.title')}</h3>\n              <p className=\"text-[var(--glass-text-secondary)]\">{t('preview.deleteConfirm.message', { title: deleteConfirm.title })}</p>\n            </div>\n            <div className=\"flex gap-3\">\n              <button\n                onClick={onCloseDeleteConfirm}\n                className=\"flex-1 px-4 py-2.5 border border-[var(--glass-stroke-strong)] rounded-lg font-medium hover:bg-[var(--glass-bg-muted)] transition-colors\"\n              >\n                {t('preview.deleteConfirm.cancel')}\n              </button>\n              <button\n                onClick={onConfirmDeleteEpisode}\n                className=\"flex-1 px-4 py-2.5 bg-[var(--glass-tone-danger-fg)] text-white rounded-lg font-medium hover:bg-[var(--glass-tone-danger-fg)] transition-colors\"\n              >\n                {t('preview.deleteConfirm.confirm')}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      <div className=\"grid lg:grid-cols-3 gap-6\">\n        <div className=\"lg:col-span-1\">\n          <div className=\"bg-[var(--glass-bg-surface)] rounded-2xl border border-[var(--glass-stroke-base)] p-6 sticky top-6\">\n            <div className=\"flex items-center justify-between mb-4\">\n              <h3 className=\"font-semibold text-lg\">{t('preview.episodeList')}</h3>\n              <span className=\"text-sm text-[var(--glass-text-tertiary)]\">{episodes.length} {t('preview.episodeList')}</span>\n            </div>\n\n            <div className=\"space-y-3 max-h-[400px] overflow-y-auto\">\n              {episodes.map((ep, idx) => (\n                <div\n                  key={idx}\n                  onClick={() => onSelectEpisode(idx)}\n                  className={`p-4 rounded-xl transition-all duration-200 cursor-pointer relative group ${selectedEpisode === idx\n                    ? 'bg-[var(--glass-tone-info-bg)] border-2 border-[var(--glass-stroke-focus)]'\n                    : 'bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'\n                    }`}\n                >\n                  <div className=\"flex items-center justify-between mb-2\">\n                    <input\n                      type=\"text\"\n                      value={t('episode', { num: ep.number })}\n                      onChange={(e) => {\n                        const match = e.target.value.match(/\\d+/)\n                        const newNumber = match ? parseInt(match[0], 10) : ep.number\n                        if (newNumber !== ep.number) {\n                          onUpdateEpisodeNumber(idx, newNumber)\n                        }\n                      }}\n                      onClick={(e) => e.stopPropagation()}\n                      className={`font-semibold bg-transparent border-b border-transparent hover:border-[var(--glass-stroke-strong)] focus:border-[var(--glass-stroke-focus)] focus:outline-none w-24 ${selectedEpisode === idx ? 'text-[var(--glass-tone-info-fg)]' : 'text-[var(--glass-text-secondary)]'}`}\n                    />\n                    <div className=\"flex items-center gap-2\">\n                      <span className={`text-xs px-2 py-0.5 rounded-full ${selectedEpisode === idx ? 'bg-[var(--glass-accent-from)] text-white' : 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'\n                        }`}>\n                        {ep.wordCount.toLocaleString()} {t('upload.words')}\n                      </span>\n                      {episodes.length > 1 && (\n                        <button\n                          onClick={(e) => {\n                            e.stopPropagation()\n                            onOpenDeleteConfirm(idx, t('episode', { num: ep.number }))\n                          }}\n                          className=\"opacity-0 group-hover:opacity-100 p-1 text-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-bg)] rounded transition-all\"\n                          title={t('preview.deleteEpisode')}\n                        >\n                          <AppIcon name=\"trash\" className=\"w-4 h-4\" />\n                        </button>\n                      )}\n                    </div>\n                  </div>\n                  <input\n                    type=\"text\"\n                    value={ep.title}\n                    onChange={(e) => onUpdateEpisodeTitle(idx, e.target.value)}\n                    onClick={(e) => e.stopPropagation()}\n                    placeholder={t('preview.episodePlaceholder')}\n                    className=\"text-sm text-[var(--glass-text-secondary)] font-medium w-full bg-transparent border-b border-transparent hover:border-[var(--glass-stroke-strong)] focus:border-[var(--glass-stroke-focus)] focus:outline-none\"\n                  />\n                  <input\n                    type=\"text\"\n                    value={ep.summary}\n                    onChange={(e) => onUpdateEpisodeSummary(idx, e.target.value)}\n                    onClick={(e) => e.stopPropagation()}\n                    placeholder={t('preview.summaryPlaceholder')}\n                    className=\"text-xs text-[var(--glass-text-tertiary)] w-full bg-transparent border-b border-transparent hover:border-[var(--glass-stroke-strong)] focus:border-[var(--glass-stroke-focus)] focus:outline-none mt-1\"\n                  />\n                </div>\n              ))}\n            </div>\n\n            <button\n              onClick={onAddEpisode}\n              className=\"w-full mt-4 py-3 border-2 border-dashed border-[var(--glass-stroke-strong)] rounded-xl text-[var(--glass-text-tertiary)] hover:border-[var(--glass-stroke-focus)] hover:text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)] transition-all duration-200 flex items-center justify-center gap-2\"\n            >\n              <AppIcon name=\"plus\" className=\"w-5 h-5\" />\n              {t('preview.addEpisode')}\n            </button>\n\n            <div className=\"mt-4 pt-4 border-t border-[var(--glass-stroke-base)] space-y-2\">\n              <div className=\"flex justify-between text-sm\">\n                <span className=\"text-[var(--glass-text-secondary)]\">{t('preview.averageWords')}</span>\n                <span className=\"font-semibold\">\n                  {episodes.length > 0 ? Math.round(episodes.reduce((sum, ep) => sum + ep.wordCount, 0) / episodes.length).toLocaleString() : 0} {t('upload.words')}\n                </span>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"lg:col-span-2\">\n          {episodes[selectedEpisode] && (\n            <div className=\"bg-[var(--glass-bg-surface)] rounded-2xl border border-[var(--glass-stroke-base)] p-6\">\n              <div className=\"flex items-center justify-between mb-6\">\n                <div className=\"flex items-center gap-4\">\n                  <input\n                    type=\"text\"\n                    value={episodes[selectedEpisode].title}\n                    onChange={(e) => onUpdateEpisodeTitle(selectedEpisode, e.target.value)}\n                    className=\"text-2xl font-semibold border-b-2 border-transparent hover:border-[var(--glass-stroke-base)] focus:border-[var(--glass-stroke-focus)] focus:outline-none transition-colors duration-200 px-2\"\n                  />\n                  <span className=\"text-sm text-[var(--glass-text-tertiary)]\">{t('episode', { num: episodes[selectedEpisode].number })}</span>\n                </div>\n                <span className=\"text-sm text-[var(--glass-text-tertiary)]\">{episodes[selectedEpisode].wordCount.toLocaleString()} {t('upload.words')}</span>\n              </div>\n\n              <div>\n                <div className=\"flex items-center justify-between mb-3\">\n                  <label className=\"text-sm font-semibold text-[var(--glass-text-secondary)]\">{t('preview.episodeContent')}</label>\n                  <span className=\"text-sm text-[var(--glass-text-tertiary)]\">{episodes[selectedEpisode].wordCount.toLocaleString()} {t('upload.words')}</span>\n                </div>\n                <textarea\n                  rows={16}\n                  value={episodes[selectedEpisode].content}\n                  onChange={(e) => onUpdateEpisodeContent(selectedEpisode, e.target.value)}\n                  className=\"w-full border border-[var(--glass-stroke-strong)] rounded-xl p-4 focus:outline-none focus:ring-2 focus:ring-[var(--glass-focus-ring-strong)] focus:border-[var(--glass-stroke-focus)] resize-none font-mono text-sm leading-relaxed\"\n                />\n              </div>\n\n              <div className=\"mt-4 p-4 bg-[var(--glass-tone-info-bg)] border border-[var(--glass-stroke-focus)] rounded-xl\">\n                <div className=\"flex items-start gap-3\">\n                  <AppIcon name=\"info\" className=\"w-5 h-5 text-[var(--glass-tone-info-fg)] flex-shrink-0 mt-0.5\" />\n                  <div className=\"flex-1\">\n                    <p className=\"font-medium text-[var(--glass-text-primary)] mb-1\">{t('plotSummary')}</p>\n                    <p className=\"text-sm text-[var(--glass-text-primary)]\">\n                      {episodes[selectedEpisode].summary || t('preview.summaryPlaceholder')}\n                    </p>\n                  </div>\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/smart-import/steps/StepParse.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\n\nexport default function StepParse() {\n  const t = useTranslations('smartImport')\n\n  return (\n    <div className=\"min-h-[calc(100vh-200px)] flex items-center justify-center p-8\">\n      <div className=\"text-center\">\n        <div className=\"flex gap-1.5 justify-center mb-8\">\n          {[0, 1, 2, 3, 4].map((i) => (\n            <div\n              key={i}\n              className=\"w-3 h-12 bg-[var(--glass-accent-from)] rounded-full\"\n              style={{\n                animation: 'wave 1s ease-in-out infinite',\n                animationDelay: `${i * 0.1}s`,\n              }}\n            />\n          ))}\n        </div>\n        <h2 className=\"text-xl font-semibold text-[var(--glass-text-primary)] mb-2\">{t('analyzing.title')}</h2>\n        <p className=\"text-[var(--glass-text-secondary)]\">{t('analyzing.description')}</p>\n        <p className=\"text-sm text-[var(--glass-text-tertiary)] mt-2\">{t('analyzing.autoSave')}</p>\n\n        <style jsx>{`\n          @keyframes wave {\n            0%, 100% { transform: scaleY(0.4); }\n            50% { transform: scaleY(1); }\n          }\n        `}</style>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/smart-import/steps/StepSource.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { countWords } from '@/lib/word-count'\nimport type { EpisodeMarkerResult } from '@/lib/episode-marker-detector'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface StepSourceProps {\n  onManualCreate: () => void\n  rawContent: string\n  onRawContentChange: (content: string) => void\n  onAnalyze: () => void\n  error: string | null\n  showMarkerConfirm: boolean\n  markerResult: EpisodeMarkerResult | null\n  onCloseMarkerConfirm: () => void\n  onUseMarkerSplit: () => void\n  onUseAiSplit: () => void\n}\n\nexport default function StepSource({\n  onManualCreate,\n  rawContent,\n  onRawContentChange,\n  onAnalyze,\n  error,\n  showMarkerConfirm,\n  markerResult,\n  onCloseMarkerConfirm,\n  onUseMarkerSplit,\n  onUseAiSplit,\n}: StepSourceProps) {\n  const t = useTranslations('smartImport')\n\n  return (\n    <div className=\"min-h-[calc(100vh-200px)] flex items-center justify-center p-8\">\n      {showMarkerConfirm && markerResult && (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50\" onClick={onCloseMarkerConfirm}>\n          <div className=\"glass-surface-modal p-6 w-full max-w-lg animate-in fade-in zoom-in-95 duration-200\" onClick={e => e.stopPropagation()}>\n            <div className=\"text-center mb-6\">\n              <div className=\"w-14 h-14 bg-[var(--glass-tone-info-bg)] rounded-full flex items-center justify-center mx-auto mb-4\">\n                <AppIcon name=\"fileText\" className=\"w-7 h-7 text-[var(--glass-tone-info-fg)]\" />\n              </div>\n              <h3 className=\"text-xl font-bold text-[var(--glass-text-primary)] mb-2\">{t('markerDetected.title')}</h3>\n              <p className=\"text-[var(--glass-text-secondary)]\">\n                {t('markerDetected.description', {\n                  count: markerResult.matches.length,\n                  type: t(`markerDetected.markerTypes.${markerResult.markerTypeKey}` as 'numbered' | 'chapter' | 'custom'),\n                })}\n              </p>\n            </div>\n\n            <div className=\"mb-6\">\n              <p className=\"text-sm font-medium text-[var(--glass-text-tertiary)] mb-3\">{t('markerDetected.preview')}</p>\n              <div className=\"bg-[var(--glass-bg-muted)] rounded-xl p-4 max-h-64 overflow-y-auto space-y-2\">\n                {markerResult.previewSplits.map((split, idx) => (\n                  <div key={idx} className=\"flex items-start gap-3 text-sm\">\n                    <span className=\"flex-shrink-0 w-16 font-medium text-[var(--glass-tone-info-fg)]\">\n                      {t('episode', { num: split.number })}\n                    </span>\n                    <span className=\"text-[var(--glass-text-secondary)] truncate flex-1\">\n                      {split.preview || split.title}\n                    </span>\n                    <span className=\"flex-shrink-0 text-[var(--glass-text-tertiary)] text-xs\">\n                      ~{split.wordCount.toLocaleString()}{t('upload.words')}\n                    </span>\n                  </div>\n                ))}\n              </div>\n            </div>\n\n            <div className=\"grid grid-cols-2 gap-4 mb-4\">\n              <button\n                onClick={onUseMarkerSplit}\n                className=\"glass-btn-base glass-btn-primary py-4 px-3 rounded-xl font-bold transition-all flex flex-col items-center gap-1\"\n              >\n                <span>{t('markerDetected.useMarker')}</span>\n                <span className=\"text-xs font-normal opacity-80\">{t('markerDetected.useMarkerDesc')}</span>\n              </button>\n              <button\n                onClick={onUseAiSplit}\n                className=\"py-4 bg-[var(--glass-bg-surface)] border-2 border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] rounded-xl font-bold hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-all flex flex-col items-center gap-1\"\n              >\n                <span>{t('markerDetected.useAI')}</span>\n                <span className=\"text-xs font-normal text-[var(--glass-text-tertiary)]\">{t('markerDetected.useAIDesc')}</span>\n              </button>\n            </div>\n\n            <button\n              onClick={onCloseMarkerConfirm}\n              className=\"w-full py-2.5 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] font-medium transition-colors\"\n            >\n              {t('markerDetected.cancel')}\n            </button>\n          </div>\n        </div>\n      )}\n\n      <div className=\"max-w-5xl w-full\">\n        <div className=\"text-center mb-12 relative\">\n          <div className=\"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-[var(--glass-bg-surface)]/80 rounded-full blur-3xl -z-10\"></div>\n          <div className=\"inline-block relative\">\n            <h1 className=\"text-5xl md:text-6xl font-extrabold mb-6 tracking-tight\">\n              <span className=\"text-[var(--glass-tone-info-fg)]\">\n                {t('title')}\n              </span>\n            </h1>\n          </div>\n          <p className=\"text-[var(--glass-text-tertiary)] text-xl font-medium max-w-2xl mx-auto leading-relaxed\">\n            {t('subtitle')}\n          </p>\n        </div>\n\n        <div className=\"grid md:grid-cols-2 gap-8 items-stretch\">\n          <button\n            onClick={onManualCreate}\n            className=\"group bg-[var(--glass-bg-surface)] border-2 border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)] rounded-2xl p-8 text-left transition-all duration-200 hover:shadow-xl cursor-pointer flex flex-col justify-center\"\n          >\n            <div className=\"w-16 h-16 bg-[var(--glass-bg-muted)] rounded-2xl flex items-center justify-center mb-6 group-hover:bg-[var(--glass-tone-info-bg)] transition-colors duration-200\">\n              <AppIcon name=\"edit\" className=\"w-8 h-8 text-[var(--glass-text-secondary)] group-hover:text-[var(--glass-tone-info-fg)] transition-colors duration-200\" />\n            </div>\n            <h3 className=\"text-2xl font-bold mb-3 text-[var(--glass-text-primary)]\">{t('manualCreate.title')}</h3>\n            <p className=\"text-[var(--glass-text-tertiary)] mb-6 leading-relaxed\">{t('manualCreate.description')}</p>\n            <div className=\"flex items-center text-[var(--glass-tone-info-fg)] font-bold\">\n              <span>{t('manualCreate.button')}</span>\n              <AppIcon name=\"chevronRight\" className=\"w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform duration-200\" />\n            </div>\n          </button>\n\n          <div className=\"relative rounded-2xl border-2 border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] p-6 flex flex-col\">\n            <div className=\"flex items-center gap-3 mb-4\">\n              <div className=\"w-12 h-12 bg-[var(--glass-tone-info-bg)] rounded-xl flex items-center justify-center\">\n                <AppIcon name=\"bolt\" className=\"w-6 h-6 text-[var(--glass-tone-info-fg)]\" />\n              </div>\n              <div>\n                <h3 className=\"text-xl font-bold text-[var(--glass-text-primary)]\">{t('smartImport.title')}</h3>\n                <p className=\"text-sm text-[var(--glass-text-tertiary)]\">{t('smartImport.description')}</p>\n              </div>\n            </div>\n\n            <div className=\"flex-grow flex flex-col\">\n              <textarea\n                value={rawContent}\n                onChange={(e) => onRawContentChange(e.target.value)}\n                className=\"flex-grow w-full bg-[var(--glass-bg-muted)] border-2 border-[var(--glass-stroke-base)] rounded-xl p-4 text-sm text-[var(--glass-text-primary)] placeholder:text-[var(--glass-text-tertiary)] focus:bg-[var(--glass-bg-surface)] focus:border-[var(--glass-stroke-focus)] focus:ring-4 focus:ring-[var(--glass-tone-info-fg)]/10 outline-none transition-all resize-none leading-relaxed min-h-[180px]\"\n                placeholder={t('upload.placeholder')}\n              />\n\n              <div className=\"mt-4 flex items-center justify-between gap-6\">\n                <span className=\"text-sm text-[var(--glass-text-tertiary)] whitespace-nowrap\">\n                  {countWords(rawContent).toLocaleString()} {t('upload.words')} / 30,000\n                </span>\n                <button\n                  onClick={onAnalyze}\n                  disabled={!rawContent.trim() || rawContent.length < 100}\n                  className=\"glass-btn-base glass-btn-primary px-5 py-2 rounded-xl font-bold active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap\"\n                >\n                  <span>{t('upload.startAnalysis')}</span>\n                  <AppIcon name=\"arrowRightWide\" className=\"w-4 h-4\" />\n                </button>\n              </div>\n            </div>\n\n            {error && (\n              <div className=\"mt-4 p-3 bg-[var(--glass-tone-danger-bg)] border border-[var(--glass-stroke-danger)] rounded-lg text-[var(--glass-tone-danger-fg)] text-sm\">\n                {error}\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/smart-import/types.ts",
    "content": "export interface SplitEpisode {\n  number: number\n  title: string\n  summary: string\n  content: string\n  wordCount: number\n}\n\nexport type WizardStage = 'select' | 'analyzing' | 'preview'\n\nexport interface DeleteConfirmState {\n  show: boolean\n  index: number\n  title: string\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModal.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport AIDataModalFormPane from './AIDataModalFormPane'\nimport AIDataModalPreviewPane from './AIDataModalPreviewPane'\nimport type { AIDataModalProps } from './AIDataModal.types'\nimport { useAIDataModalState } from './hooks/useAIDataModalState'\nimport { AppIcon } from '@/components/ui/icons'\n\nexport type {\n  AIDataModalProps,\n  AIDataSavePayload,\n  PhotographyRules,\n  ActingCharacter,\n  ActingNotes,\n} from './AIDataModal.types'\n\nexport default function AIDataModal({\n  isOpen,\n  onClose,\n  syncKey,\n  panelNumber,\n  shotType: initialShotType,\n  cameraMove: initialCameraMove,\n  description: initialDescription,\n  location,\n  characters,\n  videoPrompt: initialVideoPrompt,\n  photographyRules: initialPhotographyRules,\n  actingNotes: initialActingNotes,\n  videoRatio,\n  onSave,\n}: AIDataModalProps) {\n  const t = useTranslations('storyboard')\n\n  const {\n    shotType,\n    setShotType,\n    cameraMove,\n    setCameraMove,\n    description,\n    setDescription,\n    videoPrompt,\n    setVideoPrompt,\n    photographyRules,\n    actingNotes,\n    updatePhotographyField,\n    updatePhotographyCharacter,\n    updateActingCharacter,\n    savePayload,\n  } = useAIDataModalState({\n    isOpen,\n    syncKey,\n    initialShotType,\n    initialCameraMove,\n    initialDescription,\n    initialVideoPrompt,\n    initialPhotographyRules,\n    initialActingNotes,\n  })\n\n  const handleSave = () => {\n    onSave(savePayload)\n    onClose()\n  }\n\n  const previewJson = {\n    aspect_ratio: videoRatio,\n    shot: {\n      shot_type: shotType,\n      camera_move: cameraMove,\n      description,\n      location,\n      characters,\n      prompt_text: `A ${videoRatio} shot: ${description}. ${videoPrompt}`,\n    },\n    ...(photographyRules ? { photography_rules: photographyRules } : {}),\n    ...(actingNotes.length > 0 ? { acting_notes: actingNotes } : {}),\n  }\n\n  if (!isOpen) return null\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center\">\n      <div className=\"absolute inset-0 bg-[var(--glass-overlay)] backdrop-blur-sm\" onClick={onClose} />\n\n      <div className=\"relative bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl w-[90vw] max-w-5xl max-h-[90vh] overflow-hidden flex flex-col\">\n        <div className=\"flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]\">\n          <div className=\"flex items-center gap-3\">\n            <span className=\"text-2xl\" />\n            <div>\n              <h2 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">{t('aiData.title')}</h2>\n              <p className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('aiData.subtitle', { number: panelNumber })}</p>\n            </div>\n          </div>\n          <button onClick={onClose} className=\"p-2 hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors\">\n            <AppIcon name=\"close\" className=\"w-5 h-5 text-[var(--glass-text-tertiary)]\" />\n          </button>\n        </div>\n\n        <div className=\"flex-1 overflow-hidden flex\">\n          <AIDataModalFormPane\n            t={(key) => t(key as never)}\n            shotType={shotType}\n            cameraMove={cameraMove}\n            description={description}\n            location={location}\n            characters={characters}\n            videoPrompt={videoPrompt}\n            photographyRules={photographyRules}\n            actingNotes={actingNotes}\n            onShotTypeChange={setShotType}\n            onCameraMoveChange={setCameraMove}\n            onDescriptionChange={setDescription}\n            onVideoPromptChange={setVideoPrompt}\n            onPhotographyFieldChange={updatePhotographyField}\n            onPhotographyCharacterChange={updatePhotographyCharacter}\n            onActingCharacterChange={updateActingCharacter}\n          />\n\n          <AIDataModalPreviewPane\n            t={(key) => t(key as never)}\n            previewJson={previewJson}\n          />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]\">\n          <button\n            onClick={onClose}\n            className=\"px-4 py-2 text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors\"\n          >\n            {t('candidate.cancel')}\n          </button>\n          <button\n            onClick={handleSave}\n            className=\"px-4 py-2 text-sm text-white bg-[var(--glass-accent-from)] hover:bg-[var(--glass-accent-to)] rounded-lg transition-colors flex items-center gap-2\"\n          >\n            <AppIcon name=\"check\" className=\"w-4 h-4\" />\n            {t('aiData.save')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModal.types.ts",
    "content": "'use client'\n\nexport interface PhotographyCharacter {\n  name: string\n  screen_position: string\n  posture: string\n  facing: string\n}\n\nexport interface PhotographyRules {\n  panel_number?: number\n  scene_summary: string\n  lighting: {\n    direction: string\n    quality: string\n  }\n  characters: PhotographyCharacter[]\n  depth_of_field: string\n  color_tone: string\n}\n\nexport interface ActingCharacter {\n  name: string\n  acting: string\n}\n\nexport interface ActingNotes {\n  panel_number?: number\n  characters: ActingCharacter[]\n}\n\nexport interface AIDataSavePayload {\n  shotType: string | null\n  cameraMove: string | null\n  description: string | null\n  videoPrompt: string | null\n  photographyRules: PhotographyRules | null\n  actingNotes: ActingCharacter[] | null\n}\n\nexport interface AIDataModalProps {\n  isOpen: boolean\n  onClose: () => void\n  syncKey?: string\n  panelNumber: number\n  shotType: string | null\n  cameraMove: string | null\n  description: string | null\n  location: string | null\n  characters: string[]\n  videoPrompt: string | null\n  photographyRules: PhotographyRules | null\n  actingNotes: ActingNotes | ActingCharacter[] | null\n  videoRatio: string\n  onSave: (data: AIDataSavePayload) => void\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModalFormPane.tsx",
    "content": "'use client'\n\nimport type {\n  ActingCharacter,\n  PhotographyCharacter,\n  PhotographyRules,\n} from './AIDataModal.types'\n\ninterface AIDataModalFormPaneProps {\n  t: (key: string) => string\n  shotType: string\n  cameraMove: string\n  description: string\n  location: string | null\n  characters: string[]\n  videoPrompt: string\n  photographyRules: PhotographyRules | null\n  actingNotes: ActingCharacter[]\n  onShotTypeChange: (value: string) => void\n  onCameraMoveChange: (value: string) => void\n  onDescriptionChange: (value: string) => void\n  onVideoPromptChange: (value: string) => void\n  onPhotographyFieldChange: (path: string, value: string) => void\n  onPhotographyCharacterChange: (index: number, field: keyof PhotographyCharacter, value: string) => void\n  onActingCharacterChange: (index: number, field: keyof ActingCharacter, value: string) => void\n}\n\nexport default function AIDataModalFormPane({\n  t,\n  shotType,\n  cameraMove,\n  description,\n  location,\n  characters,\n  videoPrompt,\n  photographyRules,\n  actingNotes,\n  onShotTypeChange,\n  onCameraMoveChange,\n  onDescriptionChange,\n  onVideoPromptChange,\n  onPhotographyFieldChange,\n  onPhotographyCharacterChange,\n  onActingCharacterChange,\n}: AIDataModalFormPaneProps) {\n  return (\n    <div className=\"w-1/2 border-r border-[var(--glass-stroke-base)] overflow-y-auto p-6 space-y-5\">\n      <div className=\"text-sm font-medium text-[var(--glass-text-secondary)] mb-3\">{t('aiData.basicData')}</div>\n\n      <div className=\"grid grid-cols-2 gap-4\">\n        <div>\n          <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.shotType')}</label>\n          <input\n            type=\"text\"\n            value={shotType}\n            onChange={(event) => onShotTypeChange(event.target.value)}\n            className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n            placeholder={t('aiData.shotTypePlaceholder')}\n          />\n        </div>\n        <div>\n          <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.cameraMove')}</label>\n          <input\n            type=\"text\"\n            value={cameraMove}\n            onChange={(event) => onCameraMoveChange(event.target.value)}\n            className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n            placeholder={t('aiData.cameraMovePlaceholder')}\n          />\n        </div>\n      </div>\n\n      <div className=\"grid grid-cols-2 gap-4\">\n        <div>\n          <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.scene')}</label>\n          <div className=\"px-3 py-2 bg-[var(--glass-bg-muted)] border border-[var(--glass-stroke-base)] rounded-lg text-sm text-[var(--glass-text-secondary)]\">\n            {location || t('aiData.notSelected')}\n          </div>\n        </div>\n        <div>\n          <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.characters')}</label>\n          <div className=\"px-3 py-2 bg-[var(--glass-bg-muted)] border border-[var(--glass-stroke-base)] rounded-lg text-sm text-[var(--glass-text-secondary)]\">\n            {characters.length > 0 ? characters.join('、') : t('common.none')}\n          </div>\n        </div>\n      </div>\n\n      <div>\n        <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.visualDescription')}</label>\n        <textarea\n          value={description}\n          onChange={(event) => onDescriptionChange(event.target.value)}\n          rows={3}\n          className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n          placeholder={t('insert.placeholder.description')}\n        />\n      </div>\n\n      <div>\n        <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.videoPrompt')}</label>\n        <textarea\n          value={videoPrompt}\n          onChange={(event) => onVideoPromptChange(event.target.value)}\n          rows={2}\n          className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-warning-bg)]\"\n          placeholder={t('panel.videoPromptPlaceholder')}\n        />\n      </div>\n\n      {photographyRules && (\n        <>\n          <div className=\"border-t border-[var(--glass-stroke-base)] pt-4 mt-4\">\n            <div className=\"text-sm font-medium text-[var(--glass-text-secondary)] mb-3\">{t('aiData.photographyRules')}</div>\n          </div>\n\n          <div>\n            <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.summary')}</label>\n            <input\n              type=\"text\"\n              value={photographyRules.scene_summary || ''}\n              onChange={(event) => onPhotographyFieldChange('scene_summary', event.target.value)}\n              className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]\"\n            />\n          </div>\n\n          <div className=\"grid grid-cols-2 gap-4\">\n            <div>\n              <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.lightingDirection')}</label>\n              <input\n                type=\"text\"\n                value={photographyRules.lighting?.direction || ''}\n                onChange={(event) => onPhotographyFieldChange('lighting.direction', event.target.value)}\n                className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]\"\n              />\n            </div>\n            <div>\n              <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.lightingQuality')}</label>\n              <input\n                type=\"text\"\n                value={photographyRules.lighting?.quality || ''}\n                onChange={(event) => onPhotographyFieldChange('lighting.quality', event.target.value)}\n                className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]\"\n              />\n            </div>\n          </div>\n\n          <div>\n            <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.depthOfField')}</label>\n            <input\n              type=\"text\"\n              value={photographyRules.depth_of_field || ''}\n              onChange={(event) => onPhotographyFieldChange('depth_of_field', event.target.value)}\n              className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]\"\n            />\n          </div>\n\n          <div>\n            <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-1\">{t('aiData.colorTone')}</label>\n            <input\n              type=\"text\"\n              value={photographyRules.color_tone || ''}\n              onChange={(event) => onPhotographyFieldChange('color_tone', event.target.value)}\n              className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]\"\n            />\n          </div>\n\n          {photographyRules.characters && photographyRules.characters.length > 0 && (\n            <div>\n              <label className=\"block text-xs font-medium text-[var(--glass-text-secondary)] mb-2\">{t('aiData.characterPosition')}</label>\n              <div className=\"space-y-3\">\n                {photographyRules.characters.map((character, index) => (\n                  <div key={index} className=\"p-3 bg-[var(--glass-bg-muted)] rounded-lg border border-[var(--glass-stroke-base)]\">\n                    <div className=\"text-xs font-medium text-[var(--glass-tone-info-fg)] mb-2\">{character.name}</div>\n                    <div className=\"grid grid-cols-3 gap-2\">\n                      <div>\n                        <label className=\"block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5\">{t('aiData.position')}</label>\n                        <input\n                          type=\"text\"\n                          value={character.screen_position || ''}\n                          onChange={(event) => onPhotographyCharacterChange(index, 'screen_position', event.target.value)}\n                          className=\"w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs\"\n                        />\n                      </div>\n                      <div>\n                        <label className=\"block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5\">{t('aiData.posture')}</label>\n                        <input\n                          type=\"text\"\n                          value={character.posture || ''}\n                          onChange={(event) => onPhotographyCharacterChange(index, 'posture', event.target.value)}\n                          className=\"w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs\"\n                        />\n                      </div>\n                      <div>\n                        <label className=\"block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5\">{t('aiData.facing')}</label>\n                        <input\n                          type=\"text\"\n                          value={character.facing || ''}\n                          onChange={(event) => onPhotographyCharacterChange(index, 'facing', event.target.value)}\n                          className=\"w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs\"\n                        />\n                      </div>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            </div>\n          )}\n        </>\n      )}\n\n      {actingNotes.length > 0 && (\n        <>\n          <div className=\"border-t border-[var(--glass-stroke-base)] pt-4 mt-4\">\n            <div className=\"text-sm font-medium text-[var(--glass-text-secondary)] mb-3\">{t('aiData.actingNotes')}</div>\n          </div>\n\n          <div className=\"space-y-3\">\n            {actingNotes.map((character, index) => (\n              <div key={index} className=\"p-3 bg-[var(--glass-tone-info-bg)] rounded-lg border border-[var(--glass-stroke-focus)]\">\n                <div className=\"text-xs font-medium text-[var(--glass-tone-info-fg)] mb-2\">{character.name}</div>\n                <div>\n                  <label className=\"block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5\">{t('aiData.actingDescription')}</label>\n                  <textarea\n                    value={character.acting || ''}\n                    onChange={(event) => onActingCharacterChange(index, 'acting', event.target.value)}\n                    rows={2}\n                    className=\"w-full px-2 py-1 border border-[var(--glass-stroke-focus)] rounded text-xs resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n                  />\n                </div>\n              </div>\n            ))}\n          </div>\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModalPreviewPane.tsx",
    "content": "'use client'\n\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface AIDataModalPreviewPaneProps {\n  t: (key: string) => string\n  previewJson: Record<string, unknown>\n}\n\nexport default function AIDataModalPreviewPane({\n  t,\n  previewJson,\n}: AIDataModalPreviewPaneProps) {\n  return (\n    <div className=\"w-1/2 bg-[var(--glass-text-primary)] overflow-y-auto p-4\">\n      <div className=\"flex items-center justify-between mb-3\">\n        <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('aiData.jsonPreview')}</span>\n        <button\n          onClick={() => {\n            const text = JSON.stringify(previewJson, null, 2)\n            if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {\n              navigator.clipboard.writeText(text).catch(() => { })\n            } else {\n              // HTTP 环境 fallback\n              const el = document.createElement('textarea')\n              el.value = text\n              el.style.position = 'fixed'\n              el.style.opacity = '0'\n              document.body.appendChild(el)\n              el.select()\n              document.execCommand('copy')\n              document.body.removeChild(el)\n            }\n          }}\n          className=\"text-xs text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-text-primary)] flex items-center gap-1\"\n        >\n          <AppIcon name=\"copy\" className=\"w-3.5 h-3.5\" />\n          {t('common.copy')}\n        </button>\n      </div>\n      <pre className=\"text-xs text-[var(--glass-tone-success-fg)] font-mono whitespace-pre-wrap break-all\">\n        {JSON.stringify(previewJson, null, 2)}\n      </pre>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/CandidateSelector.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\n\nimport { useState } from 'react'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface CandidateSelectorProps {\n  originalImageUrl: string | null\n  candidates: string[]\n  selectedIndex: number  // 0 = 原图, 1-n = 候选图\n  videoRatio: string  // 完整比例字符串，如 \"16:9\", \"3:2\" 等\n  onSelect: (index: number) => void\n  onConfirm: () => void\n  onCancel: () => void\n  onPreview: (imageUrl: string) => void\n  getImageUrl: (url: string | null) => string | null\n}\n\nexport default function CandidateSelector({\n  originalImageUrl,\n  candidates,\n  selectedIndex,\n  videoRatio,\n  onSelect,\n  onConfirm,\n  onCancel,\n  onPreview,\n  getImageUrl\n}: CandidateSelectorProps) {\n  const t = useTranslations('storyboard')\n  const [isConfirming, setIsConfirming] = useState(false)\n  const confirmingState = isConfirming\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'process',\n      resource: 'image',\n      hasOutput: true,\n    })\n    : null\n\n  // 根据比例计算缩略图尺寸（固定宽度 120px）\n  const [w, h] = videoRatio.split(':').map(Number)\n  const thumbWidth = 120\n  const thumbHeight = Math.round(thumbWidth * h / w)\n  return (\n    <div className=\"mb-4 p-4 glass-surface-soft border border-[var(--glass-stroke-focus)]\">\n      <div className=\"flex items-center justify-between mb-3\">\n        <div>\n          <h4 className=\"font-bold text-[var(--glass-text-primary)] text-sm\">{t('candidate.title')}</h4>\n          <p className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('image.clickToPreview')}</p>\n        </div>\n        <button\n          onClick={onCancel}\n          disabled={isConfirming}\n          className=\"text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] disabled:opacity-50 disabled:cursor-not-allowed\"\n        >\n          <AppIcon name=\"close\" className=\"w-5 h-5\" />\n        </button>\n      </div>\n\n      {/* 缩略图选择 - 横向排列 */}\n      <div className=\"flex gap-3 flex-wrap\">\n        {/* 原图 */}\n        <div className=\"flex flex-col items-center gap-1\">\n          <button\n            onClick={() => {\n              onSelect(0)\n              if (originalImageUrl) onPreview(getImageUrl(originalImageUrl)!)\n            }}\n            className={`relative rounded-lg overflow-hidden border-3 transition-all hover:scale-105 ${selectedIndex === 0\n              ? 'border-[var(--glass-stroke-focus)] ring-2 ring-[var(--glass-focus-ring)] shadow-lg'\n              : 'border-[var(--glass-stroke-strong)] hover:border-[var(--glass-stroke-focus)]'\n              }`}\n            style={{ width: `${thumbWidth}px`, height: `${thumbHeight}px` }}\n          >\n            {originalImageUrl ? (\n              <MediaImageWithLoading\n                src={getImageUrl(originalImageUrl)!}\n                alt={t('candidate.original')}\n                containerClassName=\"w-full h-full\"\n                className=\"w-full h-full object-cover\"\n              />\n            ) : (\n              <div className=\"w-full h-full bg-[var(--glass-bg-muted)] flex items-center justify-center text-[var(--glass-text-tertiary)] text-xs\">\n                {t('image.noValidCandidates')}\n              </div>\n            )}\n            {selectedIndex === 0 && (\n              <div className=\"absolute top-1 right-1 w-5 h-5 bg-[var(--glass-accent-from)] text-white rounded-full flex items-center justify-center shadow\">\n                <AppIcon name=\"checkSm\" className=\"w-3 h-3\" />\n              </div>\n            )}\n            {/* 放大图标 */}\n            <div className=\"absolute bottom-1 right-1 w-5 h-5 bg-[var(--glass-overlay)] text-white rounded flex items-center justify-center\">\n              <AppIcon name=\"searchPlus\" className=\"w-3 h-3\" />\n            </div>\n          </button>\n          <span className=\"text-xs text-[var(--glass-text-secondary)]\">{t('candidate.original')}</span>\n        </div>\n\n        {/* 候选图片 */}\n        {candidates.map((url, index) => (\n          <div key={index} className=\"flex flex-col items-center gap-1\">\n            <button\n              onClick={() => {\n                onSelect(index + 1)\n                onPreview(getImageUrl(url)!)\n              }}\n              className={`relative rounded-lg overflow-hidden border-3 transition-all hover:scale-105 ${selectedIndex === index + 1\n                ? 'border-[var(--glass-stroke-focus)] ring-2 ring-[var(--glass-focus-ring)] shadow-lg'\n                : 'border-[var(--glass-stroke-strong)] hover:border-[var(--glass-stroke-focus)]'\n                }`}\n              style={{ width: `${thumbWidth}px`, height: `${thumbHeight}px` }}\n            >\n              <MediaImageWithLoading\n                src={getImageUrl(url)!}\n                alt={`${t('image.candidateCount', { count: index + 1 })}`}\n                containerClassName=\"w-full h-full\"\n                className=\"w-full h-full object-cover\"\n              />\n              {selectedIndex === index + 1 && (\n                <div className=\"absolute top-1 right-1 w-5 h-5 bg-[var(--glass-accent-from)] text-white rounded-full flex items-center justify-center shadow\">\n                  <AppIcon name=\"checkSm\" className=\"w-3 h-3\" />\n                </div>\n              )}\n              {/* 放大图标 */}\n              <div className=\"absolute bottom-1 right-1 w-5 h-5 bg-[var(--glass-overlay)] text-white rounded flex items-center justify-center\">\n                <AppIcon name=\"searchPlus\" className=\"w-3 h-3\" />\n              </div>\n            </button>\n            <span className=\"text-xs text-[var(--glass-text-secondary)]\">{t('image.candidateCount', { count: index + 1 })}</span>\n          </div>\n        ))}\n      </div>\n\n      {/* 底部按钮 */}\n      <div className=\"mt-4 flex justify-between items-center\">\n        <span className=\"text-sm text-[var(--glass-text-secondary)] font-medium\">\n          {t('image.confirmCandidate')}: <span className=\"text-[var(--glass-tone-info-fg)]\">{selectedIndex === 0 ? t('candidate.original') : t('image.candidateCount', { count: selectedIndex })}</span>\n        </span>\n        <div className=\"flex gap-2\">\n          <button\n            onClick={onCancel}\n            disabled={isConfirming}\n            className=\"px-4 py-2 text-sm text-[var(--glass-text-secondary)] bg-[var(--glass-bg-muted)] rounded-lg hover:bg-[var(--glass-bg-muted)] transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            {t(\"candidate.cancel\")}\n          </button>\n          <button\n            onClick={() => {\n              setIsConfirming(true)\n              onConfirm()\n            }}\n            disabled={isConfirming}\n            className=\"px-5 py-2 text-sm bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 shadow-sm\"\n          >\n            {isConfirming ? (\n              <TaskStatusInline state={confirmingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n            ) : (\n              <>\n                <AppIcon name=\"check\" className=\"w-4 h-4\" />\n                {t('candidate.select')}\n              </>\n            )}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/ImageEditModal.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport { useState, useRef, useCallback } from 'react'\nimport { Character, Location } from '@/types/project'\nimport { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'\nimport { SelectedAsset } from './hooks/useImageGeneration'\nimport ImagePreviewModal from '@/components/ui/ImagePreviewModal'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport ImageEditModalSelectedAssets from './ImageEditModalSelectedAssets'\nimport ImageEditModalAssetPicker from './ImageEditModalAssetPicker'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ImageEditModalProps {\n  projectId: string\n  defaultAssets: SelectedAsset[]\n  onSubmit: (prompt: string, images: string[], assets: SelectedAsset[]) => void\n  onClose: () => void\n}\n\nexport default function ImageEditModal({\n  projectId,\n  defaultAssets,\n  onSubmit,\n  onClose,\n}: ImageEditModalProps) {\n  const t = useTranslations('storyboard')\n\n  const { data: assets } = useProjectAssets(projectId)\n  const characters: Character[] = assets?.characters ?? []\n  const locations: Location[] = assets?.locations ?? []\n\n  const [editPrompt, setEditPrompt] = useState('')\n  const [editImages, setEditImages] = useState<string[]>([])\n  const [selectedAssets, setSelectedAssets] = useState<SelectedAsset[]>(defaultAssets)\n  const [showAssetPicker, setShowAssetPicker] = useState(false)\n  const [previewImage, setPreviewImage] = useState<string | null>(null)\n  const fileInputRef = useRef<HTMLInputElement>(null)\n\n  const handleImageUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {\n    const files = event.target.files\n    if (!files) return\n\n    Array.from(files).forEach((file) => {\n      const reader = new FileReader()\n      reader.onload = (readerEvent) => {\n        const base64 = readerEvent.target?.result as string\n        setEditImages((previous) => [...previous, base64])\n      }\n      reader.readAsDataURL(file)\n    })\n\n    event.target.value = ''\n  }, [])\n\n  const handlePaste = useCallback((event: React.ClipboardEvent) => {\n    const items = event.clipboardData.items\n    for (let index = 0; index < items.length; index++) {\n      if (items[index].type.startsWith('image/')) {\n        const file = items[index].getAsFile()\n        if (file) {\n          const reader = new FileReader()\n          reader.onload = (readerEvent) => {\n            const base64 = readerEvent.target?.result as string\n            setEditImages((previous) => [...previous, base64])\n          }\n          reader.readAsDataURL(file)\n        }\n      }\n    }\n  }, [])\n\n  const removeImage = (index: number) => {\n    setEditImages((previous) => previous.filter((_, imageIndex) => imageIndex !== index))\n  }\n\n  const handleAddAsset = (asset: SelectedAsset) => {\n    setSelectedAssets((previous) => {\n      if (previous.some((item) => item.id === asset.id && item.type === asset.type)) return previous\n      return [...previous, asset]\n    })\n  }\n\n  const handleRemoveAsset = (assetId: string, assetType: string) => {\n    setSelectedAssets((previous) => previous.filter((item) => !(item.id === assetId && item.type === assetType)))\n  }\n\n  const handleSubmit = () => {\n    if (!editPrompt.trim()) {\n      alert(t('prompts.enterInstruction'))\n      return\n    }\n    onSubmit(editPrompt, editImages, selectedAssets)\n  }\n\n  return (\n    <div className=\"fixed inset-0 bg-[var(--glass-overlay)] z-50 flex items-center justify-center p-4\">\n      <div\n        className=\"bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto\"\n        onPaste={handlePaste}\n      >\n        <div className=\"p-6 border-b\">\n          <h3 className=\"text-lg font-bold text-[var(--glass-text-primary)]\">{t('imageEdit.title')}</h3>\n          <p className=\"text-sm text-[var(--glass-text-tertiary)] mt-1\">{t('imageEdit.subtitle')}</p>\n        </div>\n\n        <div className=\"p-6 space-y-4\">\n          <div>\n            <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">{t('prompts.aiInstruction')}</label>\n            <textarea\n              value={editPrompt}\n              onChange={(event) => setEditPrompt(event.target.value)}\n              placeholder={t('imageEdit.promptPlaceholder')}\n              className=\"w-full h-24 px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] resize-none\"\n              autoFocus\n            />\n          </div>\n\n          <ImageEditModalSelectedAssets\n            selectedAssets={selectedAssets}\n            onOpenAssetPicker={() => setShowAssetPicker(true)}\n            onPreviewImage={setPreviewImage}\n            onRemoveAsset={handleRemoveAsset}\n          />\n\n          <div>\n            <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">\n              {t('imageEdit.referenceImagesLabel')} <span className=\"text-[var(--glass-text-tertiary)] font-normal\">{t('imageEdit.referenceImagesHint')}</span>\n            </label>\n            <input\n              ref={fileInputRef}\n              type=\"file\"\n              accept=\"image/*\"\n              multiple\n              onChange={handleImageUpload}\n              className=\"hidden\"\n            />\n            <div className=\"flex flex-wrap gap-2\">\n              {editImages.map((image, index) => (\n                <div key={index} className=\"relative w-16 h-16\">\n                  <MediaImageWithLoading\n                    src={image}\n                    alt=\"\"\n                    containerClassName=\"w-full h-full rounded-lg\"\n                    className=\"w-full h-full object-cover rounded-lg\"\n                  />\n                  <button\n                    onClick={() => removeImage(index)}\n                    className=\"absolute -top-1 -right-1 w-5 h-5 bg-[var(--glass-tone-danger-fg)] text-white rounded-full text-xs flex items-center justify-center hover:bg-[var(--glass-tone-danger-fg)]\"\n                  >\n                    <AppIcon name=\"closeSm\" className=\"h-3 w-3\" />\n                  </button>\n                </div>\n              ))}\n              <button\n                onClick={() => fileInputRef.current?.click()}\n                className=\"w-16 h-16 border-2 border-dashed border-[var(--glass-stroke-strong)] rounded-lg flex items-center justify-center text-[var(--glass-text-tertiary)] hover:border-[var(--glass-stroke-focus)] hover:text-[var(--glass-tone-info-fg)] transition-colors\"\n              >\n                <AppIcon name=\"plus\" className=\"w-6 h-6\" />\n              </button>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"p-6 border-t flex justify-end gap-3\">\n          <button\n            onClick={onClose}\n            className=\"px-4 py-2 text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors\"\n          >\n            {t('candidate.cancel')}\n          </button>\n          <button\n            onClick={handleSubmit}\n            disabled={!editPrompt.trim()}\n            className=\"px-4 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n          >\n            {t('imageEdit.start')}\n          </button>\n        </div>\n      </div>\n\n      <ImageEditModalAssetPicker\n        isOpen={showAssetPicker}\n        characters={characters}\n        locations={locations}\n        selectedAssets={selectedAssets}\n        onClose={() => setShowAssetPicker(false)}\n        onAddAsset={handleAddAsset}\n        onRemoveAsset={handleRemoveAsset}\n        onPreviewImage={setPreviewImage}\n      />\n\n      {previewImage && (\n        <ImagePreviewModal\n          imageUrl={previewImage}\n          onClose={() => setPreviewImage(null)}\n        />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/ImageEditModalAssetPicker.tsx",
    "content": "'use client'\n\nimport type { Character, Location } from '@/types/project'\nimport { useTranslations } from 'next-intl'\nimport { toDisplayImageUrl } from '@/lib/media/image-url'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport type { SelectedAsset } from './hooks/useImageGeneration'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ImageEditModalAssetPickerProps {\n  isOpen: boolean\n  characters: Character[]\n  locations: Location[]\n  selectedAssets: SelectedAsset[]\n  onClose: () => void\n  onAddAsset: (asset: SelectedAsset) => void\n  onRemoveAsset: (assetId: string, assetType: string) => void\n  onPreviewImage: (url: string | null) => void\n}\n\nexport default function ImageEditModalAssetPicker({\n  isOpen,\n  characters,\n  locations,\n  selectedAssets,\n  onClose,\n  onAddAsset,\n  onRemoveAsset,\n  onPreviewImage,\n}: ImageEditModalAssetPickerProps) {\n  const t = useTranslations('storyboard')\n  if (!isOpen) return null\n\n  return (\n    <div className=\"fixed inset-0 glass-overlay z-[60] flex items-center justify-center p-4\">\n      <div className=\"glass-surface-modal w-full max-w-lg max-h-[80vh] overflow-hidden\">\n        <div className=\"p-4 border-b flex items-center justify-between\">\n          <h4 className=\"font-bold text-[var(--glass-text-primary)]\">{t('imageEdit.selectAsset')}</h4>\n          <button onClick={onClose} className=\"text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]\">\n            <AppIcon name=\"close\" className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        <div className=\"p-4 overflow-y-auto max-h-[60vh]\">\n          {characters.length > 0 && (\n            <div className=\"mb-4\">\n              <h5 className=\"text-sm font-medium text-[var(--glass-text-secondary)] mb-2 flex items-center gap-1.5\">\n                <AppIcon name=\"user\" className=\"h-4 w-4 text-[var(--glass-text-tertiary)]\" />\n                <span>{t('prompts.character')}</span>\n              </h5>\n\n              <div className=\"grid grid-cols-4 gap-2\">\n                {characters.map((character) => {\n                  const appearances = character.appearances || []\n                  const hasMultipleAppearances = appearances.length > 1\n                  return appearances.map((appearance) => {\n                    const isSelected = selectedAssets.some(\n                      (asset) =>\n                        asset.id === character.id &&\n                        asset.type === 'character' &&\n                        asset.appearanceId === appearance.appearanceIndex,\n                    )\n                    const displayName = hasMultipleAppearances\n                      ? `${character.name} - ${appearance.changeReason || t('panel.defaultAppearance')}`\n                      : character.name\n                    const displayImageUrl = toDisplayImageUrl(appearance.imageUrl)\n\n                    return (\n                      <button\n                        key={`${character.id}-${appearance.appearanceIndex}`}\n                        onClick={() => {\n                          if (isSelected) {\n                            onRemoveAsset(character.id, 'character')\n                          } else {\n                            onAddAsset({\n                              id: character.id,\n                              name: displayName,\n                              type: 'character',\n                              imageUrl: appearance.imageUrl,\n                              appearanceId: appearance.appearanceIndex,\n                              appearanceName: appearance.changeReason,\n                            })\n                          }\n                        }}\n                        className={`relative aspect-square rounded-lg overflow-hidden border-2 ${isSelected ? 'border-[var(--glass-stroke-focus)]' : 'border-transparent'}`}\n                      >\n                        {displayImageUrl ? (\n                          <MediaImageWithLoading\n                            src={displayImageUrl}\n                            alt={displayName}\n                            containerClassName=\"w-full h-full\"\n                            className=\"w-full h-full object-cover cursor-zoom-in\"\n                            onClick={(event) => {\n                              event.stopPropagation()\n                              onPreviewImage(appearance.imageUrl || null)\n                            }}\n                          />\n                        ) : (\n                          <div className=\"w-full h-full bg-[var(--glass-bg-muted)] flex items-center justify-center text-[var(--glass-text-tertiary)]\">\n                            <AppIcon name=\"user\" className=\"h-7 w-7\" />\n                          </div>\n                        )}\n                        <div className=\"absolute bottom-0 left-0 right-0 bg-[var(--glass-overlay)] text-white text-xs p-1 truncate\" title={displayName}>\n                          {displayName}\n                        </div>\n                        {isSelected && (\n                          <div className=\"absolute top-1 right-1 w-5 h-5 bg-[var(--glass-accent-from)] text-white rounded-full flex items-center justify-center\">\n                            <AppIcon name=\"checkXs\" className=\"h-3 w-3\" />\n                          </div>\n                        )}\n                      </button>\n                    )\n                  })\n                })}\n              </div>\n            </div>\n          )}\n\n          {locations.length > 0 && (\n            <div>\n              <h5 className=\"text-sm font-medium text-[var(--glass-text-secondary)] mb-2 flex items-center gap-1.5\">\n                <AppIcon name=\"imageAlt\" className=\"h-4 w-4 text-[var(--glass-text-tertiary)]\" />\n                <span>{t('prompts.location')}</span>\n              </h5>\n\n              <div className=\"grid grid-cols-4 gap-2\">\n                {locations.map((location) => {\n                  const isSelected = selectedAssets.some((asset) => asset.id === location.id && asset.type === 'location')\n                  const selectedImage = location.selectedImageId\n                    ? location.images?.find((image) => image.id === location.selectedImageId)\n                    : location.images?.find((image) => image.isSelected) || location.images?.find((image) => image.imageUrl) || location.images?.[0]\n                  const imageUrl = selectedImage?.imageUrl\n                  const displayImageUrl = toDisplayImageUrl(imageUrl || null)\n\n                  return (\n                    <button\n                      key={location.id}\n                      onClick={() => {\n                        if (isSelected) {\n                          onRemoveAsset(location.id, 'location')\n                        } else {\n                          onAddAsset({\n                            id: location.id,\n                            name: location.name,\n                            type: 'location',\n                            imageUrl: imageUrl ?? null,\n                          })\n                        }\n                      }}\n                      className={`relative aspect-[3/2] rounded-lg overflow-hidden border-2 ${isSelected ? 'border-[var(--glass-stroke-focus)]' : 'border-transparent'}`}\n                    >\n                      {displayImageUrl ? (\n                        <MediaImageWithLoading\n                          src={displayImageUrl}\n                          alt={location.name}\n                          containerClassName=\"w-full h-full\"\n                          className=\"w-full h-full object-cover cursor-zoom-in\"\n                          onClick={(event) => {\n                            event.stopPropagation()\n                            onPreviewImage(imageUrl || null)\n                          }}\n                        />\n                      ) : (\n                        <div className=\"w-full h-full bg-[var(--glass-bg-muted)] flex items-center justify-center text-[var(--glass-text-tertiary)]\">\n                          <AppIcon name=\"imageAlt\" className=\"h-7 w-7\" />\n                        </div>\n                      )}\n                      <div className=\"absolute bottom-0 left-0 right-0 bg-[var(--glass-overlay)] text-white text-xs p-1 truncate\">\n                        {location.name}\n                      </div>\n                      {isSelected && (\n                        <div className=\"absolute top-1 right-1 w-5 h-5 bg-[var(--glass-accent-from)] text-white rounded-full flex items-center justify-center\">\n                          <AppIcon name=\"checkXs\" className=\"h-3 w-3\" />\n                        </div>\n                      )}\n                    </button>\n                  )\n                })}\n              </div>\n            </div>\n          )}\n        </div>\n\n        <div className=\"p-4 border-t flex justify-end\">\n          <button\n            onClick={onClose}\n            className=\"px-4 py-2 bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)]\"\n          >\n            {t('common.confirm')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/ImageEditModalSelectedAssets.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { toDisplayImageUrl } from '@/lib/media/image-url'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport type { SelectedAsset } from './hooks/useImageGeneration'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ImageEditModalSelectedAssetsProps {\n  selectedAssets: SelectedAsset[]\n  onOpenAssetPicker: () => void\n  onPreviewImage: (url: string | null) => void\n  onRemoveAsset: (assetId: string, assetType: string) => void\n}\n\nexport default function ImageEditModalSelectedAssets({\n  selectedAssets,\n  onOpenAssetPicker,\n  onPreviewImage,\n  onRemoveAsset,\n}: ImageEditModalSelectedAssetsProps) {\n  const t = useTranslations('storyboard')\n  return (\n    <div>\n      <div className=\"flex items-center justify-between mb-2\">\n        <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)]\">\n          {t('imageEdit.selectedAssetsLabel')} <span className=\"text-[var(--glass-text-tertiary)] font-normal\">({t('imageEdit.selectedAssetsCount', { count: selectedAssets.length })})</span>\n        </label>\n        <button\n          onClick={onOpenAssetPicker}\n          className=\"text-sm text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-tone-info-fg)] flex items-center gap-1\"\n        >\n          <AppIcon name=\"plus\" className=\"w-4 h-4\" />\n          {t('imageEdit.addAsset')}\n        </button>\n      </div>\n\n      <div className=\"flex flex-wrap gap-2 min-h-[64px] p-2 bg-[var(--glass-bg-muted)] rounded-lg\">\n        {selectedAssets.length === 0 ? (\n          <p className=\"text-sm text-[var(--glass-text-tertiary)] w-full text-center py-4\">{t('imageEdit.noAssets')}</p>\n        ) : (\n          selectedAssets.map((asset) => {\n            const displayImageUrl = toDisplayImageUrl(asset.imageUrl)\n            return (\n              <div key={`${asset.type}-${asset.id}`} className=\"relative w-14 h-14 group\">\n                {displayImageUrl ? (\n                  <MediaImageWithLoading\n                    src={displayImageUrl}\n                    alt={asset.name}\n                    containerClassName=\"w-full h-full rounded-lg\"\n                    className=\"w-full h-full object-cover rounded-lg border cursor-zoom-in\"\n                    onClick={() => onPreviewImage(asset.imageUrl || null)}\n                  />\n                ) : (\n                  <div className=\"w-full h-full bg-[var(--glass-bg-muted)] rounded-lg flex items-center justify-center text-[var(--glass-text-tertiary)] text-xs\">\n                    {asset.type === 'character' ? (\n                      <AppIcon name=\"user\" className=\"h-4 w-4\" />\n                    ) : (\n                      <AppIcon name=\"imageAlt\" className=\"h-4 w-4\" />\n                    )}\n                  </div>\n                )}\n                <button\n                  onClick={(event) => {\n                    event.stopPropagation()\n                    onRemoveAsset(asset.id, asset.type)\n                  }}\n                  className=\"absolute -top-1 -right-1 w-5 h-5 bg-[var(--glass-tone-danger-fg)] text-white rounded-full text-xs flex items-center justify-center hover:bg-[var(--glass-tone-danger-fg)] opacity-0 group-hover:opacity-100 transition-opacity\"\n                >\n                  <AppIcon name=\"closeSm\" className=\"h-3 w-3\" />\n                </button>\n                <div className=\"absolute bottom-0 left-0 right-0 bg-[var(--glass-overlay)] text-white text-xs px-1 py-0.5 rounded-b-lg truncate\">\n                  {asset.name}\n                </div>\n              </div>\n            )\n          })\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/ImageSection.css",
    "content": "/* 重新生成卡片反馈动画 - 亮度提升效果 */\n\n@keyframes brightness-boost {\n\n    0%,\n    100% {\n        filter: brightness(1);\n    }\n\n    50% {\n        filter: brightness(1.08);\n    }\n}\n\n.animate-brightness-boost {\n    animation: brightness-boost 0.6s ease-out;\n}"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/ImageSection.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport { useState } from 'react'\nimport './ImageSection.css'\nimport { GlassButton } from '@/components/ui/primitives'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport TaskStatusOverlay from '@/components/task/TaskStatusOverlay'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport ImageSectionCandidateMode from './ImageSectionCandidateMode'\nimport ImageSectionActionButtons from './ImageSectionActionButtons'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface PanelCandidateData {\n  candidates: string[]\n  selectedIndex: number\n}\n\ninterface ImageSectionProps {\n  panelId: string\n  imageUrl: string | null\n  globalPanelNumber: number\n  shotType: string\n  videoRatio: string\n  isDeleting: boolean\n  isModifying: boolean\n  isSubmittingPanelImageTask: boolean\n  failedError: string | null\n  candidateData: PanelCandidateData | null\n  previousImageUrl?: string | null\n  onRegeneratePanelImage: (panelId: string, count?: number, force?: boolean) => void\n  onOpenEditModal: () => void\n  onOpenAIDataModal: () => void\n  onSelectCandidateIndex: (panelId: string, index: number) => void\n  onConfirmCandidate: (panelId: string, imageUrl: string) => Promise<void>\n  onCancelCandidate: (panelId: string) => void\n  onClearError: () => void\n  onUndo?: (panelId: string) => void\n  onPreviewImage?: (url: string) => void\n}\n\nexport default function ImageSection({\n  panelId,\n  imageUrl,\n  globalPanelNumber,\n  shotType,\n  videoRatio,\n  isDeleting,\n  isModifying,\n  isSubmittingPanelImageTask,\n  failedError,\n  candidateData,\n  previousImageUrl,\n  onRegeneratePanelImage,\n  onOpenEditModal,\n  onOpenAIDataModal,\n  onSelectCandidateIndex,\n  onConfirmCandidate,\n  onCancelCandidate,\n  onClearError,\n  onUndo,\n  onPreviewImage,\n}: ImageSectionProps) {\n  const t = useTranslations('storyboard')\n  const [isTaskPulseAnimating, setIsTaskPulseAnimating] = useState(false)\n  const cssAspectRatio = videoRatio.replace(':', '/')\n  const hasValidCandidates = !!candidateData && candidateData.candidates.some((url) => !url.startsWith('PENDING:'))\n\n  const triggerPulse = () => {\n    setIsTaskPulseAnimating(true)\n    setTimeout(() => setIsTaskPulseAnimating(false), 600)\n  }\n\n  const renderLoadingState = (\n    intent: 'generate' | 'regenerate' | 'modify' | 'process',\n    backdropImageUrl: string | null = null,\n  ) => {\n    const state = resolveTaskPresentationState({\n      phase: 'processing',\n      intent,\n      resource: 'image',\n      hasOutput: !!backdropImageUrl,\n    })\n\n    return (\n      <div className=\"relative flex h-full w-full items-center justify-center overflow-hidden bg-[var(--glass-bg-surface-modal)] backdrop-blur-md group/loading\">\n        {backdropImageUrl && (\n          <MediaImageWithLoading\n            src={backdropImageUrl}\n            alt={t('image.clickToPreview')}\n            containerClassName=\"absolute inset-0 h-full w-full\"\n            className=\"absolute inset-0 h-full w-full object-cover\"\n            sizes=\"(max-width: 768px) 100vw, 33vw\"\n          />\n        )}\n        <div className={`absolute inset-0 ${backdropImageUrl ? 'bg-black/45 backdrop-blur-[1px]' : 'bg-[var(--glass-bg-surface-modal)] backdrop-blur-md'}`} />\n        <TaskStatusOverlay\n          state={state}\n          className={backdropImageUrl ? 'bg-black/45 backdrop-blur-[1px]' : undefined}\n        />\n      </div>\n    )\n  }\n\n  const renderFailedState = () => (\n    <div className=\"flex h-full w-full flex-col items-center justify-center gap-1 bg-[var(--glass-danger-ring)] text-[var(--glass-tone-danger-fg)] p-2\">\n      <AppIcon name=\"alert\" className=\"w-6 h-6 mb-1\" />\n      <span className=\"text-xs text-center font-medium\">{t('image.failed')}</span>\n      <span className=\"text-[10px] text-center mt-1 line-clamp-2 px-1\">{failedError}</span>\n      <button\n        onClick={onClearError}\n        className=\"glass-btn-base glass-btn-tone-danger mt-1 px-2 py-1 text-[10px] rounded-md\"\n      >\n        {t('variant.close')}\n      </button>\n    </div>\n  )\n\n  const renderEmptyState = () => (\n    <div className=\"flex h-full w-full flex-col items-center justify-center gap-2 bg-[var(--glass-bg-surface-strong)] text-[var(--glass-text-tertiary)]\">\n      <AppIcon name=\"imagePreview\" className=\"w-8 h-8\" />\n      <span className=\"text-xs\">{t('video.toolbar.showPending')}</span>\n      <GlassButton\n        variant=\"primary\"\n        size=\"sm\"\n        onClick={() => {\n          triggerPulse()\n          onRegeneratePanelImage(panelId, 1, false)\n        }}\n      >\n        {t('panel.generateImage')}\n      </GlassButton>\n    </div>\n  )\n\n  return (\n    <div\n      className={`relative overflow-hidden group rounded-t-2xl transition-all bg-[var(--glass-bg-muted)] ${isTaskPulseAnimating ? 'animate-brightness-boost' : ''}`}\n      style={{ aspectRatio: cssAspectRatio }}\n    >\n      {isDeleting ? (\n        renderLoadingState('process', imageUrl)\n      ) : isModifying ? (\n        renderLoadingState('modify', imageUrl)\n      ) : isSubmittingPanelImageTask ? (\n        renderLoadingState('regenerate', imageUrl)\n      ) : candidateData ? (\n        hasValidCandidates ? (\n          <ImageSectionCandidateMode\n            panelId={panelId}\n            imageUrl={imageUrl}\n            candidateData={candidateData}\n            onSelectCandidateIndex={onSelectCandidateIndex}\n            onConfirmCandidate={onConfirmCandidate}\n            onCancelCandidate={onCancelCandidate}\n            onPreviewImage={onPreviewImage}\n          />\n        ) : (\n          renderLoadingState(imageUrl ? 'regenerate' : 'generate', imageUrl)\n        )\n      ) : failedError ? (\n        renderFailedState()\n      ) : imageUrl ? (\n        <MediaImageWithLoading\n          src={imageUrl}\n          alt={t('variant.shotNum', { number: globalPanelNumber })}\n          containerClassName=\"h-full w-full\"\n          className={`w-full h-full object-cover ${onPreviewImage ? 'cursor-zoom-in' : ''}`}\n          onClick={onPreviewImage ? () => onPreviewImage(imageUrl) : undefined}\n          title={onPreviewImage ? t('image.clickToPreview') : undefined}\n          sizes=\"(max-width: 768px) 100vw, 33vw\"\n        />\n      ) : (\n        renderEmptyState()\n      )}\n\n      <div className=\"absolute top-2 left-2\">\n        <span className=\"glass-chip glass-chip-neutral px-2 py-0.5 text-xs font-medium\">{globalPanelNumber}</span>\n      </div>\n\n      <div className=\"absolute top-2 right-2\">\n        <span className=\"glass-chip glass-chip-info px-2 py-0.5 text-xs\">{shotType}</span>\n      </div>\n\n      {!candidateData && (\n        <ImageSectionActionButtons\n          panelId={panelId}\n          imageUrl={imageUrl}\n          previousImageUrl={previousImageUrl}\n          isSubmittingPanelImageTask={isSubmittingPanelImageTask}\n          isModifying={isModifying}\n          onRegeneratePanelImage={onRegeneratePanelImage}\n          onOpenEditModal={onOpenEditModal}\n          onOpenAIDataModal={onOpenAIDataModal}\n          onUndo={onUndo}\n          triggerPulse={triggerPulse}\n        />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/ImageSectionActionButtons.tsx",
    "content": "'use client'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\nimport { getImageGenerationCountOptions } from '@/lib/image-generation/count'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\nimport { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'\nimport AISparklesIcon from '@/components/ui/icons/AISparklesIcon'\n\ninterface ImageSectionActionButtonsProps {\n  panelId: string\n  imageUrl: string | null\n  previousImageUrl?: string | null\n  isSubmittingPanelImageTask: boolean\n  isModifying: boolean\n  onRegeneratePanelImage: (panelId: string, count?: number, force?: boolean) => void\n  onOpenEditModal: () => void\n  onOpenAIDataModal: () => void\n  onUndo?: (panelId: string) => void\n  triggerPulse: () => void\n}\n\nexport default function ImageSectionActionButtons({\n  panelId,\n  imageUrl,\n  previousImageUrl,\n  isSubmittingPanelImageTask,\n  isModifying,\n  onRegeneratePanelImage,\n  onOpenEditModal,\n  onOpenAIDataModal,\n  onUndo,\n  triggerPulse,\n}: ImageSectionActionButtonsProps) {\n  const t = useTranslations('storyboard')\n  const { count, setCount } = useImageGenerationCount('storyboard-candidates')\n\n  return (\n    <>\n      <div className={`absolute bottom-1.5 left-1/2 -translate-x-1/2 z-20 transition-opacity ${isSubmittingPanelImageTask ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>\n        <div className=\"relative glass-surface-modal border border-[var(--glass-stroke-base)] rounded-lg p-0.5\">\n          <div className=\"flex items-center gap-0.5\">\n            <ImageGenerationInlineCountButton\n              prefix={\n                <>\n                  <AppIcon name=\"refresh\" className=\"w-2.5 h-2.5\" />\n                  <span>{isSubmittingPanelImageTask ? t('image.forceRegenerate') : t('panel.regenerate')}</span>\n                </>\n              }\n              suffix={<span>{t('image.generateCountSuffix')}</span>}\n              value={count}\n              options={getImageGenerationCountOptions('storyboard-candidates')}\n              onValueChange={setCount}\n              onClick={() => {\n                _ulogInfo('[ImageSection] 🔄 左下角重新生成按钮被点击')\n                _ulogInfo('[ImageSection] isSubmittingPanelImageTask:', isSubmittingPanelImageTask)\n                _ulogInfo('[ImageSection] 将传递 force:', isSubmittingPanelImageTask)\n                triggerPulse()\n                onRegeneratePanelImage(panelId, count, isSubmittingPanelImageTask)\n              }}\n              disabled={false}\n              ariaLabel={t('image.selectCount')}\n              className={`glass-btn-base glass-btn-secondary flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] transition-all active:scale-95 ${isSubmittingPanelImageTask ? 'opacity-75' : ''}`}\n              selectClassName=\"appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-text-primary)] outline-none cursor-pointer leading-none transition-colors\"\n              labelClassName=\"inline-flex items-center gap-0.5\"\n            />\n\n            <div className=\"w-px h-3 bg-[var(--glass-stroke-base)]\" />\n\n            <button\n              onClick={onOpenAIDataModal}\n              className={`glass-btn-base glass-btn-secondary flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[10px] transition-all active:scale-95 ${isSubmittingPanelImageTask || isModifying ? 'opacity-75' : ''}`}\n              title={t('aiData.viewData')}\n            >\n              <AppIcon name=\"chart\" className=\"w-2.5 h-2.5\" />\n              <span>{t('aiData.viewData')}</span>\n            </button>\n            {imageUrl && (\n              <button\n                onClick={onOpenEditModal}\n                className={`glass-btn-base h-6 w-6 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS} ${isSubmittingPanelImageTask || isModifying ? 'opacity-75' : ''}`}\n                title={t('image.editImage')}\n              >\n                <AISparklesIcon className={`w-2.5 h-2.5 ${AI_EDIT_ICON_CLASS}`} />\n              </button>\n            )}\n\n            {previousImageUrl && onUndo && (\n              <>\n                <div className=\"w-px h-3 bg-[var(--glass-stroke-base)]\" />\n                <button\n                  onClick={() => onUndo(panelId)}\n                  disabled={isSubmittingPanelImageTask}\n                  className=\"glass-btn-base glass-btn-secondary flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[10px] transition-all active:scale-95 disabled:opacity-50\"\n                  title={t('assets.image.undo')}\n                >\n                  <span>{t('assets.image.undo')}</span>\n                </button>\n              </>\n            )}\n          </div>\n        </div>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/ImageSectionCandidateMode.tsx",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface PanelCandidateData {\n  candidates: string[]\n  selectedIndex: number\n}\n\ninterface ImageSectionCandidateModeProps {\n  panelId: string\n  imageUrl: string | null\n  candidateData: PanelCandidateData\n  onSelectCandidateIndex: (panelId: string, index: number) => void\n  onConfirmCandidate: (panelId: string, imageUrl: string) => Promise<void>\n  onCancelCandidate: (panelId: string) => void\n  onPreviewImage?: (url: string) => void\n}\n\nexport default function ImageSectionCandidateMode({\n  panelId,\n  imageUrl,\n  candidateData,\n  onSelectCandidateIndex,\n  onConfirmCandidate,\n  onCancelCandidate,\n  onPreviewImage,\n}: ImageSectionCandidateModeProps) {\n  const t = useTranslations('storyboard')\n  const [isConfirming, setIsConfirming] = useState(false)\n\n  const validCandidates = candidateData.candidates.filter((url) => !url.startsWith('PENDING:'))\n  if (validCandidates.length === 0) {\n    return null\n  }\n\n  const safeSelectedIndex = Math.min(candidateData.selectedIndex, validCandidates.length - 1)\n  const confirmingState = isConfirming\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'process',\n      resource: 'image',\n      hasOutput: !!imageUrl,\n    })\n    : null\n\n  return (\n    <div className=\"w-full h-full relative\">\n      <MediaImageWithLoading\n        src={validCandidates[safeSelectedIndex]}\n        alt={t('image.candidateCount', { count: safeSelectedIndex + 1 })}\n        containerClassName=\"h-full w-full\"\n        className=\"w-full h-full object-cover cursor-pointer\"\n        onClick={() => onPreviewImage?.(validCandidates[safeSelectedIndex])}\n        title={t('image.clickToPreview')}\n        sizes=\"(max-width: 768px) 100vw, 33vw\"\n      />\n\n      <div className=\"absolute bottom-2 left-2 right-2 glass-surface-soft border border-[var(--glass-stroke-base)] p-2 rounded-xl\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex gap-1\">\n            {validCandidates.map((url, idx) => (\n              <div key={idx} className=\"relative group/thumb\">\n                <button\n                  onClick={() => onSelectCandidateIndex(panelId, idx)}\n                  className={`w-8 h-8 rounded border-2 overflow-hidden ${idx === safeSelectedIndex\n                    ? 'border-[var(--glass-accent-from)]'\n                    : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'\n                    }`}\n                >\n                  <MediaImageWithLoading\n                    src={url}\n                    alt={t('image.candidateCount', { count: idx + 1 })}\n                    containerClassName=\"h-full w-full\"\n                    className=\"w-full h-full object-cover\"\n                  />\n                </button>\n                {onPreviewImage && (\n                  <button\n                    onClick={(event) => {\n                      event.stopPropagation()\n                      onPreviewImage(url)\n                    }}\n                    className=\"absolute -top-1 -right-1 w-4 h-4 glass-btn-base glass-btn-soft text-[var(--glass-text-primary)] rounded-full flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity\"\n                    title={t('image.enlargePreview')}\n                  >\n                    <AppIcon name=\"searchPlus\" className=\"w-2.5 h-2.5\" />\n                  </button>\n                )}\n              </div>\n            ))}\n          </div>\n\n          <div className=\"flex gap-1\">\n            <button\n              onClick={() => onCancelCandidate(panelId)}\n              disabled={isConfirming}\n              className=\"glass-btn-base glass-btn-secondary px-2 py-1 text-xs rounded disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n              取消候选\n            </button>\n            <button\n              onClick={async () => {\n                _ulogInfo('[ImageSection] 🎯 确认按钮被点击')\n                _ulogInfo('[ImageSection] panelId:', panelId)\n                _ulogInfo('[ImageSection] 选中的图片索引:', safeSelectedIndex)\n                _ulogInfo('[ImageSection] 选中的图片 URL:', validCandidates[safeSelectedIndex])\n                setIsConfirming(true)\n                try {\n                  await onConfirmCandidate(panelId, validCandidates[safeSelectedIndex])\n                  _ulogInfo('[ImageSection] ✅ 确认操作完成')\n                } catch (error) {\n                  _ulogError('[ImageSection] ❌ 确认操作失败:', error)\n                  setIsConfirming(false)\n                  _ulogInfo('[ImageSection] isConfirming 状态已重置为 false (失败重试)')\n                }\n              }}\n              disabled={isConfirming}\n              className=\"glass-btn-base glass-btn-primary flex items-center gap-1 rounded px-2 py-1 text-xs disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n              {isConfirming ? (\n                <TaskStatusInline state={confirmingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n              ) : (\n                t('common.confirm')\n              )}\n            </button>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"glass-chip glass-chip-success absolute top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 text-xs\">\n        {t('image.candidateCount', { count: safeSelectedIndex + 1 })}/{validCandidates.length}\n        {candidateData.candidates.length > validCandidates.length &&\n          ` (${t('image.candidateGenerating', { count: candidateData.candidates.length - validCandidates.length })})`}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/InsertPanelButton.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\n/**\n * InsertPanelButton - 面板间插入按钮\n * 在两个 PanelCard 之间显示一个 + 号按钮\n */\n\ninterface InsertPanelButtonProps {\n    onClick: () => void\n    disabled?: boolean\n}\n\nexport default function InsertPanelButton({ onClick, disabled }: InsertPanelButtonProps) {\n    const t = useTranslations('storyboard')\n    return (\n        <button\n            onClick={onClick}\n            disabled={disabled}\n            className={`\n                group relative h-7 w-7 rounded-full\n                glass-btn-base border border-[var(--glass-stroke-base)]\n                bg-[var(--glass-bg-surface)] text-[var(--glass-text-secondary)]\n                shadow-[var(--glass-shadow-sm)] transition-all duration-200 ease-out\n                flex items-center justify-center\n                ${disabled\n                    ? 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)] cursor-not-allowed'\n                    : 'hover:-translate-y-0.5 hover:shadow-[var(--glass-shadow-md)] hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)]'\n                }\n            `}\n            title={t('panelActions.insertHere')}\n        >\n            <AppIcon name=\"plus\" className=\"w-4 h-4\" />\n\n            {/* Hover 时显示提示 */}\n            <span className={`\n                absolute -top-8 left-1/2 -translate-x-1/2\n                px-2 py-1 text-xs text-white bg-[var(--glass-overlay)] rounded\n                opacity-0 group-hover:opacity-100\n                transition-opacity duration-200\n                whitespace-nowrap pointer-events-none\n                ${disabled ? 'hidden' : ''}\n            `}>\n                {t('panelActions.insertPanel')}\n            </span>\n        </button>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/InsertPanelModal.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\n\nimport { useState, useEffect } from 'react'\nimport { createPortal } from 'react-dom'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { AppIcon } from '@/components/ui/icons'\n\n/**\n * InsertPanelModal - 插入分镜模态框\n * 使用 Portal 渲染到 document.body，确保在用户屏幕中央显示\n */\n\ninterface PanelInfo {\n    id: string\n    panelNumber: number | null\n    description: string | null\n    imageUrl: string | null\n}\n\ninterface InsertPanelModalProps {\n    isOpen: boolean\n    onClose: () => void\n    prevPanel: PanelInfo\n    nextPanel: PanelInfo | null\n    onInsert: (userInput: string) => Promise<void>\n    isInserting: boolean\n}\n\nexport default function InsertPanelModal({\n    isOpen,\n    onClose,\n    prevPanel,\n    nextPanel,\n    onInsert,\n    isInserting\n}: InsertPanelModalProps) {\n    const t = useTranslations('storyboard')\n    const [userInput, setUserInput] = useState('')\n    const [mounted, setMounted] = useState(false)\n\n    useEffect(() => {\n        setMounted(true)\n    }, [])\n\n    const analyzingState = isInserting\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'analyze',\n            resource: 'text',\n            hasOutput: true,\n        })\n        : null\n    const insertingState = isInserting\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'build',\n            resource: 'text',\n            hasOutput: true,\n        })\n        : null\n\n    if (!isOpen || !mounted) return null\n\n    const handleInsert = async () => {\n        await onInsert(userInput)\n        setUserInput('')\n    }\n\n    const handleAutoAnalyze = async () => {\n        await onInsert('')\n        setUserInput('')\n    }\n\n    const handleClose = () => {\n        if (!isInserting) {\n            setUserInput('')\n            onClose()\n        }\n    }\n\n    const modalContent = (\n        <div\n            className=\"fixed inset-0 glass-overlay flex items-center justify-center p-4\"\n            style={{ zIndex: 9999 }}\n            onClick={handleClose}\n        >\n            <div\n                className=\"glass-surface-modal w-full max-w-lg\"\n                onClick={(e) => e.stopPropagation()}\n            >\n                {/* 标题 */}\n                <div className=\"px-5 py-3 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-t-2xl\">\n                    <div className=\"flex items-center justify-between\">\n                        <h2 className=\"text-base font-bold text-[var(--glass-text-primary)] flex items-center gap-2\">\n                            <span className=\"inline-flex h-6 w-6 items-center justify-center rounded-lg bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] text-sm font-bold\">+</span>\n                            {t('insertModal.insertBetween', { before: prevPanel.panelNumber ?? 0, after: nextPanel?.panelNumber ?? '' })}\n                        </h2>\n                        <button\n                            onClick={handleClose}\n                            disabled={isInserting}\n                            className=\"text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] disabled:opacity-50\"\n                        >\n                            <AppIcon name=\"close\" className=\"w-5 h-5\" />\n                        </button>\n                    </div>\n                </div>\n\n                {/* 内容 */}\n                <div className=\"p-5 space-y-4\">\n                    {/* 前后镜头预览 - 更紧凑 */}\n                    <div className=\"flex gap-3 items-center\">\n                        {/* 前一个镜头 */}\n                        <div className=\"flex-1 bg-[var(--glass-bg-muted)] rounded-lg p-2 text-center\">\n                            {prevPanel.imageUrl ? (\n                                <MediaImageWithLoading\n                                    src={prevPanel.imageUrl}\n                                    alt={`${t('insertModal.panel')} ${prevPanel.panelNumber}`}\n                                    containerClassName=\"w-full aspect-[9/16] rounded-md\"\n                                    className=\"w-full aspect-[9/16] object-cover rounded-md\"\n                                />\n                            ) : (\n                                <div className=\"w-full aspect-[9/16] bg-[var(--glass-bg-muted)] rounded-md flex items-center justify-center text-[var(--glass-text-tertiary)] text-xs\">\n                                    {t('insertModal.noImage')}\n                                </div>\n                            )}\n                            <div className=\"text-xs text-[var(--glass-text-tertiary)] mt-1\">#{prevPanel.panelNumber}</div>\n                        </div>\n\n                        {/* 插入指示 */}\n                        <div className=\"flex flex-col items-center\">\n                            <div className=\"w-10 h-10 rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] flex items-center justify-center text-xl font-bold\">\n                                +\n                            </div>\n                        </div>\n\n                        {/* 后一个镜头 */}\n                        <div className=\"flex-1 bg-[var(--glass-bg-muted)] rounded-lg p-2 text-center\">\n                            {nextPanel ? (\n                                <>\n                                    {nextPanel.imageUrl ? (\n                                        <MediaImageWithLoading\n                                            src={nextPanel.imageUrl}\n                                            alt={`${t('insertModal.panel')} ${nextPanel.panelNumber}`}\n                                            containerClassName=\"w-full aspect-[9/16] rounded-md\"\n                                            className=\"w-full aspect-[9/16] object-cover rounded-md\"\n                                        />\n                                    ) : (\n                                        <div className=\"w-full aspect-[9/16] bg-[var(--glass-bg-muted)] rounded-md flex items-center justify-center text-[var(--glass-text-tertiary)] text-xs\">\n                                            {t('insertModal.noImage')}\n                                        </div>\n                                    )}\n                                    <div className=\"text-xs text-[var(--glass-text-tertiary)] mt-1\">#{nextPanel.panelNumber}</div>\n                                </>\n                            ) : (\n                                <>\n                                    <div className=\"w-full aspect-[9/16] bg-[var(--glass-bg-muted)] rounded-md flex items-center justify-center text-[var(--glass-text-tertiary)] text-xs\">\n                                        {t('insertModal.insertAtEnd')}\n                                    </div>\n                                    <div className=\"text-xs text-[var(--glass-text-tertiary)] mt-1\">{t('insertModal.insert')}</div>\n                                </>\n                            )}\n                        </div>\n                    </div>\n\n                    {/* 用户输入 */}\n                    <div>\n                        <textarea\n                            value={userInput}\n                            onChange={(e) => setUserInput(e.target.value)}\n                            placeholder={t('insertModal.placeholder')}\n                            className=\"w-full h-16 px-3 py-2 border border-[var(--glass-stroke-base)] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] text-sm\"\n                            disabled={isInserting}\n                        />\n                    </div>\n\n                    {/* 操作按钮 */}\n                    <div className=\"flex gap-3\">\n                        <button\n                            onClick={handleAutoAnalyze}\n                            disabled={isInserting}\n                            className={`flex-1 py-2.5 rounded-lg font-medium text-sm flex items-center justify-center gap-2 transition-all\n                                ${isInserting ? 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)]' : 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'}`}\n                        >\n                            {isInserting && !userInput ? (\n                                <TaskStatusInline state={analyzingState} />\n                            ) : (\n                                <>{t('insertModal.aiAnalyze')}</>\n                            )}\n                        </button>\n\n                        <button\n                            onClick={handleInsert}\n                            disabled={isInserting || !userInput.trim()}\n                            className={`flex-1 py-2.5 rounded-lg font-medium text-sm flex items-center justify-center gap-2 transition-all\n                                ${isInserting || !userInput.trim() ? 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)]' : 'bg-[var(--glass-accent-from)] text-white hover:bg-[var(--glass-accent-to)] shadow-[var(--glass-shadow-md)]'}`}\n                        >\n                            {isInserting && userInput ? (\n                                <TaskStatusInline state={insertingState} />\n                            ) : (\n                                <>{t('insertModal.insert')}</>\n                            )}\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    )\n\n    // 使用 Portal 渲染到 document.body\n    return createPortal(modalContent, document.body)\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/PanelActionButtons.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\n\n/**\n * PanelActionButtons - 面板间操作按钮组\n * 包含两个按钮：\n * - + 插入分镜（原有功能）\n * - 镜头变体（新功能）\n */\n\ninterface PanelActionButtonsProps {\n    onInsertPanel: () => void\n    onVariant: () => void\n    disabled?: boolean\n    hasImage: boolean // 原镜头是否有图片（没图片不能做变体）\n}\n\nexport default function PanelActionButtons({\n    onInsertPanel,\n    onVariant,\n    disabled,\n    hasImage\n}: PanelActionButtonsProps) {\n    const t = useTranslations('storyboard')\n    const baseButtonClass = `\n        group relative h-7 w-7 rounded-full\n        glass-btn-base border border-[var(--glass-stroke-base)]\n        bg-[var(--glass-bg-surface)] text-[var(--glass-text-secondary)]\n        shadow-[var(--glass-shadow-sm)] transition-all duration-200 ease-out\n        flex items-center justify-center\n    `\n    const enabledButtonClass = `\n        hover:-translate-y-0.5 hover:shadow-[var(--glass-shadow-md)]\n        hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)]\n    `\n    const disabledButtonClass = `\n        bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)] cursor-not-allowed\n    `\n\n    return (\n        <div className=\"flex flex-col items-center gap-1\">\n            {/* 插入分镜按钮 */}\n            <button\n                onClick={onInsertPanel}\n                disabled={disabled}\n                className={`\n                    ${baseButtonClass}\n                    ${disabled ? disabledButtonClass : enabledButtonClass}\n                `}\n                title={t('panelActions.insertHere')}\n            >\n                <AppIcon name=\"plus\" className=\"w-4 h-4\" />\n\n                {/* Hover 时显示提示 */}\n                <span className={`\n                    absolute -top-8 left-1/2 -translate-x-1/2\n                    px-2 py-1 text-xs text-white bg-[var(--glass-overlay)] rounded\n                    opacity-0 group-hover:opacity-100\n                    transition-opacity duration-200\n                    whitespace-nowrap pointer-events-none\n                    ${disabled ? 'hidden' : ''}\n                `}>\n                    {t('panelActions.insertPanel')}\n                </span>\n            </button>\n\n            {/* 镜头变体按钮 */}\n            <button\n                onClick={onVariant}\n                disabled={disabled || !hasImage}\n                className={`\n                    ${baseButtonClass}\n                    ${disabled || !hasImage ? disabledButtonClass : enabledButtonClass}\n                `}\n                title={hasImage ? t('panelActions.generateVariant') : t('panelActions.needImage')}\n            >\n                <AppIcon name=\"videoAlt\" className=\"w-4 h-4\" />\n\n                {/* Hover 时显示提示 */}\n                <span className={`\n                    absolute -top-8 left-1/2 -translate-x-1/2\n                    px-2 py-1 text-xs text-white bg-[var(--glass-overlay)] rounded\n                    opacity-0 group-hover:opacity-100\n                    transition-opacity duration-200\n                    whitespace-nowrap pointer-events-none\n                    ${disabled || !hasImage ? 'hidden' : ''}\n                `}>\n                    {t('panelActions.panelVariant')}\n                </span>\n            </button>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/PanelCard.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport PanelEditForm, { PanelEditData } from '../PanelEditForm'\nimport ImageSection from './ImageSection'\nimport PanelActionButtons from './PanelActionButtons'\nimport { StoryboardPanel } from './hooks/useStoryboardState'\nimport { GlassSurface } from '@/components/ui/primitives'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface PanelCandidateData {\n  candidates: string[]\n  selectedIndex: number\n}\n\ninterface PanelCardProps {\n  panel: StoryboardPanel\n  panelData: PanelEditData\n  imageUrl: string | null\n  globalPanelNumber: number\n  storyboardId: string\n  videoRatio: string\n  isSaving: boolean\n  hasUnsavedChanges?: boolean\n  saveErrorMessage?: string | null\n  isDeleting: boolean\n  isModifying: boolean\n  isSubmittingPanelImageTask: boolean\n  failedError: string | null\n  candidateData: PanelCandidateData | null\n  previousImageUrl?: string | null  // 支持撤回\n  onUpdate: (updates: Partial<PanelEditData>) => void\n  onDelete: () => void\n  onOpenCharacterPicker: () => void\n  onOpenLocationPicker: () => void\n  onRetrySave?: () => void\n  onRemoveCharacter: (index: number) => void\n  onRemoveLocation: () => void\n  onRegeneratePanelImage: (panelId: string, count?: number, force?: boolean) => void\n  onOpenEditModal: () => void\n  onOpenAIDataModal: () => void\n  onSelectCandidateIndex: (panelId: string, index: number) => void\n  onConfirmCandidate: (panelId: string, imageUrl: string) => Promise<void>\n  onCancelCandidate: (panelId: string) => void\n  onClearError: () => void\n  onUndo?: (panelId: string) => void  // 撤回到上一版本\n  onPreviewImage?: (url: string) => void  // 放大预览图片\n  onInsertAfter?: () => void  // 在此镜头后插入\n  onVariant?: () => void  // 生成镜头变体\n  isInsertDisabled?: boolean  // 插入按钮是否禁用\n}\n\nexport default function PanelCard({\n  panel,\n  panelData,\n  imageUrl,\n  globalPanelNumber,\n  storyboardId,\n  videoRatio,\n  isSaving,\n  hasUnsavedChanges = false,\n  saveErrorMessage = null,\n  isDeleting,\n  isModifying,\n  isSubmittingPanelImageTask,\n  failedError,\n  candidateData,\n  previousImageUrl,\n  onUpdate,\n  onDelete,\n  onOpenCharacterPicker,\n  onOpenLocationPicker,\n  onRetrySave,\n  onRemoveCharacter,\n  onRemoveLocation,\n  onRegeneratePanelImage,\n  onOpenEditModal,\n  onOpenAIDataModal,\n  onSelectCandidateIndex,\n  onConfirmCandidate,\n  onCancelCandidate,\n  onClearError,\n  onUndo,\n  onPreviewImage,\n  onInsertAfter,\n  onVariant,\n  isInsertDisabled\n}: PanelCardProps) {\n  const t = useTranslations('storyboard')\n  return (\n    <GlassSurface\n      variant=\"elevated\"\n      padded={false}\n      className=\"relative h-full overflow-visible transition-all hover:shadow-[var(--glass-shadow-md)] group/card\"\n      data-storyboard-id={storyboardId}\n    >\n      {/* 删除按钮 - 右上角外部 */}\n      {!isModifying && !isDeleting && (\n        <button\n          onClick={onDelete}\n          className=\"absolute -top-2 -right-2 z-10 opacity-0 group-hover/card:opacity-100 transition-opacity bg-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-fg)] text-white w-5 h-5 rounded-full flex items-center justify-center text-xs shadow-md\"\n          title={t('panelActions.deleteShot')}\n        >\n          <AppIcon name=\"closeMd\" className=\"h-3 w-3\" />\n        </button>\n      )}\n\n      {/* 镜头图片区域 - 包含插入按钮 */}\n      <div className=\"relative\">\n        <ImageSection\n          panelId={panel.id}\n          imageUrl={imageUrl}\n          globalPanelNumber={globalPanelNumber}\n          shotType={panel.shot_type}\n          videoRatio={videoRatio}\n          isDeleting={isDeleting}\n          isModifying={isModifying}\n          isSubmittingPanelImageTask={isSubmittingPanelImageTask}\n          failedError={failedError}\n          candidateData={candidateData}\n          previousImageUrl={previousImageUrl}\n          onRegeneratePanelImage={onRegeneratePanelImage}\n          onOpenEditModal={onOpenEditModal}\n          onOpenAIDataModal={onOpenAIDataModal}\n          onSelectCandidateIndex={onSelectCandidateIndex}\n          onConfirmCandidate={onConfirmCandidate}\n          onCancelCandidate={onCancelCandidate}\n          onClearError={onClearError}\n          onUndo={onUndo}\n          onPreviewImage={onPreviewImage}\n        />\n        {/* 插入分镜/镜头变体按钮 - 在图片区域右侧垂直居中 */}\n        {(onInsertAfter || onVariant) && (\n          <div className=\"absolute -right-[22px] top-1/2 -translate-y-1/2 z-50\">\n            <PanelActionButtons\n              onInsertPanel={onInsertAfter || (() => { })}\n              onVariant={onVariant || (() => { })}\n              disabled={isInsertDisabled}\n              hasImage={!!imageUrl}\n            />\n          </div>\n        )}\n      </div>\n\n      {/* 分镜信息编辑区 */}\n      <div className=\"p-3\">\n        <PanelEditForm\n          panelData={panelData}\n          isSaving={isSaving}\n          saveStatus={hasUnsavedChanges ? 'error' : (isSaving ? 'saving' : 'idle')}\n          saveErrorMessage={saveErrorMessage}\n          onRetrySave={onRetrySave}\n          onUpdate={onUpdate}\n          onOpenCharacterPicker={onOpenCharacterPicker}\n          onOpenLocationPicker={onOpenLocationPicker}\n          onRemoveCharacter={onRemoveCharacter}\n          onRemoveLocation={onRemoveLocation}\n        />\n      </div>\n    </GlassSurface>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/PanelVariantModal.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport { useState, useEffect, useCallback, useRef } from 'react'\nimport { createPortal } from 'react-dom'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { useAnalyzeProjectShotVariants } from '@/lib/query/hooks'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport type { PanelInfo, ShotVariantSuggestion } from './PanelVariantModal.types'\nimport PanelVariantModalSuggestionList from './PanelVariantModalSuggestionList'\nimport PanelVariantModalCustomOptions from './PanelVariantModalCustomOptions'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface PanelVariantModalProps {\n  isOpen: boolean\n  onClose: () => void\n  panel: PanelInfo\n  projectId: string\n  onVariant: (\n    variant: Omit<ShotVariantSuggestion, 'id' | 'creative_score'>,\n    options: { includeCharacterAssets: boolean; includeLocationAsset: boolean },\n  ) => Promise<void>\n  isSubmittingVariantTask: boolean\n}\n\nexport default function PanelVariantModal({\n  isOpen,\n  onClose,\n  panel,\n  projectId,\n  onVariant,\n  isSubmittingVariantTask,\n}: PanelVariantModalProps) {\n  const t = useTranslations('storyboard')\n  const [mounted, setMounted] = useState(false)\n  const [isAnalyzing, setIsAnalyzing] = useState(false)\n  const [suggestions, setSuggestions] = useState<ShotVariantSuggestion[]>([])\n  const [error, setError] = useState<string | null>(null)\n  const [customInput, setCustomInput] = useState('')\n  const [includeCharacterAssets, setIncludeCharacterAssets] = useState(true)\n  const [includeLocationAsset, setIncludeLocationAsset] = useState(true)\n  const [selectedVariantId, setSelectedVariantId] = useState<number | null>(null)\n  const autoAnalyzeKeyRef = useRef<string | null>(null)\n  const analyzingRef = useRef(false)\n  const analyzeShotVariantsMutation = useAnalyzeProjectShotVariants(projectId)\n\n  useEffect(() => {\n    setMounted(true)\n  }, [])\n\n  const analyzeShotVariants = useCallback(async () => {\n    if (analyzingRef.current) return\n    analyzingRef.current = true\n    setIsAnalyzing(true)\n    setError(null)\n    setSuggestions([])\n\n    try {\n      const data = await analyzeShotVariantsMutation.mutateAsync({ panelId: panel.id })\n      setSuggestions(data.suggestions || [])\n    } catch (err: unknown) {\n      setError(err instanceof Error ? err.message : t('variant.analyzeFailed'))\n    } finally {\n      setIsAnalyzing(false)\n      analyzingRef.current = false\n    }\n  }, [analyzeShotVariantsMutation, panel.id, t])\n\n  useEffect(() => {\n    if (!isOpen || !panel.imageUrl) return\n    const autoAnalyzeKey = `${panel.id}:${panel.imageUrl}`\n    if (autoAnalyzeKeyRef.current === autoAnalyzeKey) return\n    autoAnalyzeKeyRef.current = autoAnalyzeKey\n    void analyzeShotVariants()\n  }, [analyzeShotVariants, isOpen, panel.id, panel.imageUrl])\n\n  useEffect(() => {\n    if (isOpen) return\n    autoAnalyzeKeyRef.current = null\n    analyzingRef.current = false\n  }, [isOpen])\n\n  const handleSelectVariant = async (suggestion: ShotVariantSuggestion) => {\n    setSelectedVariantId(suggestion.id)\n    await onVariant(\n      {\n        title: suggestion.title,\n        description: suggestion.description,\n        shot_type: suggestion.shot_type,\n        camera_move: suggestion.camera_move,\n        video_prompt: suggestion.video_prompt,\n      },\n      { includeCharacterAssets, includeLocationAsset },\n    )\n  }\n\n  const handleCustomVariant = async () => {\n    if (!customInput.trim()) return\n\n    await onVariant(\n      {\n        title: t('variant.customVariant'),\n        description: customInput,\n        shot_type: t('variant.defaultShotType'),\n        camera_move: t('variant.defaultCameraMove'),\n        video_prompt: customInput,\n      },\n      { includeCharacterAssets, includeLocationAsset },\n    )\n  }\n\n  const handleClose = () => {\n    if (!isSubmittingVariantTask && !isAnalyzing) {\n      setSuggestions([])\n      setError(null)\n      setCustomInput('')\n      setSelectedVariantId(null)\n      autoAnalyzeKeyRef.current = null\n      analyzingRef.current = false\n      onClose()\n    }\n  }\n\n  const variantTaskRunningState = isSubmittingVariantTask\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'image',\n      hasOutput: !!panel.imageUrl,\n    })\n    : null\n\n  const analyzeTaskRunningState = isAnalyzing\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'analyze',\n      resource: 'image',\n      hasOutput: false,\n    })\n    : null\n\n  if (!isOpen || !mounted) return null\n\n  const modalContent = (\n    <div\n      className=\"fixed inset-0 glass-overlay flex items-center justify-center p-4\"\n      style={{ zIndex: 9999 }}\n      onClick={handleClose}\n    >\n      <div\n        className=\"glass-surface-modal w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col\"\n        onClick={(event) => event.stopPropagation()}\n      >\n        <div className=\"px-5 py-3 border-b border-[var(--glass-stroke-base)] flex items-center justify-between\">\n          <h2 className=\"text-base font-bold text-[var(--glass-text-primary)] flex items-center gap-2\">\n            <AppIcon name=\"videoWide\" className=\"h-4 w-4 text-[var(--glass-text-secondary)]\" />\n            {t('variant.shotTitle', { number: panel.panelNumber ?? '' })}\n          </h2>\n          <button\n            onClick={handleClose}\n            disabled={isSubmittingVariantTask || isAnalyzing}\n            className=\"glass-btn-base glass-btn-soft p-1.5 disabled:opacity-50\"\n          >\n            <AppIcon name=\"close\" className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        <div className=\"flex-1 overflow-y-auto p-5 space-y-4\">\n          <div className=\"flex gap-4 items-start\">\n            <div className=\"w-32 flex-shrink-0\">\n              {panel.imageUrl ? (\n                <MediaImageWithLoading\n                  src={panel.imageUrl}\n                  alt={t('variant.shotNum', { number: panel.panelNumber ?? '' })}\n                  containerClassName=\"w-full aspect-[9/16] rounded-lg shadow-[var(--glass-shadow-sm)]\"\n                  className=\"w-full aspect-[9/16] object-cover rounded-lg shadow-[var(--glass-shadow-sm)]\"\n                  width={256}\n                  height={456}\n                  sizes=\"128px\"\n                />\n              ) : (\n                <div className=\"w-full aspect-[9/16] bg-[var(--glass-bg-muted)] rounded-lg flex items-center justify-center text-[var(--glass-text-tertiary)] text-xs\">\n                  {t('variant.noImage')}\n                </div>\n              )}\n              <div className=\"text-xs text-[var(--glass-text-tertiary)] mt-1 text-center\">#{panel.panelNumber}</div>\n            </div>\n            <div className=\"flex-1\">\n              <h3 className=\"text-sm font-medium text-[var(--glass-text-primary)] mb-1\">{t('variant.originalDescription')}</h3>\n              <p className=\"text-sm text-[var(--glass-text-secondary)]\">{panel.description || t('variant.noDescription')}</p>\n            </div>\n          </div>\n\n          <div className=\"glass-divider\" />\n\n          <PanelVariantModalSuggestionList\n            isAnalyzing={isAnalyzing}\n            suggestions={suggestions}\n            error={error}\n            selectedVariantId={selectedVariantId}\n            isSubmittingVariantTask={isSubmittingVariantTask}\n            analyzeTaskRunningState={analyzeTaskRunningState}\n            variantTaskRunningState={variantTaskRunningState}\n            onReanalyze={analyzeShotVariants}\n            onSelectVariant={(suggestion) => {\n              void handleSelectVariant(suggestion)\n            }}\n          />\n\n          <div className=\"glass-divider\" />\n\n          <PanelVariantModalCustomOptions\n            customInput={customInput}\n            includeCharacterAssets={includeCharacterAssets}\n            includeLocationAsset={includeLocationAsset}\n            isSubmittingVariantTask={isSubmittingVariantTask}\n            onCustomInputChange={setCustomInput}\n            onIncludeCharacterAssetsChange={setIncludeCharacterAssets}\n            onIncludeLocationAssetChange={setIncludeLocationAsset}\n          />\n        </div>\n\n        <div className=\"px-5 py-3 border-t border-[var(--glass-stroke-base)] flex justify-end gap-3\">\n          <button\n            onClick={handleClose}\n            disabled={isSubmittingVariantTask || isAnalyzing}\n            className=\"glass-btn-base glass-btn-secondary px-4 py-2 text-sm disabled:opacity-50\"\n          >\n            {t('candidate.cancel')}\n          </button>\n          <button\n            onClick={() => {\n              void handleCustomVariant()\n            }}\n            disabled={isSubmittingVariantTask || !customInput.trim()}\n            className={`glass-btn-base px-4 py-2 text-sm rounded-lg ${isSubmittingVariantTask || !customInput.trim() ? 'glass-btn-soft text-[var(--glass-text-tertiary)] cursor-not-allowed' : 'glass-btn-primary text-white'}`}\n          >\n            {isSubmittingVariantTask ? (\n              <TaskStatusInline\n                state={variantTaskRunningState}\n                className=\"text-[var(--glass-text-tertiary)] [&>span]:text-[var(--glass-text-tertiary)] [&_svg]:text-[var(--glass-text-tertiary)]\"\n              />\n            ) : t('variant.useCustomGenerate')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n\n  return createPortal(modalContent, document.body)\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/PanelVariantModal.types.ts",
    "content": "export interface ShotVariantSuggestion {\n  id: number\n  title: string\n  description: string\n  shot_type: string\n  camera_move: string\n  video_prompt: string\n  creative_score: number\n}\n\nexport interface PanelInfo {\n  id: string\n  panelNumber: number | null\n  description: string | null\n  imageUrl: string | null\n  storyboardId: string\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/PanelVariantModalCustomOptions.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\n\ninterface PanelVariantModalCustomOptionsProps {\n  customInput: string\n  includeCharacterAssets: boolean\n  includeLocationAsset: boolean\n  isSubmittingVariantTask: boolean\n  onCustomInputChange: (value: string) => void\n  onIncludeCharacterAssetsChange: (checked: boolean) => void\n  onIncludeLocationAssetChange: (checked: boolean) => void\n}\n\nexport default function PanelVariantModalCustomOptions({\n  customInput,\n  includeCharacterAssets,\n  includeLocationAsset,\n  isSubmittingVariantTask,\n  onCustomInputChange,\n  onIncludeCharacterAssetsChange,\n  onIncludeLocationAssetChange,\n}: PanelVariantModalCustomOptionsProps) {\n  const t = useTranslations('storyboard')\n\n  return (\n    <>\n      <div>\n        <h3 className=\"text-sm font-medium text-[var(--glass-text-primary)] mb-2\">{t('variant.customInstruction')}</h3>\n        <textarea\n          value={customInput}\n          onChange={(event) => onCustomInputChange(event.target.value)}\n          placeholder={t('variant.customPlaceholder')}\n          className=\"glass-textarea-base h-16 px-3 py-2 text-sm resize-none\"\n          disabled={isSubmittingVariantTask}\n        />\n      </div>\n\n      <div className=\"flex items-center gap-4\">\n        <label className=\"flex items-center gap-2 text-sm text-[var(--glass-text-secondary)] cursor-pointer\">\n          <input\n            type=\"checkbox\"\n            checked={includeCharacterAssets}\n            onChange={(event) => onIncludeCharacterAssetsChange(event.target.checked)}\n            className=\"w-4 h-4 accent-[var(--glass-accent-from)] rounded\"\n          />\n          {t('variant.includeCharacter')}\n        </label>\n        <label className=\"flex items-center gap-2 text-sm text-[var(--glass-text-secondary)] cursor-pointer\">\n          <input\n            type=\"checkbox\"\n            checked={includeLocationAsset}\n            onChange={(event) => onIncludeLocationAssetChange(event.target.checked)}\n            className=\"w-4 h-4 accent-[var(--glass-accent-from)] rounded\"\n          />\n          {t('variant.includeLocation')}\n        </label>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/PanelVariantModalSuggestionList.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport type { ShotVariantSuggestion } from './PanelVariantModal.types'\n\ninterface PanelVariantModalSuggestionListProps {\n  isAnalyzing: boolean\n  suggestions: ShotVariantSuggestion[]\n  error: string | null\n  selectedVariantId: number | null\n  isSubmittingVariantTask: boolean\n  analyzeTaskRunningState: TaskPresentationState | null\n  variantTaskRunningState: TaskPresentationState | null\n  onReanalyze: () => void\n  onSelectVariant: (suggestion: ShotVariantSuggestion) => void\n}\n\nexport default function PanelVariantModalSuggestionList({\n  isAnalyzing,\n  suggestions,\n  error,\n  selectedVariantId,\n  isSubmittingVariantTask,\n  analyzeTaskRunningState,\n  variantTaskRunningState,\n  onReanalyze,\n  onSelectVariant,\n}: PanelVariantModalSuggestionListProps) {\n  const t = useTranslations('storyboard')\n  const renderScore = (score: number) => t('variant.creativeScore', { score })\n\n  return (\n    <div>\n      <div className=\"flex items-center justify-between mb-3\">\n        <h3 className=\"text-sm font-medium text-[var(--glass-text-primary)] flex items-center gap-2\">\n          {t('variant.aiRecommend')}\n          {isAnalyzing && (\n            <TaskStatusInline\n              state={analyzeTaskRunningState}\n              className=\"text-[var(--glass-tone-info-fg)] [&>span]:text-[var(--glass-tone-info-fg)] [&_svg]:text-[var(--glass-tone-info-fg)]\"\n            />\n          )}\n        </h3>\n        {!isAnalyzing && suggestions.length > 0 && (\n          <button\n            onClick={onReanalyze}\n            className=\"text-xs text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-text-primary)] flex items-center gap-1\"\n          >\n            {t('variant.reanalyze')}\n          </button>\n        )}\n      </div>\n\n      {error && (\n        <div className=\"p-3 bg-[var(--glass-tone-danger-bg)] text-[var(--glass-tone-danger-fg)] text-sm rounded-lg mb-3 border border-[var(--glass-stroke-danger)]\">\n          {error}\n        </div>\n      )}\n\n      <div className=\"space-y-2 max-h-64 overflow-y-auto\">\n        {suggestions.map((suggestion) => (\n          <div\n            key={suggestion.id}\n            className={`p-3 border rounded-lg transition-colors cursor-pointer ${selectedVariantId === suggestion.id ? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]' : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-bg-muted)]'}`}\n            onClick={() => !isSubmittingVariantTask && onSelectVariant(suggestion)}\n          >\n            <div className=\"flex items-start justify-between\">\n              <div className=\"flex-1\">\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"text-xs text-[var(--glass-tone-warning-fg)]\">{renderScore(suggestion.creative_score)}</span>\n                  <h4 className=\"text-sm font-medium text-[var(--glass-text-primary)]\">{suggestion.title}</h4>\n                </div>\n                <p className=\"text-xs text-[var(--glass-text-secondary)] mt-1\">{suggestion.description}</p>\n                <div className=\"flex gap-2 mt-1\">\n                  <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('variant.shotType')} {suggestion.shot_type}</span>\n                  <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('variant.cameraMove')} {suggestion.camera_move}</span>\n                </div>\n              </div>\n              <button\n                disabled={isSubmittingVariantTask}\n                className={`glass-btn-base px-3 py-1 text-xs rounded-lg ${isSubmittingVariantTask && selectedVariantId === suggestion.id ? 'glass-btn-soft text-[var(--glass-text-tertiary)]' : 'glass-btn-primary text-white'}`}\n              >\n                {isSubmittingVariantTask && selectedVariantId === suggestion.id ? (\n                  <TaskStatusInline\n                    state={variantTaskRunningState}\n                    className=\"text-[var(--glass-text-tertiary)] [&>span]:text-[var(--glass-text-tertiary)] [&_svg]:text-[var(--glass-text-tertiary)]\"\n                  />\n                ) : t('candidate.select')}\n              </button>\n            </div>\n          </div>\n        ))}\n\n        {!isAnalyzing && suggestions.length === 0 && !error && (\n          <div className=\"text-center py-8 text-[var(--glass-text-tertiary)] text-sm\">\n            {t('variant.clickToAnalyze')}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/ScreenplayDisplay.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\nimport { useState } from 'react'\n\ninterface ScreenplayScene {\n    scene_number: number\n    heading: {\n        int_ext: string\n        location: string\n        time: string\n    } | string\n    description?: string\n    characters?: string[]\n    content: Array<{\n        type: 'action' | 'dialogue' | 'voiceover'\n        text?: string\n        character?: string\n        lines?: string\n        parenthetical?: string\n    }>\n}\n\ninterface Screenplay {\n    clip_id: string\n    original_text?: string\n    scenes: ScreenplayScene[]\n}\n\ninterface ScreenplayDisplayProps {\n    screenplay: string | null\n    originalContent: string\n}\n\nexport default function ScreenplayDisplay({ screenplay, originalContent }: ScreenplayDisplayProps) {\n    const t = useTranslations('storyboard')\n    const [activeTab, setActiveTab] = useState<'screenplay' | 'original'>('screenplay')\n\n    // 解析剧本JSON\n    let parsedScreenplay: Screenplay | null = null\n    try {\n        if (screenplay) {\n            parsedScreenplay = JSON.parse(screenplay)\n        }\n    } catch (e) {\n        _ulogError('Failed to parse screenplay:', e)\n    }\n\n    return (\n        <div className=\"space-y-3\">\n            <div className=\"flex items-center gap-2\">\n                <button\n                    onClick={() => setActiveTab('screenplay')}\n                    className={`glass-btn-base rounded-xl px-3 py-1.5 text-sm ${activeTab === 'screenplay'\n                        ? 'glass-btn-secondary text-[var(--glass-text-secondary)]'\n                        : 'glass-btn-soft'\n                        }`}\n                >\n                    {t('screenplay.tabs.formatted')}\n                </button>\n                <button\n                    onClick={() => setActiveTab('original')}\n                    className={`glass-btn-base rounded-xl px-3 py-1.5 text-sm ${activeTab === 'original'\n                        ? 'glass-btn-secondary text-[var(--glass-text-secondary)]'\n                        : 'glass-btn-soft'\n                        }`}\n                >\n                    {t('screenplay.tabs.original')}\n                </button>\n            </div>\n\n            <div className=\"glass-surface-soft p-4 max-h-96 overflow-y-auto\">\n                {activeTab === 'screenplay' && parsedScreenplay ? (\n                    <div className=\"space-y-3\">\n                        {parsedScreenplay.scenes.map((scene, sceneIndex) => (\n                            <div key={sceneIndex} className=\"border-l-2 border-[var(--glass-stroke-focus)] pl-3 space-y-2\">\n                                <div className=\"flex items-center gap-2 text-xs flex-wrap\">\n                                    <span className=\"font-bold text-[var(--glass-tone-info-fg)] bg-[var(--glass-tone-info-bg)] px-2 py-0.5 rounded\">\n                                        {t('screenplay.scene', { number: scene.scene_number })}\n                                    </span>\n                                    <span className=\"text-[var(--glass-text-tertiary)]\">\n                                        {typeof scene.heading === 'string'\n                                            ? scene.heading\n                                            : `${scene.heading.int_ext} · ${scene.heading.location} · ${scene.heading.time}`}\n                                    </span>\n                                </div>\n\n                                {scene.description && (\n                                    <div className=\"text-xs text-[var(--glass-text-tertiary)] italic bg-[var(--glass-bg-muted)]/70 px-2 py-1 rounded\">\n                                        {scene.description}\n                                    </div>\n                                )}\n\n                                {scene.characters && scene.characters.length > 0 && (\n                                    <div className=\"flex gap-1 flex-wrap items-center\">\n                                        <span className=\"text-[10px] text-[var(--glass-text-tertiary)]\">{t('screenplay.characters')}</span>\n                                        {scene.characters.map((name, index) => (\n                                            <span key={`${name}-${index}`} className=\"text-[10px] text-[var(--glass-text-secondary)] bg-[var(--glass-bg-muted)] px-1.5 py-0.5 rounded\">\n                                                {name}\n                                            </span>\n                                        ))}\n                                    </div>\n                                )}\n\n                                <div className=\"space-y-1.5\">\n                                    {scene.content.map((item, itemIndex) => (\n                                        <div key={itemIndex}>\n                                            {item.type === 'action' && (\n                                                <p className=\"text-sm text-[var(--glass-text-secondary)] leading-relaxed\">{item.text}</p>\n                                            )}\n                                            {item.type === 'dialogue' && (\n                                                <div className=\"bg-[var(--glass-tone-warning-bg)]/60 border-l-2 border-[var(--glass-stroke-warning)] pl-2 py-1\">\n                                                    <div>\n                                                        <span className=\"text-xs font-medium text-[var(--glass-tone-warning-fg)]\">{item.character}</span>\n                                                        {item.parenthetical && (\n                                                            <span className=\"text-[var(--glass-tone-warning-fg)] ml-1\">({item.parenthetical})</span>\n                                                        )}\n                                                    </div>\n                                                    <p className=\"text-sm text-[var(--glass-text-secondary)]\">\n                                                        <span className=\"select-none text-[var(--glass-text-tertiary)]\">&quot;</span>\n                                                        {item.lines}\n                                                        <span className=\"select-none text-[var(--glass-text-tertiary)]\">&quot;</span>\n                                                    </p>\n                                                </div>\n                                            )}\n                                            {item.type === 'voiceover' && (\n                                                <div className=\"bg-[var(--glass-tone-info-bg)]/60 border-l-2 border-[var(--glass-stroke-focus)] pl-2 py-1\">\n                                                    <span className=\"text-xs text-[var(--glass-tone-info-fg)]\">{t('screenplay.voiceover')}</span>\n                                                    <p className=\"text-sm text-[var(--glass-text-secondary)] italic\">{item.text}</p>\n                                                </div>\n                                            )}\n                                        </div>\n                                    ))}\n                                </div>\n                            </div>\n                        ))}\n                    </div>\n                ) : activeTab === 'screenplay' && !parsedScreenplay ? (\n                    <div className=\"text-center text-[var(--glass-text-tertiary)] py-8\">\n                        <p>{t('screenplay.parseFailedTitle')}</p>\n                        <p className=\"text-xs mt-1\">{t('screenplay.parseFailedDescription')}</p>\n                    </div>\n                ) : (\n                    <div className=\"text-sm text-[var(--glass-text-secondary)] whitespace-pre-wrap leading-relaxed\">{originalContent}</div>\n                )}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardCanvas.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { NovelPromotionClip, NovelPromotionPanel, NovelPromotionStoryboard } from '@/types/project'\nimport StoryboardGroup from './StoryboardGroup'\nimport { StoryboardPanel } from './hooks/useStoryboardState'\nimport { PanelEditData } from '../PanelEditForm'\nimport { VariantData, VariantOptions } from './hooks/usePanelVariant'\nimport type { PanelSaveState } from './hooks/usePanelCrudActions'\nimport { AppIcon } from '@/components/ui/icons'\nimport { GlassButton } from '@/components/ui/primitives'\n\ninterface StoryboardCanvasProps {\n  sortedStoryboards: NovelPromotionStoryboard[]\n  videoRatio: string\n  expandedClips: Set<string>\n  submittingStoryboardIds: Set<string>\n  selectingCandidateIds: Set<string>\n  submittingStoryboardTextIds: Set<string>\n  savingPanels: Set<string>\n  deletingPanelIds: Set<string>\n  saveStateByPanel: Record<string, PanelSaveState>\n  hasUnsavedByPanel: Set<string>\n  modifyingPanels: Set<string>\n  submittingPanelImageIds: Set<string>\n  movingClipId: string | null\n  insertingAfterPanelId: string | null\n  submittingVariantPanelId: string | null\n  projectId: string\n  episodeId: string\n  storyboardStartIndex: Record<string, number>\n  getClipInfo: (clipId: string) => NovelPromotionClip | undefined\n  getTextPanels: (storyboard: NovelPromotionStoryboard) => StoryboardPanel[]\n  getPanelEditData: (panel: StoryboardPanel) => PanelEditData\n  formatClipTitle: (clip: NovelPromotionClip | undefined) => string\n  onToggleExpandedClip: (storyboardId: string) => void\n  onMoveStoryboardGroup: (clipId: string, direction: 'up' | 'down') => Promise<void>\n  onRegenerateStoryboardText: (storyboardId: string) => Promise<void>\n  onAddPanel: (storyboardId: string) => Promise<void>\n  onDeleteStoryboard: (storyboardId: string, panelCount: number) => Promise<void>\n  onGenerateAllIndividually: (storyboardId: string) => Promise<void>\n  onPreviewImage: (url: string) => void\n  onCloseStoryboardError: (storyboardId: string) => void\n  onPanelUpdate: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void\n  onPanelDelete: (\n    panelId: string,\n    storyboardId: string,\n    setLocalStoryboards: React.Dispatch<React.SetStateAction<NovelPromotionStoryboard[]>>,\n  ) => Promise<void>\n  onOpenCharacterPicker: (panelId: string) => void\n  onOpenLocationPicker: (panelId: string) => void\n  onRemoveCharacter: (panel: StoryboardPanel, index: number, storyboardId: string) => void\n  onRemoveLocation: (panel: StoryboardPanel, storyboardId: string) => void\n  onRetryPanelSave: (panelId: string) => void\n  onRegeneratePanelImage: (panelId: string, count?: number, force?: boolean) => void\n  onOpenEditModal: (storyboardId: string, panelIndex: number) => void\n  onOpenAIDataModal: (storyboardId: string, panelIndex: number) => void\n  getPanelCandidates: (panel: NovelPromotionPanel) => { candidates: string[]; selectedIndex: number } | null\n  onSelectPanelCandidateIndex: (panelId: string, index: number) => void\n  onConfirmPanelCandidate: (panelId: string, imageUrl: string) => Promise<void>\n  onCancelPanelCandidate: (panelId: string) => void\n  onInsertPanel: (storyboardId: string, insertAfterPanelId: string, userInput: string) => Promise<void>\n  onPanelVariant: (\n    sourcePanelId: string,\n    storyboardId: string,\n    insertAfterPanelId: string,\n    variant: VariantData,\n    options: VariantOptions,\n  ) => Promise<void>\n  addStoryboardGroup: (insertIndex: number) => Promise<void>\n  addingStoryboardGroup: boolean\n  setLocalStoryboards: React.Dispatch<React.SetStateAction<NovelPromotionStoryboard[]>>\n}\n\nexport default function StoryboardCanvas({\n  sortedStoryboards,\n  videoRatio,\n  expandedClips,\n  submittingStoryboardIds,\n  selectingCandidateIds,\n  submittingStoryboardTextIds,\n  savingPanels,\n  deletingPanelIds,\n  saveStateByPanel,\n  hasUnsavedByPanel,\n  modifyingPanels,\n  submittingPanelImageIds,\n  movingClipId,\n  insertingAfterPanelId,\n  submittingVariantPanelId,\n  projectId,\n  episodeId,\n  storyboardStartIndex,\n  getClipInfo,\n  getTextPanels,\n  getPanelEditData,\n  formatClipTitle,\n  onToggleExpandedClip,\n  onMoveStoryboardGroup,\n  onRegenerateStoryboardText,\n  onAddPanel,\n  onDeleteStoryboard,\n  onGenerateAllIndividually,\n  onPreviewImage,\n  onCloseStoryboardError,\n  onPanelUpdate,\n  onPanelDelete,\n  onOpenCharacterPicker,\n  onOpenLocationPicker,\n  onRemoveCharacter,\n  onRemoveLocation,\n  onRetryPanelSave,\n  onRegeneratePanelImage,\n  onOpenEditModal,\n  onOpenAIDataModal,\n  getPanelCandidates,\n  onSelectPanelCandidateIndex,\n  onConfirmPanelCandidate,\n  onCancelPanelCandidate,\n  onInsertPanel,\n  onPanelVariant,\n  addStoryboardGroup,\n  addingStoryboardGroup,\n  setLocalStoryboards,\n}: StoryboardCanvasProps) {\n  const t = useTranslations('storyboard')\n  if (sortedStoryboards.length === 0) {\n    return (\n      <div className=\"text-center py-12 text-[var(--glass-text-tertiary)]\">\n        <p>{t('canvas.emptyTitle')}</p>\n        <p className=\"text-sm mt-2\">{t('canvas.emptyDescription')}</p>\n      </div>\n    )\n  }\n\n  return (\n    <>\n      {sortedStoryboards.map((storyboard, sbIndex) => {\n        const clip = getClipInfo(storyboard.clipId)\n        const textPanels = getTextPanels(storyboard)\n        const isSubmittingStoryboardTask = submittingStoryboardIds.has(storyboard.id)\n        const isSelectingCandidate = selectingCandidateIds.has(storyboard.id)\n        const isSubmittingStoryboardTextTask = submittingStoryboardTextIds.has(storyboard.id)\n        const hasAnyImage = textPanels.some((panel) => panel.imageUrl)\n        const failedError = storyboard.lastError ?? null\n\n        return (\n          <div key={storyboard.id}>\n            <StoryboardGroup\n              storyboard={storyboard}\n              clip={clip}\n              sbIndex={sbIndex}\n              totalStoryboards={sortedStoryboards.length}\n              textPanels={textPanels}\n              storyboardStartIndex={storyboardStartIndex[storyboard.id]}\n              videoRatio={videoRatio}\n              isExpanded={expandedClips.has(storyboard.id)}\n              isSubmittingStoryboardTask={isSubmittingStoryboardTask}\n              isSelectingCandidate={isSelectingCandidate}\n              isSubmittingStoryboardTextTask={isSubmittingStoryboardTextTask}\n              hasAnyImage={hasAnyImage}\n              failedError={failedError}\n              savingPanels={savingPanels}\n              deletingPanelIds={deletingPanelIds}\n              saveStateByPanel={saveStateByPanel}\n              hasUnsavedByPanel={hasUnsavedByPanel}\n              modifyingPanels={modifyingPanels}\n              submittingPanelImageIds={submittingPanelImageIds}\n              onToggleExpand={() => onToggleExpandedClip(storyboard.id)}\n              onMoveUp={() => onMoveStoryboardGroup(storyboard.clipId, 'up')}\n              onMoveDown={() => onMoveStoryboardGroup(storyboard.clipId, 'down')}\n              onRegenerateText={() => onRegenerateStoryboardText(storyboard.id)}\n              onAddPanel={() => onAddPanel(storyboard.id)}\n              onDeleteStoryboard={() => onDeleteStoryboard(storyboard.id, textPanels.length)}\n              onGenerateAllIndividually={() => onGenerateAllIndividually(storyboard.id)}\n              onPreviewImage={onPreviewImage}\n              onCloseError={() => onCloseStoryboardError(storyboard.id)}\n              getPanelEditData={getPanelEditData}\n              onPanelUpdate={onPanelUpdate}\n              onPanelDelete={(panelId) => onPanelDelete(panelId, storyboard.id, setLocalStoryboards)}\n              onOpenCharacterPicker={onOpenCharacterPicker}\n              onOpenLocationPicker={onOpenLocationPicker}\n              onRemoveCharacter={(panel, index) => onRemoveCharacter(panel, index, storyboard.id)}\n              onRemoveLocation={(panel) => onRemoveLocation(panel, storyboard.id)}\n              onRetryPanelSave={onRetryPanelSave}\n              onRegeneratePanelImage={onRegeneratePanelImage}\n              onOpenEditModal={(panelIndex) => onOpenEditModal(storyboard.id, panelIndex)}\n              onOpenAIDataModal={(panelIndex) => onOpenAIDataModal(storyboard.id, panelIndex)}\n              getPanelCandidates={getPanelCandidates}\n              onSelectPanelCandidateIndex={onSelectPanelCandidateIndex}\n              onConfirmPanelCandidate={onConfirmPanelCandidate}\n              onCancelPanelCandidate={onCancelPanelCandidate}\n              formatClipTitle={formatClipTitle}\n              movingClipId={movingClipId}\n              onInsertPanel={onInsertPanel}\n              insertingAfterPanelId={insertingAfterPanelId}\n              projectId={projectId}\n              episodeId={episodeId}\n              onPanelVariant={onPanelVariant}\n              submittingVariantPanelId={submittingVariantPanelId}\n            />\n\n            <div className=\"flex justify-center py-2\">\n              <GlassButton\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => addStoryboardGroup(sbIndex + 1)}\n                disabled={addingStoryboardGroup}\n                className=\"opacity-60 hover:opacity-100\"\n              >\n                <AppIcon name=\"plusAlt\" className=\"h-3 w-3\" />\n                <span>{t('group.insertHere')}</span>\n              </GlassButton>\n            </div>\n          </div>\n        )\n      })}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardGroup.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\n\nimport { useCallback, useMemo } from 'react'\nimport ScreenplayDisplay from './ScreenplayDisplay'\nimport { StoryboardPanel } from './hooks/useStoryboardState'\nimport StoryboardGroupHeader from './StoryboardGroupHeader'\nimport StoryboardGroupActions from './StoryboardGroupActions'\nimport StoryboardPanelList from './StoryboardPanelList'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport TaskStatusOverlay from '@/components/task/TaskStatusOverlay'\nimport { useStoryboardGroupTaskErrors } from './hooks/useStoryboardGroupTaskErrors'\nimport { useStoryboardInsertVariantRuntime } from './hooks/useStoryboardInsertVariantRuntime'\nimport StoryboardGroupFailedAlert from './StoryboardGroupFailedAlert'\nimport StoryboardGroupDialogs from './StoryboardGroupDialogs'\nimport type { StoryboardGroupProps } from './StoryboardGroup.types'\nimport { AppIcon } from '@/components/ui/icons'\n\nexport default function StoryboardGroup({\n  storyboard,\n  clip,\n  sbIndex,\n  totalStoryboards,\n  textPanels,\n  storyboardStartIndex,\n  videoRatio,\n  isExpanded,\n  isSubmittingStoryboardTask,\n  isSelectingCandidate,\n  isSubmittingStoryboardTextTask,\n  hasAnyImage,\n  failedError,\n  savingPanels,\n  deletingPanelIds,\n  saveStateByPanel,\n  hasUnsavedByPanel,\n  modifyingPanels,\n  submittingPanelImageIds,\n  onToggleExpand,\n  onMoveUp,\n  onMoveDown,\n  onRegenerateText,\n  onAddPanel,\n  onDeleteStoryboard,\n  onGenerateAllIndividually,\n  onPreviewImage,\n  onCloseError,\n  getPanelEditData,\n  onPanelUpdate,\n  onPanelDelete,\n  onOpenCharacterPicker,\n  onOpenLocationPicker,\n  onRemoveCharacter,\n  onRemoveLocation,\n  onRetryPanelSave,\n  onRegeneratePanelImage,\n  onOpenEditModal,\n  onOpenAIDataModal,\n  getPanelCandidates,\n  onSelectPanelCandidateIndex,\n  onConfirmPanelCandidate,\n  onCancelPanelCandidate,\n  formatClipTitle,\n  movingClipId,\n  onInsertPanel,\n  insertingAfterPanelId,\n  projectId,\n  episodeId,\n  onPanelVariant,\n  submittingVariantPanelId,\n}: StoryboardGroupProps) {\n  const t = useTranslations('storyboard')\n\n  const {\n    insertModalOpen,\n    insertAfterPanel,\n    nextPanelForInsert,\n    variantModalPanel,\n    handleOpenInsertModal,\n    handleCloseInsertModal,\n    handleInsert,\n    handleOpenVariantModal,\n    handleCloseVariantModal,\n    handleVariant,\n  } = useStoryboardInsertVariantRuntime({\n    storyboardId: storyboard.id,\n    textPanels,\n    onInsertPanel,\n    onPanelVariant,\n  })\n\n  const {\n    panelTaskErrorMap,\n    clearPanelTaskError,\n  } = useStoryboardGroupTaskErrors({\n    projectId,\n    episodeId,\n  })\n\n  const isPanelTaskRunning = useCallback(\n    (panel: StoryboardPanel) => {\n      const taskIntent = (panel as StoryboardPanel & { imageTaskIntent?: string }).imageTaskIntent\n      if (taskIntent === 'modify') return false\n\n      const isTaskRunning = Boolean((panel as StoryboardPanel & { imageTaskRunning?: boolean }).imageTaskRunning)\n      const isSubmitting = submittingPanelImageIds.has(panel.id)\n      if (isTaskRunning || isSubmitting) return true\n\n      const taskError = panelTaskErrorMap.get(panel.id)\n      if (taskError) return false\n\n      return false\n    },\n    [panelTaskErrorMap, submittingPanelImageIds],\n  )\n\n  const currentRunningCount = textPanels.filter(isPanelTaskRunning).length\n  const pendingCount = textPanels.filter((panel) => !panel.imageUrl && !isPanelTaskRunning(panel)).length\n\n  const groupOverlayState = useMemo(() => {\n    if (!isSubmittingStoryboardTask && !isSelectingCandidate) return null\n    return resolveTaskPresentationState({\n      phase: 'processing',\n      intent: isSelectingCandidate ? 'process' : hasAnyImage ? 'regenerate' : 'generate',\n      resource: 'image',\n      hasOutput: hasAnyImage,\n    })\n  }, [hasAnyImage, isSelectingCandidate, isSubmittingStoryboardTask])\n\n  const handleRegeneratePanelImage = useCallback(\n    (panelId: string, count?: number, force?: boolean) => {\n      clearPanelTaskError(panelId)\n      onRegeneratePanelImage(panelId, count, force)\n    },\n    [clearPanelTaskError, onRegeneratePanelImage],\n  )\n\n  return (\n    <div className={`glass-surface-elevated p-6 relative ${failedError ? 'border-2 border-[var(--glass-stroke-danger)] bg-[var(--glass-danger-ring)]' : ''}`}>\n      {failedError && (\n        <StoryboardGroupFailedAlert\n          failedError={failedError}\n          title={`警告 ${t('group.failed')}`}\n          closeTitle={t('common.cancel')}\n          onClose={onCloseError}\n        />\n      )}\n\n      {(isSubmittingStoryboardTask || isSelectingCandidate) && (\n        <TaskStatusOverlay\n          state={groupOverlayState}\n          className=\"z-10 rounded-lg bg-[var(--glass-bg-surface-modal)]/90\"\n        />\n      )}\n\n      <div className=\"mb-4 pb-2 flex items-start justify-between\">\n        <StoryboardGroupHeader\n          clip={clip}\n          sbIndex={sbIndex}\n          totalStoryboards={totalStoryboards}\n          movingClipId={movingClipId}\n          storyboardClipId={storyboard.clipId}\n          formatClipTitle={formatClipTitle}\n          onMoveUp={onMoveUp}\n          onMoveDown={onMoveDown}\n        />\n        <StoryboardGroupActions\n          hasAnyImage={hasAnyImage}\n          isSubmittingStoryboardTask={isSubmittingStoryboardTask}\n          isSubmittingStoryboardTextTask={isSubmittingStoryboardTextTask}\n          currentRunningCount={currentRunningCount}\n          pendingCount={pendingCount}\n          onRegenerateText={onRegenerateText}\n          onGenerateAllIndividually={onGenerateAllIndividually}\n          onAddPanel={onAddPanel}\n          onDeleteStoryboard={onDeleteStoryboard}\n        />\n      </div>\n\n      {clip && (\n        <div className=\"mb-4\">\n          <button\n            onClick={onToggleExpand}\n            className=\"glass-btn-base glass-btn-soft rounded-xl px-3 py-2 text-sm\"\n          >\n            <AppIcon name=\"chevronRightMd\" className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />\n            <span>{clip.screenplay ? t('panel.stylePrompt') : t('panel.sourceText')}</span>\n          </button>\n          {isExpanded && (\n            <div className=\"mt-2 glass-surface-soft p-2\">\n              {clip.screenplay ? (\n                <ScreenplayDisplay screenplay={clip.screenplay} originalContent={clip.content} />\n              ) : (\n                <div className=\"whitespace-pre-wrap p-3 text-sm text-[var(--glass-text-secondary)]\">\n                  {clip.content}\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n      )}\n\n      <StoryboardPanelList\n        storyboardId={storyboard.id}\n        textPanels={textPanels}\n        storyboardStartIndex={storyboardStartIndex}\n        videoRatio={videoRatio}\n        isSubmittingStoryboardTextTask={isSubmittingStoryboardTextTask}\n        savingPanels={savingPanels}\n        deletingPanelIds={deletingPanelIds}\n        saveStateByPanel={saveStateByPanel}\n        hasUnsavedByPanel={hasUnsavedByPanel}\n        modifyingPanels={modifyingPanels}\n        panelTaskErrorMap={panelTaskErrorMap}\n        isPanelTaskRunning={isPanelTaskRunning}\n        getPanelEditData={getPanelEditData}\n        getPanelCandidates={getPanelCandidates}\n        onPanelUpdate={onPanelUpdate}\n        onPanelDelete={onPanelDelete}\n        onOpenCharacterPicker={onOpenCharacterPicker}\n        onOpenLocationPicker={onOpenLocationPicker}\n        onRemoveCharacter={onRemoveCharacter}\n        onRemoveLocation={onRemoveLocation}\n        onRetryPanelSave={onRetryPanelSave}\n        onRegeneratePanelImage={handleRegeneratePanelImage}\n        onOpenEditModal={onOpenEditModal}\n        onOpenAIDataModal={onOpenAIDataModal}\n        onSelectPanelCandidateIndex={onSelectPanelCandidateIndex}\n        onConfirmPanelCandidate={onConfirmPanelCandidate}\n        onCancelPanelCandidate={onCancelPanelCandidate}\n        onClearPanelTaskError={clearPanelTaskError}\n        onPreviewImage={onPreviewImage}\n        onInsertAfter={handleOpenInsertModal}\n        onVariant={handleOpenVariantModal}\n        isInsertDisabled={(panelId) =>\n          isSubmittingStoryboardTextTask ||\n          insertingAfterPanelId === panelId ||\n          submittingVariantPanelId === panelId\n        }\n      />\n\n      <StoryboardGroupDialogs\n        insertAfterPanel={insertAfterPanel}\n        nextPanelForInsert={nextPanelForInsert}\n        insertModalOpen={insertModalOpen}\n        insertingAfterPanelId={insertingAfterPanelId}\n        onCloseInsertModal={handleCloseInsertModal}\n        onInsert={handleInsert}\n        variantModalPanel={variantModalPanel}\n        projectId={projectId}\n        submittingVariantPanelId={submittingVariantPanelId}\n        onCloseVariantModal={handleCloseVariantModal}\n        onVariant={handleVariant}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardGroup.types.ts",
    "content": "import type { NovelPromotionStoryboard, NovelPromotionClip, NovelPromotionPanel } from '@/types/project'\nimport type { StoryboardPanel } from './hooks/useStoryboardState'\nimport type { PanelEditData } from '../PanelEditForm'\nimport type { VariantData, VariantOptions } from './hooks/usePanelVariant'\nimport type { PanelSaveState } from './hooks/usePanelCrudActions'\n\nexport interface StoryboardGroupProps {\n  storyboard: NovelPromotionStoryboard\n  clip: NovelPromotionClip | undefined\n  sbIndex: number\n  totalStoryboards: number\n  textPanels: StoryboardPanel[]\n  storyboardStartIndex: number\n  videoRatio: string\n  isExpanded: boolean\n  isSubmittingStoryboardTask: boolean\n  isSelectingCandidate: boolean\n  isSubmittingStoryboardTextTask: boolean\n  hasAnyImage: boolean\n  failedError: string | null\n  savingPanels: Set<string>\n  deletingPanelIds: Set<string>\n  saveStateByPanel: Record<string, PanelSaveState>\n  hasUnsavedByPanel: Set<string>\n  modifyingPanels: Set<string>\n  submittingPanelImageIds: Set<string>\n\n  onToggleExpand: () => void\n  onMoveUp: () => void\n  onMoveDown: () => void\n  onRegenerateText: () => void\n  onAddPanel: () => void\n  onDeleteStoryboard: () => void\n  onGenerateAllIndividually: () => void\n  onPreviewImage: (url: string) => void\n  onCloseError: () => void\n  getPanelEditData: (panel: StoryboardPanel) => PanelEditData\n  onPanelUpdate: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void\n  onPanelDelete: (panelId: string) => void\n  onOpenCharacterPicker: (panelId: string) => void\n  onOpenLocationPicker: (panelId: string) => void\n  onRemoveCharacter: (panel: StoryboardPanel, index: number) => void\n  onRemoveLocation: (panel: StoryboardPanel) => void\n  onRetryPanelSave: (panelId: string) => void\n  onRegeneratePanelImage: (panelId: string, count?: number, force?: boolean) => void\n  onOpenEditModal: (panelIndex: number) => void\n  onOpenAIDataModal: (panelIndex: number) => void\n  getPanelCandidates: (panel: NovelPromotionPanel) => { candidates: string[]; selectedIndex: number } | null\n  onSelectPanelCandidateIndex: (panelId: string, index: number) => void\n  onConfirmPanelCandidate: (panelId: string, imageUrl: string) => Promise<void>\n  onCancelPanelCandidate: (panelId: string) => void\n\n  formatClipTitle: (clip: NovelPromotionClip | undefined) => string\n  movingClipId: string | null\n  onInsertPanel: (storyboardId: string, insertAfterPanelId: string, userInput: string) => Promise<void>\n  insertingAfterPanelId: string | null\n  projectId: string\n  episodeId: string\n  onPanelVariant: (\n    sourcePanelId: string,\n    storyboardId: string,\n    insertAfterPanelId: string,\n    variant: VariantData,\n    options: VariantOptions,\n  ) => Promise<void>\n  submittingVariantPanelId: string | null\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardGroupActions.tsx",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { GlassButton } from '@/components/ui/primitives'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface StoryboardGroupActionsProps {\n  hasAnyImage: boolean\n  isSubmittingStoryboardTask: boolean\n  isSubmittingStoryboardTextTask: boolean\n  currentRunningCount: number\n  pendingCount: number\n  onRegenerateText: () => void\n  onGenerateAllIndividually: () => void\n  onAddPanel: () => void\n  onDeleteStoryboard: () => void\n}\n\nexport default function StoryboardGroupActions({\n  hasAnyImage,\n  isSubmittingStoryboardTask,\n  isSubmittingStoryboardTextTask,\n  currentRunningCount,\n  pendingCount,\n  onRegenerateText,\n  onGenerateAllIndividually,\n  onAddPanel,\n  onDeleteStoryboard,\n}: StoryboardGroupActionsProps) {\n  const t = useTranslations('storyboard')\n\n  const textTaskRunningState = useMemo(() => {\n    if (!isSubmittingStoryboardTextTask) return null\n    return resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'regenerate',\n      resource: 'text',\n      hasOutput: true,\n    })\n  }, [isSubmittingStoryboardTextTask])\n\n  const panelTaskRunningState = useMemo(() => {\n    if (currentRunningCount <= 0) return null\n    return resolveTaskPresentationState({\n      phase: 'processing',\n      intent: hasAnyImage ? 'regenerate' : 'generate',\n      resource: 'image',\n      hasOutput: hasAnyImage,\n    })\n  }, [currentRunningCount, hasAnyImage])\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <GlassButton\n        variant=\"secondary\"\n        size=\"sm\"\n        onClick={onRegenerateText}\n        disabled={isSubmittingStoryboardTextTask}\n      >\n        {isSubmittingStoryboardTextTask ? (\n          <TaskStatusInline state={textTaskRunningState} />\n        ) : (\n          <>\n            <AppIcon name=\"refresh\" className=\"h-3 w-3\" />\n            <span>{t('group.regenerateText')}</span>\n          </>\n        )}\n      </GlassButton>\n\n      {pendingCount > 0 && (\n        <GlassButton\n          variant=\"primary\"\n          size=\"sm\"\n          onClick={onGenerateAllIndividually}\n          disabled={currentRunningCount > 0}\n          title={t('group.generateMissingImages')}\n        >\n          {currentRunningCount > 0 ? (\n            <TaskStatusInline state={panelTaskRunningState} />\n          ) : (\n            <>\n              <AppIcon name=\"plus\" className=\"h-3 w-3\" />\n              <span>{t('group.generateAll')}</span>\n              <span className=\"px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-white/25 text-white\">{pendingCount}</span>\n            </>\n          )}\n        </GlassButton>\n      )}\n\n      <GlassButton\n        variant=\"secondary\"\n        size=\"sm\"\n        onClick={onAddPanel}\n      >\n        <AppIcon name=\"plusMd\" className=\"h-3.5 w-3.5\" />\n        <span>{t('group.addPanel')}</span>\n      </GlassButton>\n\n      <GlassButton\n        variant=\"danger\"\n        size=\"sm\"\n        onClick={onDeleteStoryboard}\n        disabled={isSubmittingStoryboardTask}\n        title={t('common.delete')}\n      >\n        <AppIcon name=\"trashAlt\" className=\"h-3.5 w-3.5\" />\n        <span>{t('common.delete')}</span>\n      </GlassButton>\n    </div>\n  )\n}\n\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardGroupDialogs.tsx",
    "content": "'use client'\n\nimport InsertPanelModal from './InsertPanelModal'\nimport PanelVariantModal from './PanelVariantModal'\nimport type { VariantData, VariantOptions } from './hooks/usePanelVariant'\n\ninterface PanelRuntimeSnapshot {\n  id: string\n  panelNumber: number | null\n  description: string | null\n  imageUrl: string | null\n}\n\ninterface VariantPanelRuntimeSnapshot extends PanelRuntimeSnapshot {\n  storyboardId: string\n}\n\ninterface StoryboardGroupDialogsProps {\n  insertAfterPanel: PanelRuntimeSnapshot | null\n  nextPanelForInsert: PanelRuntimeSnapshot | null\n  insertModalOpen: boolean\n  insertingAfterPanelId: string | null\n  onCloseInsertModal: () => void\n  onInsert: (userInput: string) => Promise<void>\n  variantModalPanel: VariantPanelRuntimeSnapshot | null\n  projectId: string\n  submittingVariantPanelId: string | null\n  onCloseVariantModal: () => void\n  onVariant: (variant: VariantData, options: VariantOptions) => Promise<void>\n}\n\nexport default function StoryboardGroupDialogs({\n  insertAfterPanel,\n  nextPanelForInsert,\n  insertModalOpen,\n  insertingAfterPanelId,\n  onCloseInsertModal,\n  onInsert,\n  variantModalPanel,\n  projectId,\n  submittingVariantPanelId,\n  onCloseVariantModal,\n  onVariant,\n}: StoryboardGroupDialogsProps) {\n  return (\n    <>\n      {insertAfterPanel && (\n        <InsertPanelModal\n          isOpen={insertModalOpen}\n          onClose={onCloseInsertModal}\n          prevPanel={insertAfterPanel}\n          nextPanel={nextPanelForInsert}\n          onInsert={onInsert}\n          isInserting={insertingAfterPanelId === insertAfterPanel.id}\n        />\n      )}\n\n      {variantModalPanel && (\n        <PanelVariantModal\n          isOpen={!!variantModalPanel}\n          onClose={onCloseVariantModal}\n          panel={variantModalPanel}\n          projectId={projectId}\n          onVariant={onVariant}\n          isSubmittingVariantTask={submittingVariantPanelId === variantModalPanel.id}\n        />\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardGroupFailedAlert.tsx",
    "content": "'use client'\n\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface StoryboardGroupFailedAlertProps {\n  failedError: string\n  title: string\n  closeTitle: string\n  onClose: () => void\n}\n\nexport default function StoryboardGroupFailedAlert({\n  failedError,\n  title,\n  closeTitle,\n  onClose,\n}: StoryboardGroupFailedAlertProps) {\n  return (\n    <div className=\"mb-4 rounded-lg border border-[var(--glass-stroke-danger)] bg-[var(--glass-danger-ring)] p-3\">\n      <div className=\"flex items-start gap-3\">\n        <AppIcon name=\"alert\" className=\"mt-0.5 h-5 w-5 shrink-0 text-[var(--glass-tone-danger-fg)]\" />\n        <div className=\"flex-1\">\n          <h4 className=\"text-sm font-bold text-[var(--glass-tone-danger-fg)]\">{title}</h4>\n          <p className=\"mt-1 text-sm text-[var(--glass-tone-danger-fg)]\">{failedError}</p>\n        </div>\n        <button\n          onClick={onClose}\n          className=\"glass-btn-base glass-btn-tone-danger rounded p-1\"\n          title={closeTitle}\n        >\n          <AppIcon name=\"close\" className=\"w-4 h-4\" />\n        </button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardGroupHeader.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { NovelPromotionClip } from '@/types/project'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface StoryboardGroupHeaderProps {\n  clip: NovelPromotionClip | undefined\n  sbIndex: number\n  totalStoryboards: number\n  movingClipId: string | null\n  storyboardClipId: string\n  formatClipTitle: (clip: NovelPromotionClip | undefined) => string\n  onMoveUp: () => void\n  onMoveDown: () => void\n}\n\nexport default function StoryboardGroupHeader({\n  clip,\n  sbIndex,\n  totalStoryboards,\n  movingClipId,\n  storyboardClipId,\n  formatClipTitle,\n  onMoveUp,\n  onMoveDown,\n}: StoryboardGroupHeaderProps) {\n  const t = useTranslations('storyboard')\n\n  return (\n    <div className=\"flex items-center gap-4\">\n      <div className=\"flex flex-col gap-1\">\n        <button\n          onClick={onMoveUp}\n          disabled={sbIndex === 0 || movingClipId === storyboardClipId}\n          className={`rounded p-1 transition-colors ${sbIndex === 0 || movingClipId === storyboardClipId\n            ? 'cursor-not-allowed text-[var(--glass-text-tertiary)]'\n            : 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-tone-info-bg)] hover:text-[var(--glass-tone-info-fg)]'\n            }`}\n          title={t('panel.moveUp')}\n        >\n          <AppIcon name=\"chevronUp\" className=\"w-4 h-4\" />\n        </button>\n        <button\n          onClick={onMoveDown}\n          disabled={sbIndex === totalStoryboards - 1 || movingClipId === storyboardClipId}\n          className={`rounded p-1 transition-colors ${sbIndex === totalStoryboards - 1 || movingClipId === storyboardClipId\n            ? 'cursor-not-allowed text-[var(--glass-text-tertiary)]'\n            : 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-tone-info-bg)] hover:text-[var(--glass-tone-info-fg)]'\n            }`}\n          title={t('panel.moveDown')}\n        >\n          <AppIcon name=\"chevronDown\" className=\"w-4 h-4\" />\n        </button>\n      </div>\n      <div className=\"glass-surface-soft flex h-12 w-12 items-center justify-center rounded-2xl text-2xl font-bold text-[var(--glass-tone-info-fg)]\">\n        {sbIndex + 1}\n      </div>\n      <div>\n        <h3 className=\"text-sm font-medium text-[var(--glass-text-secondary)]\">\n          {t('group.segment')}【{formatClipTitle(clip)}】\n        </h3>\n        <p className=\"mt-0.5 line-clamp-1 text-xs text-[var(--glass-text-tertiary)]\">{clip?.summary}</p>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardHeader.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { GlassButton, GlassChip, GlassSurface } from '@/components/ui/primitives'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\n\ninterface StoryboardHeaderProps {\n  totalSegments: number\n  totalPanels: number\n  isDownloadingImages: boolean\n  runningCount: number\n  pendingPanelCount: number\n  isBatchSubmitting: boolean\n  onDownloadAllImages: () => void\n  onGenerateAllPanels: () => void\n  onBack: () => void\n}\n\nexport default function StoryboardHeader({\n  totalSegments,\n  totalPanels,\n  isDownloadingImages,\n  runningCount,\n  pendingPanelCount,\n  isBatchSubmitting,\n  onDownloadAllImages,\n  onGenerateAllPanels,\n  onBack\n}: StoryboardHeaderProps) {\n  const t = useTranslations('storyboard')\n  const storyboardTaskRunningState = runningCount > 0\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'image',\n      hasOutput: true,\n    })\n    : null\n\n  return (\n    <GlassSurface variant=\"elevated\" className=\"space-y-4 p-4\">\n      <div className=\"flex flex-col gap-4 md:flex-row md:items-center md:justify-between\">\n        <div className=\"space-y-1\">\n          <h3 className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">{t('header.storyboardPanel')}</h3>\n          <p className=\"text-sm text-[var(--glass-text-secondary)]\">\n            {t('header.segmentsCount', { count: totalSegments })}\n            {t('header.panelsCount', { count: totalPanels })}\n          </p>\n        </div>\n\n        <div className=\"flex flex-wrap items-center gap-2\">\n          {runningCount > 0 ? (\n            <GlassChip tone=\"info\" icon={<span className=\"h-2 w-2 animate-pulse rounded-full bg-current\" />}>\n              <span className=\"inline-flex items-center gap-1.5\">\n                <TaskStatusInline state={storyboardTaskRunningState} />\n                <span>({runningCount})</span>\n              </span>\n            </GlassChip>\n          ) : null}\n          <GlassChip tone=\"neutral\">{t('header.concurrencyLimit', { count: 10 })}</GlassChip>\n        </div>\n      </div>\n\n      <div className=\"flex flex-wrap items-center gap-2\">\n        {pendingPanelCount > 0 ? (\n          <GlassButton\n            variant=\"primary\"\n            loading={isBatchSubmitting}\n            onClick={onGenerateAllPanels}\n            disabled={runningCount > 0}\n          >\n            {t('header.generateAllPanels')} ({pendingPanelCount})\n          </GlassButton>\n        ) : null}\n\n        <GlassButton\n          variant=\"secondary\"\n          loading={isDownloadingImages}\n          onClick={onDownloadAllImages}\n          disabled={totalPanels === 0}\n        >\n          {isDownloadingImages ? t('header.downloading') : t('header.downloadAll')}\n        </GlassButton>\n\n        <GlassButton variant=\"ghost\" onClick={onBack}>{t('header.back')}</GlassButton>\n      </div>\n    </GlassSurface>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardPanelList.tsx",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { NovelPromotionPanel } from '@/types/project'\nimport { StoryboardPanel } from './hooks/useStoryboardState'\nimport { PanelEditData } from '../PanelEditForm'\nimport { ASPECT_RATIO_CONFIGS } from '@/lib/constants'\nimport PanelCard from './PanelCard'\nimport type { PanelSaveState } from './hooks/usePanelCrudActions'\n\ninterface StoryboardPanelListProps {\n  storyboardId: string\n  textPanels: StoryboardPanel[]\n  storyboardStartIndex: number\n  videoRatio: string\n  isSubmittingStoryboardTextTask: boolean\n  savingPanels: Set<string>\n  deletingPanelIds: Set<string>\n  saveStateByPanel: Record<string, PanelSaveState>\n  hasUnsavedByPanel: Set<string>\n  modifyingPanels: Set<string>\n  panelTaskErrorMap: Map<string, { taskId: string; message: string }>\n  isPanelTaskRunning: (panel: StoryboardPanel) => boolean\n  getPanelEditData: (panel: StoryboardPanel) => PanelEditData\n  getPanelCandidates: (panel: NovelPromotionPanel) => { candidates: string[]; selectedIndex: number } | null\n  onPanelUpdate: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void\n  onPanelDelete: (panelId: string) => void\n  onOpenCharacterPicker: (panelId: string) => void\n  onOpenLocationPicker: (panelId: string) => void\n  onRemoveCharacter: (panel: StoryboardPanel, index: number) => void\n  onRemoveLocation: (panel: StoryboardPanel) => void\n  onRetryPanelSave: (panelId: string) => void\n  onRegeneratePanelImage: (panelId: string, count?: number, force?: boolean) => void\n  onOpenEditModal: (panelIndex: number) => void\n  onOpenAIDataModal: (panelIndex: number) => void\n  onSelectPanelCandidateIndex: (panelId: string, index: number) => void\n  onConfirmPanelCandidate: (panelId: string, imageUrl: string) => Promise<void>\n  onCancelPanelCandidate: (panelId: string) => void\n  onClearPanelTaskError: (panelId: string) => void\n  onPreviewImage: (url: string) => void\n  onInsertAfter: (panelIndex: number) => void\n  onVariant: (panelIndex: number) => void\n  isInsertDisabled: (panelId: string) => boolean\n}\n\nexport default function StoryboardPanelList({\n  storyboardId,\n  textPanels,\n  storyboardStartIndex,\n  videoRatio,\n  isSubmittingStoryboardTextTask,\n  savingPanels,\n  deletingPanelIds,\n  saveStateByPanel,\n  hasUnsavedByPanel,\n  modifyingPanels,\n  panelTaskErrorMap,\n  isPanelTaskRunning,\n  getPanelEditData,\n  getPanelCandidates,\n  onPanelUpdate,\n  onPanelDelete,\n  onOpenCharacterPicker,\n  onOpenLocationPicker,\n  onRemoveCharacter,\n  onRemoveLocation,\n  onRetryPanelSave,\n  onRegeneratePanelImage,\n  onOpenEditModal,\n  onOpenAIDataModal,\n  onSelectPanelCandidateIndex,\n  onConfirmPanelCandidate,\n  onCancelPanelCandidate,\n  onClearPanelTaskError,\n  onPreviewImage,\n  onInsertAfter,\n  onVariant,\n  isInsertDisabled,\n}: StoryboardPanelListProps) {\n  const displayImages = useMemo(() => textPanels.map((panel) => panel.imageUrl || null), [textPanels])\n  const isVertical = ASPECT_RATIO_CONFIGS[videoRatio]?.isVertical ?? false\n\n  return (\n    <div className={`grid gap-4 ${isVertical ? 'grid-cols-5' : 'grid-cols-3'} ${isSubmittingStoryboardTextTask ? 'opacity-50 pointer-events-none' : ''}`}>\n      {textPanels.map((panel, index) => {\n        const imageUrl = displayImages[index]\n        const globalPanelNumber = storyboardStartIndex + index + 1\n        const isPanelModifying =\n          modifyingPanels.has(panel.id) ||\n          Boolean(\n            (panel as StoryboardPanel & { imageTaskRunning?: boolean; imageTaskIntent?: string }).imageTaskRunning &&\n            (panel as StoryboardPanel & { imageTaskIntent?: string }).imageTaskIntent === 'modify',\n          )\n        const isPanelDeleting = deletingPanelIds.has(panel.id)\n        const panelSaveState = saveStateByPanel[panel.id]\n        const isPanelSaving = savingPanels.has(panel.id) || panelSaveState?.status === 'saving'\n        const hasUnsavedChanges = hasUnsavedByPanel.has(panel.id) || panelSaveState?.status === 'error'\n        const panelSaveError = panelSaveState?.errorMessage || null\n        const panelTaskRunning = isPanelTaskRunning(panel)\n        const taskError = panelTaskErrorMap.get(panel.id)\n        const panelFailedError = taskError?.message || null\n        const panelData = getPanelEditData(panel)\n        const panelCandidateData = getPanelCandidates(panel as unknown as NovelPromotionPanel)\n\n        return (\n          <div\n            key={panel.id || index}\n            className=\"relative group/panel h-full\"\n            style={{ zIndex: textPanels.length - index }}\n          >\n            <PanelCard\n              panel={panel}\n              panelData={panelData}\n              imageUrl={imageUrl}\n              globalPanelNumber={globalPanelNumber}\n              storyboardId={storyboardId}\n              videoRatio={videoRatio}\n              isSaving={isPanelSaving}\n              hasUnsavedChanges={hasUnsavedChanges}\n              saveErrorMessage={panelSaveError}\n              isDeleting={isPanelDeleting}\n              isModifying={isPanelModifying}\n              isSubmittingPanelImageTask={panelTaskRunning}\n              failedError={panelFailedError}\n              candidateData={panelCandidateData}\n              onUpdate={(updates) => onPanelUpdate(panel.id, panel, updates)}\n              onDelete={() => onPanelDelete(panel.id)}\n              onOpenCharacterPicker={() => onOpenCharacterPicker(panel.id)}\n              onOpenLocationPicker={() => onOpenLocationPicker(panel.id)}\n              onRetrySave={() => onRetryPanelSave(panel.id)}\n              onRemoveCharacter={(characterIndex) => onRemoveCharacter(panel, characterIndex)}\n              onRemoveLocation={() => onRemoveLocation(panel)}\n              onRegeneratePanelImage={onRegeneratePanelImage}\n              onOpenEditModal={() => onOpenEditModal(index)}\n              onOpenAIDataModal={() => onOpenAIDataModal(index)}\n              onSelectCandidateIndex={onSelectPanelCandidateIndex}\n              onConfirmCandidate={onConfirmPanelCandidate}\n              onCancelCandidate={onCancelPanelCandidate}\n              onClearError={() => onClearPanelTaskError(panel.id)}\n              onPreviewImage={onPreviewImage}\n              onInsertAfter={() => onInsertAfter(index)}\n              onVariant={() => onVariant(index)}\n              isInsertDisabled={isInsertDisabled(panel.id)}\n            />\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardStageShell.tsx",
    "content": "'use client'\n\nimport { ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\n\ninterface StoryboardStageShellProps {\n  children: ReactNode\n  isTransitioning: boolean\n  isNextDisabled: boolean\n  transitioningState: TaskPresentationState | null\n  onNext: () => void\n}\n\nexport default function StoryboardStageShell({\n  children,\n  isTransitioning,\n  isNextDisabled,\n  transitioningState,\n  onNext,\n}: StoryboardStageShellProps) {\n  const t = useTranslations('storyboard')\n\n  return (\n    <div className=\"space-y-6 pb-20\">\n      {children}\n      <button\n        onClick={onNext}\n        disabled={isNextDisabled}\n        className=\"glass-btn-base glass-btn-primary fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-2xl px-6 py-3 text-white shadow-lg disabled:opacity-50 disabled:cursor-not-allowed\"\n      >\n        {isTransitioning ? (\n          <TaskStatusInline state={transitioningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n        ) : (\n          t('header.generateVideo')\n        )}\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/StoryboardToolbar.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport StoryboardHeader from './StoryboardHeader'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { AppIcon } from '@/components/ui/icons'\nimport { GlassButton } from '@/components/ui/primitives'\n\ninterface StoryboardToolbarProps {\n  totalSegments: number\n  totalPanels: number\n  isDownloadingImages: boolean\n  runningCount: number\n  pendingPanelCount: number\n  isBatchSubmitting: boolean\n  addingStoryboardGroup: boolean\n  addingStoryboardGroupState: TaskPresentationState | null\n  onDownloadAllImages: () => Promise<void>\n  onGenerateAllPanels: () => Promise<void>\n  onAddStoryboardGroupAtStart: () => void\n  onBack: () => void\n}\n\nexport default function StoryboardToolbar({\n  totalSegments,\n  totalPanels,\n  isDownloadingImages,\n  runningCount,\n  pendingPanelCount,\n  isBatchSubmitting,\n  addingStoryboardGroup,\n  addingStoryboardGroupState,\n  onDownloadAllImages,\n  onGenerateAllPanels,\n  onAddStoryboardGroupAtStart,\n  onBack,\n}: StoryboardToolbarProps) {\n  const t = useTranslations('storyboard')\n  return (\n    <>\n      <StoryboardHeader\n        totalSegments={totalSegments}\n        totalPanels={totalPanels}\n        isDownloadingImages={isDownloadingImages}\n        runningCount={runningCount}\n        pendingPanelCount={pendingPanelCount}\n        isBatchSubmitting={isBatchSubmitting}\n        onDownloadAllImages={onDownloadAllImages}\n        onGenerateAllPanels={onGenerateAllPanels}\n        onBack={onBack}\n      />\n\n      <div className=\"flex justify-center\">\n        <GlassButton\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={onAddStoryboardGroupAtStart}\n          disabled={addingStoryboardGroup}\n          className=\"opacity-60 hover:opacity-100\"\n        >\n          {addingStoryboardGroup ? (\n            <TaskStatusInline state={addingStoryboardGroupState} />\n          ) : (\n            <>\n              <AppIcon name=\"plusAlt\" className=\"w-4 h-4\" />\n              <span>{t('group.addAtStart')}</span>\n            </>\n          )}\n        </GlassButton>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/contracts.ts",
    "content": "import type { PanelEditData } from '../../PanelEditForm'\nimport type { StoryboardPanel } from './useStoryboardState'\n\nexport interface StoryboardPanelUpdateContract {\n  panelId: string\n  panel: StoryboardPanel\n  updates: Partial<PanelEditData>\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/image-generation-runtime.ts",
    "content": "import { NovelPromotionPanel, NovelPromotionStoryboard } from '@/types/project'\n\nexport interface StoryboardImageMutationResult {\n  async?: boolean\n  imageUrl?: string\n}\n\nexport function isAbortError(error: unknown): boolean {\n  if (!(error instanceof Error)) return false\n  return error.name === 'AbortError' || error.message === 'Failed to fetch'\n}\n\nexport function getStoryboardPanels(storyboard: NovelPromotionStoryboard): NovelPromotionPanel[] {\n  return Array.isArray(storyboard.panels) ? storyboard.panels : []\n}\n\nexport function updatePanelImageUrlInStoryboards(\n  storyboards: NovelPromotionStoryboard[],\n  storyboardId: string,\n  panelIndex: number,\n  imageUrl: string,\n): NovelPromotionStoryboard[] {\n  return storyboards.map((storyboard) => {\n    if (storyboard.id !== storyboardId) return storyboard\n    const panels = getStoryboardPanels(storyboard)\n    const updatedPanels = panels.map((panel, index) =>\n      index === panelIndex ? { ...panel, imageUrl } : panel,\n    )\n    return { ...storyboard, panels: updatedPanels }\n  })\n}\n\nfunction createPanelMap(storyboards: NovelPromotionStoryboard[]): Map<string, NovelPromotionPanel> {\n  const panelMap = new Map<string, NovelPromotionPanel>()\n  for (const storyboard of storyboards) {\n    const panels = getStoryboardPanels(storyboard)\n    for (const panel of panels) {\n      panelMap.set(panel.id, panel)\n    }\n  }\n  return panelMap\n}\n\nexport function reconcileSubmittingPanelImageIds(\n  previousIds: Set<string>,\n  storyboards: NovelPromotionStoryboard[],\n): Set<string> {\n  const panelMap = createPanelMap(storyboards)\n  let changed = false\n  const next = new Set(previousIds)\n\n  for (const panelId of previousIds) {\n    const panel = panelMap.get(panelId)\n    if (!panel) {\n      next.delete(panelId)\n      changed = true\n      continue\n    }\n\n    const isTaskRunning = Boolean((panel as { imageTaskRunning?: boolean }).imageTaskRunning)\n    const hasError = Boolean((panel as { imageErrorMessage?: string | null }).imageErrorMessage)\n    if (isTaskRunning || hasError) {\n      next.delete(panelId)\n      changed = true\n    }\n  }\n\n  return changed ? next : previousIds\n}\n\nexport function reconcileModifyingPanelIds(\n  previousIds: Set<string>,\n  storyboards: NovelPromotionStoryboard[],\n): Set<string> {\n  const panelMap = createPanelMap(storyboards)\n  let changed = false\n  const next = new Set(previousIds)\n\n  for (const panelId of previousIds) {\n    const panel = panelMap.get(panelId)\n    if (!panel) {\n      next.delete(panelId)\n      changed = true\n      continue\n    }\n\n    const isTaskRunning = Boolean((panel as { imageTaskRunning?: boolean }).imageTaskRunning)\n    const taskIntent = (panel as NovelPromotionPanel & { imageTaskIntent?: string }).imageTaskIntent\n    const hasError = Boolean((panel as { imageErrorMessage?: string | null }).imageErrorMessage)\n    if ((isTaskRunning && taskIntent === 'modify') || hasError) {\n      next.delete(panelId)\n      changed = true\n    }\n  }\n\n  return changed ? next : previousIds\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/panel-candidate-runtime.ts",
    "content": "'use client'\n\nimport type { NovelPromotionPanel } from '@/types/project'\nimport { extractErrorMessage } from '@/lib/errors/extract'\n\nexport interface PanelCandidateData {\n  candidates: string[]\n  selectedIndex: number\n}\n\ninterface CandidateStateLike {\n  candidates: string[]\n  selectedIndex: number\n  originalUrl?: string | null\n  previousUrl?: string | null\n}\n\ninterface PanelCandidateSystemLike {\n  getCandidateState: (id: string) => CandidateStateLike | null | undefined\n  clearCandidates: (id: string) => void\n  initCandidates: (\n    id: string,\n    originalUrl: string | null,\n    candidates: string[],\n    previousUrl: string | null,\n  ) => void\n}\n\nfunction sameStringArray(left: string[], right: string[]) {\n  if (left.length !== right.length) return false\n  for (let index = 0; index < left.length; index += 1) {\n    if (left[index] !== right[index]) return false\n  }\n  return true\n}\n\nfunction parseCandidateImages(candidateImagesStr: string): string[] | null {\n  try {\n    const candidates = JSON.parse(candidateImagesStr)\n    if (!Array.isArray(candidates) || candidates.length === 0) return null\n    const normalized = candidates.filter((candidate: string) => typeof candidate === 'string' && !!candidate)\n    return normalized.length > 0 ? normalized : null\n  } catch {\n    return null\n  }\n}\n\nfunction clearIfExists(system: PanelCandidateSystemLike, panelId: string) {\n  const state = system.getCandidateState(panelId)\n  if (state) {\n    system.clearCandidates(panelId)\n  }\n}\n\nexport function ensurePanelCandidatesInitialized(\n  panel: NovelPromotionPanel,\n  candidateSystem: PanelCandidateSystemLike,\n): boolean {\n  const candidateImagesStr = panel.candidateImages\n  if (!candidateImagesStr) {\n    clearIfExists(candidateSystem, panel.id)\n    return false\n  }\n\n  const candidates = parseCandidateImages(candidateImagesStr)\n  if (!candidates) {\n    clearIfExists(candidateSystem, panel.id)\n    return false\n  }\n\n  const validCandidates = candidates.filter((candidate) => !candidate.startsWith('PENDING:'))\n  if (validCandidates.length === 0) {\n    clearIfExists(candidateSystem, panel.id)\n    return true\n  }\n\n  const existingState = candidateSystem.getCandidateState(panel.id)\n  const shouldRebuildState =\n    !existingState ||\n    !sameStringArray(existingState.candidates, validCandidates) ||\n    (existingState.originalUrl || null) !== (panel.imageUrl || null) ||\n    (existingState.previousUrl || null) !== (panel.previousImageUrl || null)\n\n  if (shouldRebuildState) {\n    candidateSystem.initCandidates(\n      panel.id,\n      panel.imageUrl || null,\n      validCandidates,\n      panel.previousImageUrl || null,\n    )\n  }\n  return true\n}\n\nexport function getPanelCandidatesFromRuntime(\n  panel: NovelPromotionPanel,\n  candidateSystem: PanelCandidateSystemLike,\n): PanelCandidateData | null {\n  const localState = candidateSystem.getCandidateState(panel.id)\n  if (localState && localState.candidates.length > 0) {\n    return {\n      candidates: localState.candidates,\n      selectedIndex: localState.selectedIndex,\n    }\n  }\n\n  const candidateImagesStr = panel.candidateImages\n  if (!candidateImagesStr) return null\n\n  const candidates = parseCandidateImages(candidateImagesStr)\n  if (!candidates) return null\n\n  const validCandidates = candidates.filter((candidate) => !candidate.startsWith('PENDING:'))\n  if (validCandidates.length === 0) {\n    return {\n      candidates,\n      selectedIndex: 0,\n    }\n  }\n\n  return {\n    candidates: validCandidates,\n    selectedIndex: 0,\n  }\n}\n\nexport function getErrorMessage(error: unknown, fallback: string): string {\n  return extractErrorMessage(error, fallback)\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/panel-operations-shared.ts",
    "content": "'use client'\n\nimport type { NovelPromotionPanel, NovelPromotionStoryboard } from '@/types/project'\nimport { extractErrorMessage } from '@/lib/errors/extract'\n\nexport function isAbortError(err: unknown): boolean {\n  return err instanceof Error && (err.name === 'AbortError' || err.message === 'Failed to fetch')\n}\n\nexport function getErrorMessage(error: unknown, fallback: string): string {\n  return extractErrorMessage(error, fallback)\n}\n\nexport function getStoryboardPanels(storyboard: NovelPromotionStoryboard): NovelPromotionPanel[] {\n  return Array.isArray(storyboard.panels) ? storyboard.panels : []\n}\n\nexport interface InsertPanelMutationResult {\n  async?: boolean\n  taskId?: string\n  panelNumber?: number\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/panel-save-coordinator.ts",
    "content": "'use client'\n\nimport type { PanelEditData } from '../../PanelEditForm'\n\nexport type PanelSaveStatus = 'idle' | 'saving' | 'error'\n\nexport interface PanelSaveState {\n  status: PanelSaveStatus\n  errorMessage: string | null\n}\n\ninterface PanelSaveRuntime {\n  inFlight: boolean\n  needsResave: boolean\n  storyboardId: string | null\n  latestSnapshot: PanelEditData | null\n  requestSeq: number\n  processorPromise: Promise<void> | null\n}\n\nexport interface PanelSaveCoordinatorCallbacks {\n  onSavingChange: (panelId: string, isSaving: boolean) => void\n  onStateChange: (panelId: string, state: PanelSaveState) => void\n  runSave: (params: { panelId: string; storyboardId: string; snapshot: PanelEditData }) => Promise<void>\n  resolveErrorMessage: (error: unknown) => string\n}\n\nfunction clonePanelEditData(data: PanelEditData): PanelEditData {\n  return {\n    ...data,\n    characters: data.characters.map((character) => ({ ...character })),\n  }\n}\n\nexport class PanelSaveCoordinator {\n  private callbacks: PanelSaveCoordinatorCallbacks\n\n  private runtimeByPanel: Record<string, PanelSaveRuntime> = {}\n\n  constructor(callbacks: PanelSaveCoordinatorCallbacks) {\n    this.callbacks = callbacks\n  }\n\n  updateCallbacks(callbacks: PanelSaveCoordinatorCallbacks) {\n    this.callbacks = callbacks\n  }\n\n  queue(\n    panelId: string,\n    storyboardId: string,\n    snapshot: PanelEditData | null | undefined,\n  ): Promise<void> | null {\n    if (!snapshot?.id) return null\n\n    const runtime = this.ensureRuntime(panelId)\n    runtime.storyboardId = storyboardId\n    runtime.latestSnapshot = clonePanelEditData(snapshot)\n    runtime.needsResave = true\n    return this.startProcessor(panelId, runtime)\n  }\n\n  retry(panelId: string, snapshotOverride?: PanelEditData | null): Promise<void> | null {\n    const runtime = this.runtimeByPanel[panelId]\n    if (!runtime?.storyboardId) return null\n    const snapshot = snapshotOverride ?? runtime.latestSnapshot\n    if (!snapshot?.id) return null\n    return this.queue(panelId, runtime.storyboardId, snapshot)\n  }\n\n  clear(panelId: string) {\n    delete this.runtimeByPanel[panelId]\n  }\n\n  private ensureRuntime(panelId: string): PanelSaveRuntime {\n    if (!this.runtimeByPanel[panelId]) {\n      this.runtimeByPanel[panelId] = {\n        inFlight: false,\n        needsResave: false,\n        storyboardId: null,\n        latestSnapshot: null,\n        requestSeq: 0,\n        processorPromise: null,\n      }\n    }\n    return this.runtimeByPanel[panelId]\n  }\n\n  private startProcessor(panelId: string, runtime: PanelSaveRuntime): Promise<void> {\n    if (runtime.processorPromise) return runtime.processorPromise\n\n    runtime.processorPromise = (async () => {\n      while (runtime.needsResave) {\n        runtime.needsResave = false\n        runtime.inFlight = true\n        await this.executeSaveAttempt(panelId, runtime)\n        runtime.inFlight = false\n      }\n    })().finally(() => {\n      runtime.processorPromise = null\n      runtime.inFlight = false\n    })\n\n    return runtime.processorPromise\n  }\n\n  private async executeSaveAttempt(panelId: string, runtime: PanelSaveRuntime) {\n    const snapshot = runtime.latestSnapshot\n    const storyboardId = runtime.storyboardId\n    if (!snapshot?.id || !storyboardId) return\n\n    runtime.requestSeq += 1\n    const requestSeq = runtime.requestSeq\n\n    this.callbacks.onSavingChange(panelId, true)\n    this.callbacks.onStateChange(panelId, { status: 'saving', errorMessage: null })\n\n    try {\n      await this.callbacks.runSave({\n        panelId,\n        storyboardId,\n        snapshot,\n      })\n      if (runtime.requestSeq === requestSeq) {\n        this.callbacks.onStateChange(panelId, { status: 'idle', errorMessage: null })\n      }\n    } catch (error: unknown) {\n      if (runtime.requestSeq === requestSeq) {\n        this.callbacks.onStateChange(panelId, {\n          status: 'error',\n          errorMessage: this.callbacks.resolveErrorMessage(error),\n        })\n      }\n    } finally {\n      this.callbacks.onSavingChange(panelId, false)\n    }\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/storyboard-panel-asset-utils.ts",
    "content": "'use client'\n\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { extractErrorMessage } from '@/lib/errors/extract'\nimport type {\n  Character,\n  Location,\n  NovelPromotionClip,\n} from '@/types/project'\nimport type { SelectedAsset } from './useImageGeneration'\n\nexport function getErrorMessage(error: unknown, fallback: string): string {\n  return extractErrorMessage(error, fallback)\n}\n\ninterface BuildDefaultAssetsForClipParams {\n  clipId: string\n  clips: NovelPromotionClip[]\n  characters: Character[]\n  locations: Location[]\n}\n\nexport function buildDefaultAssetsForClip({\n  clipId,\n  clips,\n  characters,\n  locations,\n}: BuildDefaultAssetsForClipParams): SelectedAsset[] {\n  const clip = clips.find((item) => item.id === clipId)\n  if (!clip) return []\n\n  const assets: SelectedAsset[] = []\n\n  if (clip.characters) {\n    try {\n      const characterNames: string[] = JSON.parse(clip.characters)\n      for (const characterName of characterNames) {\n        const character = characters.find(\n          (item) => item.name.toLowerCase() === characterName.toLowerCase(),\n        )\n        if (!character?.appearances) continue\n\n        const appearances = character.appearances || []\n        const firstAppearance = appearances[0]\n        if (!firstAppearance?.imageUrl) continue\n\n        const displayName = appearances.length > 1 && firstAppearance.changeReason\n          ? `${character.name} - ${firstAppearance.changeReason}`\n          : character.name\n        assets.push({\n          id: character.id,\n          name: displayName,\n          type: 'character',\n          imageUrl: firstAppearance.imageUrl,\n          appearanceId: firstAppearance.appearanceIndex,\n          appearanceName: firstAppearance.changeReason,\n        })\n      }\n    } catch (error) {\n      _ulogError('Failed to parse characters:', error)\n    }\n  }\n\n  if (clip.location) {\n    const location = locations.find(\n      (item) => item.name.toLowerCase() === clip.location?.toLowerCase(),\n    )\n    if (!location?.images) return assets\n\n    const selectedImage = location.selectedImageId\n      ? location.images.find((image) => image.id === location.selectedImageId)\n      : location.images.find((image) => image.isSelected) ||\n        location.images.find((image) => image.imageUrl) ||\n        location.images[0]\n\n    if (selectedImage?.imageUrl) {\n      assets.push({\n        id: location.id,\n        name: location.name,\n        type: 'location',\n        imageUrl: selectedImage.imageUrl,\n      })\n    }\n  }\n\n  return assets\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/storyboard-state-utils.ts",
    "content": "'use client'\n\nimport type { NovelPromotionClip, NovelPromotionPanel, NovelPromotionStoryboard } from '@/types/project'\n\nexport function getStoryboardPanels(storyboard: NovelPromotionStoryboard): NovelPromotionPanel[] {\n  return Array.isArray(storyboard.panels) ? storyboard.panels : []\n}\n\nexport function sortStoryboardsByClipOrder(\n  storyboards: NovelPromotionStoryboard[],\n  clips: NovelPromotionClip[],\n): NovelPromotionStoryboard[] {\n  const clipIndexMap = new Map(clips.map((clip, index) => [clip.id, index]))\n  return [...storyboards].sort((a, b) => {\n    const indexA = clipIndexMap.get(a.clipId) ?? Number.MAX_VALUE\n    const indexB = clipIndexMap.get(b.clipId) ?? Number.MAX_VALUE\n    return indexA - indexB\n  })\n}\n\nexport function areStoryboardsEquivalent(\n  previous: NovelPromotionStoryboard[],\n  next: NovelPromotionStoryboard[],\n): boolean {\n  if (previous === next) return true\n  if (previous.length !== next.length) return false\n  return JSON.stringify(previous) === JSON.stringify(next)\n}\n\nexport function buildStoryboardSyncSignature(\n  storyboards: NovelPromotionStoryboard[],\n  clips: NovelPromotionClip[],\n): string {\n  const clipSignature = clips.map((clip) => clip.id).join('|')\n  const storyboardSignature = storyboards.map((storyboard) => {\n    const panels = getStoryboardPanels(storyboard)\n    const panelSignature = panels.map((panel) => {\n      const id = typeof panel.id === 'string' ? panel.id : ''\n      const panelRecord = panel as unknown as Record<string, unknown>\n      const updatedAt = typeof panelRecord.updatedAt === 'string' ? panelRecord.updatedAt : ''\n      const imageUrl = typeof panel.imageUrl === 'string' ? panel.imageUrl : ''\n      const candidateImages = typeof panel.candidateImages === 'string' ? panel.candidateImages : ''\n      const error = typeof panel.imageErrorMessage === 'string' ? panel.imageErrorMessage : ''\n      const runningFlag = panel.imageTaskRunning ? '1' : '0'\n      return `${id}:${updatedAt}:${imageUrl}:${candidateImages}:${error}:${runningFlag}`\n    }).join(',')\n\n    const storyboardRecord = storyboard as unknown as Record<string, unknown>\n    const storyboardUpdatedAt = typeof storyboardRecord.updatedAt === 'string' ? storyboardRecord.updatedAt : ''\n    return `${storyboard.id}:${storyboard.clipId}:${storyboardUpdatedAt}:${panels.length}:${panelSignature}`\n  }).join('||')\n\n  return `${clipSignature}:::${storyboardSignature}`\n}\n\nexport function computeStoryboardStartIndex(\n  sortedStoryboards: NovelPromotionStoryboard[],\n): Record<string, number> {\n  const storyboardStartIndex: Record<string, number> = {}\n  let globalIndex = 0\n\n  for (const storyboard of sortedStoryboards) {\n    storyboardStartIndex[storyboard.id] = globalIndex\n    globalIndex += getStoryboardPanels(storyboard).length || storyboard.panelCount || 0\n  }\n\n  return storyboardStartIndex\n}\n\nexport function computeTotalPanels(storyboards: NovelPromotionStoryboard[]): number {\n  return storyboards.reduce((sum, storyboard) => sum + (getStoryboardPanels(storyboard).length || storyboard.panelCount || 0), 0)\n}\n\nexport function formatClipTitle(clip: NovelPromotionClip | null | undefined): string {\n  if (!clip) return '-'\n  if (clip.start !== undefined && clip.start !== null) {\n    return `${clip.start}-${clip.end}`\n  }\n  if (clip.startText && clip.endText) {\n    const startPreview = clip.startText.substring(0, 10)\n    const endPreview = clip.endText.substring(0, 10)\n    return `${startPreview}...~...${endPreview}`\n  }\n  return clip.id.slice(0, 8)\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useAIDataModalState.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport type {\n  ActingCharacter,\n  ActingNotes,\n  AIDataSavePayload,\n  PhotographyCharacter,\n  PhotographyRules,\n} from '../AIDataModal.types'\n\ninterface UseAIDataModalStateParams {\n  isOpen: boolean\n  syncKey?: string\n  initialShotType: string | null\n  initialCameraMove: string | null\n  initialDescription: string | null\n  initialVideoPrompt: string | null\n  initialPhotographyRules: PhotographyRules | null\n  initialActingNotes: ActingNotes | ActingCharacter[] | null\n}\n\nexport type DirtyField =\n  | 'shotType'\n  | 'cameraMove'\n  | 'description'\n  | 'videoPrompt'\n  | 'photographyRules'\n  | 'actingNotes'\n\nexport interface AIDataModalDraftState {\n  shotType: string\n  cameraMove: string\n  description: string\n  videoPrompt: string\n  photographyRules: PhotographyRules | null\n  actingNotes: ActingCharacter[]\n}\n\nfunction normalizeText(value: string | null): string {\n  return value || ''\n}\n\nfunction clonePhotographyRules(rules: PhotographyRules | null): PhotographyRules | null {\n  if (!rules) return null\n  return {\n    ...rules,\n    lighting: rules.lighting ? { ...rules.lighting } : { direction: '', quality: '' },\n    characters: Array.isArray(rules.characters) ? rules.characters.map((character) => ({ ...character })) : [],\n  }\n}\n\nfunction normalizeActingNotes(notes: ActingNotes | ActingCharacter[] | null): ActingCharacter[] {\n  if (Array.isArray(notes)) return notes.map((character) => ({ ...character }))\n  return (notes?.characters || []).map((character) => ({ ...character }))\n}\n\nexport function createAIDataModalDraftState(params: {\n  initialShotType: string | null\n  initialCameraMove: string | null\n  initialDescription: string | null\n  initialVideoPrompt: string | null\n  initialPhotographyRules: PhotographyRules | null\n  initialActingNotes: ActingNotes | ActingCharacter[] | null\n}): AIDataModalDraftState {\n  return {\n    shotType: normalizeText(params.initialShotType),\n    cameraMove: normalizeText(params.initialCameraMove),\n    description: normalizeText(params.initialDescription),\n    videoPrompt: normalizeText(params.initialVideoPrompt),\n    photographyRules: clonePhotographyRules(params.initialPhotographyRules),\n    actingNotes: normalizeActingNotes(params.initialActingNotes),\n  }\n}\n\nexport function mergeAIDataModalDraftStateByDirty(\n  previous: AIDataModalDraftState,\n  incoming: AIDataModalDraftState,\n  dirtyFields: ReadonlySet<DirtyField>,\n): AIDataModalDraftState {\n  return {\n    shotType: dirtyFields.has('shotType') ? previous.shotType : incoming.shotType,\n    cameraMove: dirtyFields.has('cameraMove') ? previous.cameraMove : incoming.cameraMove,\n    description: dirtyFields.has('description') ? previous.description : incoming.description,\n    videoPrompt: dirtyFields.has('videoPrompt') ? previous.videoPrompt : incoming.videoPrompt,\n    photographyRules: dirtyFields.has('photographyRules') ? previous.photographyRules : incoming.photographyRules,\n    actingNotes: dirtyFields.has('actingNotes') ? previous.actingNotes : incoming.actingNotes,\n  }\n}\n\nexport function useAIDataModalState({\n  isOpen,\n  syncKey,\n  initialShotType,\n  initialCameraMove,\n  initialDescription,\n  initialVideoPrompt,\n  initialPhotographyRules,\n  initialActingNotes,\n}: UseAIDataModalStateParams) {\n  const initialDraftState = createAIDataModalDraftState({\n    initialShotType,\n    initialCameraMove,\n    initialDescription,\n    initialVideoPrompt,\n    initialPhotographyRules,\n    initialActingNotes,\n  })\n  const [shotType, setShotTypeState] = useState(initialDraftState.shotType)\n  const [cameraMove, setCameraMoveState] = useState(initialDraftState.cameraMove)\n  const [description, setDescriptionState] = useState(initialDraftState.description)\n  const [videoPrompt, setVideoPromptState] = useState(initialDraftState.videoPrompt)\n  const [photographyRules, setPhotographyRulesState] = useState<PhotographyRules | null>(initialDraftState.photographyRules)\n  const [actingNotes, setActingNotesState] = useState<ActingCharacter[]>(initialDraftState.actingNotes)\n  const [dirtyFields, setDirtyFields] = useState<Set<DirtyField>>(new Set())\n  const hydratedSyncKeyRef = useRef<string | null>(null)\n  const latestDraftStateRef = useRef<AIDataModalDraftState>(initialDraftState)\n  const effectiveSyncKey = syncKey ?? '__default_ai_data_sync_key__'\n\n  useEffect(() => {\n    latestDraftStateRef.current = {\n      shotType,\n      cameraMove,\n      description,\n      videoPrompt,\n      photographyRules,\n      actingNotes,\n    }\n  }, [actingNotes, cameraMove, description, photographyRules, shotType, videoPrompt])\n\n  const hydrateFromInitial = useCallback(() => {\n    const nextDraft = createAIDataModalDraftState({\n      initialShotType,\n      initialCameraMove,\n      initialDescription,\n      initialVideoPrompt,\n      initialPhotographyRules,\n      initialActingNotes,\n    })\n    latestDraftStateRef.current = nextDraft\n    setShotTypeState(nextDraft.shotType)\n    setCameraMoveState(nextDraft.cameraMove)\n    setDescriptionState(nextDraft.description)\n    setVideoPromptState(nextDraft.videoPrompt)\n    setPhotographyRulesState(nextDraft.photographyRules)\n    setActingNotesState(nextDraft.actingNotes)\n    setDirtyFields(new Set())\n  }, [\n    initialActingNotes,\n    initialCameraMove,\n    initialDescription,\n    initialPhotographyRules,\n    initialShotType,\n    initialVideoPrompt,\n  ])\n\n  useEffect(() => {\n    if (!isOpen) {\n      hydratedSyncKeyRef.current = null\n      return\n    }\n\n    if (hydratedSyncKeyRef.current !== effectiveSyncKey) {\n      hydrateFromInitial()\n      hydratedSyncKeyRef.current = effectiveSyncKey\n    }\n  }, [effectiveSyncKey, hydrateFromInitial, isOpen])\n\n  useEffect(() => {\n    if (!isOpen || hydratedSyncKeyRef.current !== effectiveSyncKey) return\n\n    const incomingDraft = createAIDataModalDraftState({\n      initialShotType,\n      initialCameraMove,\n      initialDescription,\n      initialVideoPrompt,\n      initialPhotographyRules,\n      initialActingNotes,\n    })\n    const mergedDraft = mergeAIDataModalDraftStateByDirty(\n      latestDraftStateRef.current,\n      incomingDraft,\n      dirtyFields,\n    )\n    latestDraftStateRef.current = mergedDraft\n\n    setShotTypeState(mergedDraft.shotType)\n    setCameraMoveState(mergedDraft.cameraMove)\n    setDescriptionState(mergedDraft.description)\n    setVideoPromptState(mergedDraft.videoPrompt)\n    setPhotographyRulesState(mergedDraft.photographyRules)\n    setActingNotesState(mergedDraft.actingNotes)\n  }, [\n    dirtyFields,\n    effectiveSyncKey,\n    initialActingNotes,\n    initialCameraMove,\n    initialDescription,\n    initialPhotographyRules,\n    initialShotType,\n    initialVideoPrompt,\n    isOpen,\n  ])\n\n  const markDirty = useCallback((field: DirtyField) => {\n    setDirtyFields((previous) => {\n      if (previous.has(field)) return previous\n      const next = new Set(previous)\n      next.add(field)\n      return next\n    })\n  }, [])\n\n  const setShotType = useCallback((value: string) => {\n    markDirty('shotType')\n    setShotTypeState(value)\n  }, [markDirty])\n\n  const setCameraMove = useCallback((value: string) => {\n    markDirty('cameraMove')\n    setCameraMoveState(value)\n  }, [markDirty])\n\n  const setDescription = useCallback((value: string) => {\n    markDirty('description')\n    setDescriptionState(value)\n  }, [markDirty])\n\n  const setVideoPrompt = useCallback((value: string) => {\n    markDirty('videoPrompt')\n    setVideoPromptState(value)\n  }, [markDirty])\n\n  const updatePhotographyField = (path: string, value: string) => {\n    if (!photographyRules) return\n    const nextRules = clonePhotographyRules(photographyRules)\n    if (!nextRules) return\n    const parts = path.split('.')\n\n    if (parts.length === 1) {\n      const field = parts[0]\n      if (field === 'scene_summary' || field === 'depth_of_field' || field === 'color_tone') {\n        nextRules[field] = value\n      }\n    } else if (parts.length === 2) {\n      const [group, field] = parts\n      if (group === 'lighting' && (field === 'direction' || field === 'quality')) {\n        nextRules.lighting = { ...nextRules.lighting, [field]: value }\n      }\n    }\n\n    markDirty('photographyRules')\n    setPhotographyRulesState(nextRules)\n  }\n\n  const updatePhotographyCharacter = (index: number, field: keyof PhotographyCharacter, value: string) => {\n    if (!photographyRules) return\n    const nextRules = clonePhotographyRules(photographyRules)\n    if (!nextRules) return\n    nextRules.characters[index] = { ...nextRules.characters[index], [field]: value }\n    markDirty('photographyRules')\n    setPhotographyRulesState(nextRules)\n  }\n\n  const updateActingCharacter = (index: number, field: keyof ActingCharacter, value: string) => {\n    const nextNotes = actingNotes.map((note, noteIndex) => (\n      noteIndex === index ? { ...note, [field]: value } : note\n    ))\n    markDirty('actingNotes')\n    setActingNotesState(nextNotes)\n  }\n\n  const savePayload = useMemo<AIDataSavePayload>(() => ({\n    shotType: shotType || null,\n    cameraMove: cameraMove || null,\n    description: description || null,\n    videoPrompt: videoPrompt || null,\n    photographyRules,\n    actingNotes: actingNotes.length > 0 ? actingNotes : null,\n  }), [actingNotes, cameraMove, description, photographyRules, shotType, videoPrompt])\n\n  return {\n    shotType,\n    setShotType,\n    cameraMove,\n    setCameraMove,\n    description,\n    setDescription,\n    videoPrompt,\n    setVideoPrompt,\n    photographyRules,\n    actingNotes,\n    updatePhotographyField,\n    updatePhotographyCharacter,\n    updateActingCharacter,\n    savePayload,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useImageGeneration.ts",
    "content": "'use client'\n\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useState, useCallback, useEffect } from 'react'\nimport { NovelPromotionStoryboard } from '@/types/project'\nimport { usePanelCandidates } from './usePanelCandidates'\nimport {\n  useClearProjectStoryboardError,\n  useRefreshProjectAssets,\n  useRefreshEpisodeData,\n  useRefreshStoryboards,\n  useRegenerateProjectPanelImage,\n  useModifyProjectStoryboardImage,\n  useDownloadProjectImages,\n} from '@/lib/query/hooks'\nimport {\n  getStoryboardPanels,\n  reconcileModifyingPanelIds,\n  reconcileSubmittingPanelImageIds,\n} from './image-generation-runtime'\nimport { usePanelImageRegeneration } from './usePanelImageRegeneration'\nimport { usePanelImageModification } from './usePanelImageModification'\nimport { usePanelImageDownload } from './usePanelImageDownload'\n\nexport interface SelectedAsset {\n  id: string\n  name: string\n  type: 'character' | 'location'\n  imageUrl: string | null\n  appearanceId?: number\n  appearanceName?: string\n}\n\ninterface UseStoryboardImageGenerationProps {\n  projectId: string\n  episodeId?: string\n  localStoryboards: NovelPromotionStoryboard[]\n  setLocalStoryboards: React.Dispatch<React.SetStateAction<NovelPromotionStoryboard[]>>\n}\n\nexport function useStoryboardImageGeneration({\n  projectId,\n  episodeId,\n  localStoryboards,\n  setLocalStoryboards,\n}: UseStoryboardImageGenerationProps) {\n  const onSilentRefresh = useRefreshProjectAssets(projectId)\n  const refreshEpisode = useRefreshEpisodeData(projectId, episodeId ?? null)\n  const refreshStoryboards = useRefreshStoryboards(episodeId ?? null)\n  const regeneratePanelMutation = useRegenerateProjectPanelImage(projectId)\n  const modifyPanelMutation = useModifyProjectStoryboardImage(projectId)\n  const downloadImagesMutation = useDownloadProjectImages(projectId)\n  const clearStoryboardErrorMutation = useClearProjectStoryboardError(projectId)\n\n  const submittingStoryboardIds = new Set<string>(\n    localStoryboards\n      .filter((storyboard) => storyboard.storyboardTaskRunning)\n      .map((storyboard) => storyboard.id),\n  )\n\n  const [submittingPanelImageIds, setSubmittingPanelImageIds] = useState<Set<string>>(new Set())\n  const [selectingCandidateIds] = useState<Set<string>>(new Set())\n  const [editingPanel, setEditingPanel] = useState<{ storyboardId: string; panelIndex: number } | null>(null)\n  const [modifyingPanels, setModifyingPanels] = useState<Set<string>>(new Set())\n  const [isDownloadingImages, setIsDownloadingImages] = useState(false)\n  const [previewImage, setPreviewImage] = useState<string | null>(null)\n\n  const {\n    panelCandidateIndex,\n    setPanelCandidateIndex,\n    getPanelCandidates,\n    ensurePanelCandidatesInitialized,\n    selectPanelCandidateIndex,\n    confirmPanelCandidate,\n    cancelPanelCandidate,\n  } = usePanelCandidates({\n    projectId,\n    episodeId,\n    onConfirmed: (panelId, confirmedImageUrl) => {\n      setLocalStoryboards((previousStoryboards) =>\n        previousStoryboards.map((storyboard) => {\n          const panels = getStoryboardPanels(storyboard)\n          let changed = false\n          const updatedPanels = panels.map((panel) => {\n            if (panel.id !== panelId) return panel\n            changed = true\n            return {\n              ...panel,\n              imageUrl: confirmedImageUrl ?? panel.imageUrl,\n              candidateImages: null,\n              imageTaskRunning: false,\n            }\n          })\n          return changed ? { ...storyboard, panels: updatedPanels } : storyboard\n        }),\n      )\n    },\n  })\n\n  useEffect(() => {\n    localStoryboards.forEach((storyboard) => {\n      getStoryboardPanels(storyboard).forEach((panel) => {\n        ensurePanelCandidatesInitialized(panel)\n      })\n    })\n  }, [ensurePanelCandidatesInitialized, localStoryboards])\n\n  useEffect(() => {\n    if (submittingPanelImageIds.size === 0) return\n    setSubmittingPanelImageIds((previousIds) =>\n      reconcileSubmittingPanelImageIds(previousIds, localStoryboards),\n    )\n  }, [localStoryboards, submittingPanelImageIds.size])\n\n  useEffect(() => {\n    if (modifyingPanels.size === 0) return\n    setModifyingPanels((previousIds) => reconcileModifyingPanelIds(previousIds, localStoryboards))\n  }, [localStoryboards, modifyingPanels.size])\n\n  const { regeneratePanelImage, regenerateAllPanelsIndividually } = usePanelImageRegeneration({\n    localStoryboards,\n    setLocalStoryboards,\n    submittingPanelImageIds,\n    setSubmittingPanelImageIds,\n    onSilentRefresh,\n    refreshEpisode,\n    refreshStoryboards,\n    regeneratePanelMutation,\n    selectPanelCandidateIndex,\n  })\n\n  const { modifyPanelImage } = usePanelImageModification({\n    localStoryboards,\n    setLocalStoryboards,\n    modifyPanelMutation,\n    setModifyingPanels,\n    onSilentRefresh,\n    refreshEpisode,\n    refreshStoryboards,\n  })\n\n  const { downloadAllImages } = usePanelImageDownload({\n    localStoryboards,\n    downloadImagesMutation,\n    setIsDownloadingImages,\n  })\n\n  const clearStoryboardError = useCallback(async (storyboardId: string) => {\n    let snapshot: NovelPromotionStoryboard[] | null = null\n    setLocalStoryboards((previousStoryboards) =>\n      {\n        snapshot = previousStoryboards\n        return previousStoryboards.map((storyboard) =>\n        storyboard.id === storyboardId ? { ...storyboard, lastError: null } : storyboard,\n      )\n      },\n    )\n\n    try {\n      await clearStoryboardErrorMutation.mutateAsync({ storyboardId })\n      if (onSilentRefresh) {\n        await onSilentRefresh()\n      }\n      refreshEpisode()\n      refreshStoryboards()\n    } catch (error: unknown) {\n      if (snapshot) {\n        setLocalStoryboards(snapshot)\n      }\n      _ulogError('[clearStoryboardError] persist failed:', error)\n    }\n  }, [\n    clearStoryboardErrorMutation,\n    onSilentRefresh,\n    refreshEpisode,\n    refreshStoryboards,\n    setLocalStoryboards,\n  ])\n\n  return {\n    submittingStoryboardIds,\n    submittingPanelImageIds,\n    selectingCandidateIds,\n    panelCandidateIndex,\n    setPanelCandidateIndex,\n    editingPanel,\n    setEditingPanel,\n    modifyingPanels,\n    isDownloadingImages,\n    previewImage,\n    setPreviewImage,\n    regeneratePanelImage,\n    regenerateAllPanelsIndividually,\n    selectPanelCandidate: confirmPanelCandidate,\n    selectPanelCandidateIndex,\n    cancelPanelCandidate,\n    getPanelCandidates,\n    modifyPanelImage,\n    downloadAllImages,\n    clearStoryboardError,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelCandidates.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\nimport { useCallback } from 'react'\nimport type { NovelPromotionPanel } from '@/types/project'\nimport { useCandidateSystem } from '@/hooks/common/useCandidateSystem'\nimport {\n  useRefreshProjectAssets,\n  useRefreshEpisodeData,\n  useRefreshStoryboards,\n} from '@/lib/query/hooks'\nimport { useSelectProjectPanelCandidate } from '@/lib/query/mutations/useProjectMutations'\nimport {\n  ensurePanelCandidatesInitialized,\n  getErrorMessage,\n  getPanelCandidatesFromRuntime,\n  type PanelCandidateData,\n} from './panel-candidate-runtime'\nimport { usePanelEpisodeCachePatch } from './usePanelEpisodeCachePatch'\n\ninterface UsePanelCandidatesProps {\n  projectId: string\n  episodeId?: string\n  onConfirmed?: (panelId: string, imageUrl: string | null) => void\n}\n\ninterface SelectPanelCandidateResult {\n  imageUrl?: string\n}\n\nexport function usePanelCandidates({\n  projectId,\n  episodeId,\n  onConfirmed,\n}: UsePanelCandidatesProps) {\n  const t = useTranslations('storyboard')\n  const onSilentRefresh = useRefreshProjectAssets(projectId)\n  const refreshEpisode = useRefreshEpisodeData(projectId, episodeId ?? null)\n  const refreshStoryboards = useRefreshStoryboards(episodeId ?? null)\n  const selectCandidateMutation = useSelectProjectPanelCandidate(projectId)\n\n  const candidateSystem = useCandidateSystem<string>()\n  const patchPanelInEpisodeCache = usePanelEpisodeCachePatch({\n    projectId,\n    episodeId,\n  })\n\n  const handleEnsurePanelCandidatesInitialized = useCallback((panel: NovelPromotionPanel): boolean => {\n    return ensurePanelCandidatesInitialized(panel, candidateSystem)\n  }, [candidateSystem])\n\n  const getPanelCandidates = useCallback((panel: NovelPromotionPanel): PanelCandidateData | null => {\n    return getPanelCandidatesFromRuntime(panel, candidateSystem)\n  }, [candidateSystem])\n\n  const selectPanelCandidateIndex = useCallback((panelId: string, index: number) => {\n    candidateSystem.selectCandidate(panelId, index)\n  }, [candidateSystem])\n\n  const confirmPanelCandidate = useCallback(async (panelId: string, imageUrl: string) => {\n    try {\n      _ulogInfo('[confirmPanelCandidate] 🎯 开始确认候选图片')\n      _ulogInfo('[confirmPanelCandidate] panelId:', panelId)\n      _ulogInfo('[confirmPanelCandidate] imageUrl:', imageUrl.substring(0, 100))\n\n      const data = await selectCandidateMutation.mutateAsync({\n        panelId,\n        selectedImageUrl: imageUrl,\n        action: 'select',\n      })\n      const result = (data || {}) as SelectPanelCandidateResult\n\n      candidateSystem.clearCandidates(panelId)\n      _ulogInfo('[confirmPanelCandidate] ✅ 已清除本地候选状态')\n\n      const confirmedImageUrl = result.imageUrl || imageUrl\n      onConfirmed?.(panelId, confirmedImageUrl)\n      patchPanelInEpisodeCache(panelId, {\n        imageUrl: confirmedImageUrl,\n        candidateImages: null,\n        imageTaskRunning: false,\n        imageErrorMessage: null,\n      })\n\n      if (onSilentRefresh) {\n        await onSilentRefresh()\n      }\n      refreshEpisode()\n      refreshStoryboards()\n      _ulogInfo('[confirmPanelCandidate] ✅ 数据刷新完成')\n    } catch (error: unknown) {\n      _ulogError('[confirmPanelCandidate] ❌ 确认失败:', error)\n      alert(\n        t('messages.selectCandidateFailed', {\n          error: getErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n    }\n  }, [\n    candidateSystem,\n    onConfirmed,\n    onSilentRefresh,\n    patchPanelInEpisodeCache,\n    refreshEpisode,\n    refreshStoryboards,\n    selectCandidateMutation,\n    t,\n  ])\n\n  const cancelPanelCandidate = useCallback(async (panelId: string) => {\n    try {\n      await selectCandidateMutation.mutateAsync({\n        panelId,\n        action: 'cancel',\n      })\n\n      candidateSystem.clearCandidates(panelId)\n      patchPanelInEpisodeCache(panelId, {\n        candidateImages: null,\n        imageTaskRunning: false,\n      })\n\n      if (onSilentRefresh) {\n        await onSilentRefresh()\n      }\n      refreshEpisode()\n      refreshStoryboards()\n    } catch (error: unknown) {\n      _ulogError('取消选择失败:', error)\n    }\n  }, [\n    candidateSystem,\n    onSilentRefresh,\n    patchPanelInEpisodeCache,\n    refreshEpisode,\n    refreshStoryboards,\n    selectCandidateMutation,\n  ])\n\n  const hasPanelCandidates = useCallback((panel: NovelPromotionPanel): boolean => {\n    return getPanelCandidates(panel) !== null\n  }, [getPanelCandidates])\n\n  return {\n    panelCandidateIndex: candidateSystem.states,\n    setPanelCandidateIndex: candidateSystem.selectCandidate,\n    getPanelCandidates,\n    ensurePanelCandidatesInitialized: handleEnsurePanelCandidatesInitialized,\n    selectPanelCandidateIndex,\n    confirmPanelCandidate,\n    cancelPanelCandidate,\n    hasPanelCandidates,\n    candidateSystem,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelCrudActions.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport type { PanelEditData } from '../../PanelEditForm'\nimport type { StoryboardPanel } from './useStoryboardState'\nimport type { NovelPromotionStoryboard } from '@/types/project'\nimport {\n  PanelSaveCoordinator,\n  type PanelSaveState,\n  type PanelSaveStatus,\n} from './panel-save-coordinator'\nimport {\n  useCreateProjectPanel,\n  useDeleteProjectPanel,\n  useUpdateProjectPanel,\n} from '@/lib/query/hooks'\nimport {\n  getErrorMessage,\n  getStoryboardPanels,\n  isAbortError,\n} from './panel-operations-shared'\nimport { syncPanelCharacterDependentJson } from '@/lib/novel-promotion/panel-ai-data-sync'\n\ninterface UsePanelCrudActionsProps {\n  projectId: string\n  panelEditsRef: React.MutableRefObject<Record<string, PanelEditData>>\n  onRefresh: () => Promise<void> | void\n}\n\nexport type { PanelSaveState, PanelSaveStatus }\n\nexport function usePanelCrudActions({\n  projectId,\n  panelEditsRef,\n  onRefresh,\n}: UsePanelCrudActionsProps) {\n  const t = useTranslations('storyboard')\n  const [savingPanels, setSavingPanels] = useState<Set<string>>(new Set())\n  const [deletingPanelIds, setDeletingPanelIds] = useState<Set<string>>(new Set())\n  const [saveStateByPanel, setSaveStateByPanel] = useState<Record<string, PanelSaveState>>({})\n  const saveTimeouts = useRef<Record<string, NodeJS.Timeout>>({})\n  const panelSaveCoordinatorRef = useRef<PanelSaveCoordinator | null>(null)\n\n  const savePanelMutation = useUpdateProjectPanel(projectId)\n  const createPanelMutation = useCreateProjectPanel(projectId)\n  const deletePanelMutation = useDeleteProjectPanel(projectId)\n\n  const setPanelSaveState = useCallback((panelId: string, nextState: PanelSaveState) => {\n    setSaveStateByPanel((previous) => {\n      const previousState = previous[panelId]\n      if (\n        previousState\n        && previousState.status === nextState.status\n        && previousState.errorMessage === nextState.errorMessage\n      ) {\n        return previous\n      }\n      return {\n        ...previous,\n        [panelId]: nextState,\n      }\n    })\n  }, [])\n\n  if (!panelSaveCoordinatorRef.current) {\n    panelSaveCoordinatorRef.current = new PanelSaveCoordinator({\n      onSavingChange: (panelId, isSaving) => {\n        setSavingPanels((previous) => {\n          const next = new Set(previous)\n          if (isSaving) {\n            next.add(panelId)\n          } else {\n            next.delete(panelId)\n          }\n          return next\n        })\n      },\n      onStateChange: setPanelSaveState,\n      runSave: async ({ storyboardId, snapshot }) => {\n        await savePanelMutation.mutateAsync({\n          storyboardId,\n          panelIndex: snapshot.panelIndex,\n          id: snapshot.id,\n          panelNumber: snapshot.panelNumber,\n          shotType: snapshot.shotType,\n          cameraMove: snapshot.cameraMove,\n          description: snapshot.description,\n          location: snapshot.location,\n          characters: JSON.stringify(snapshot.characters),\n          srtStart: snapshot.srtStart,\n          srtEnd: snapshot.srtEnd,\n          duration: snapshot.duration,\n          videoPrompt: snapshot.videoPrompt,\n          photographyRules: snapshot.photographyRules,\n          actingNotes: snapshot.actingNotes,\n        })\n      },\n      resolveErrorMessage: (error) => {\n        _ulogError('保存失败:', error)\n        return getErrorMessage(error, t('common.unknownError'))\n      },\n    })\n  }\n\n  panelSaveCoordinatorRef.current.updateCallbacks({\n    onSavingChange: (panelId, isSaving) => {\n      setSavingPanels((previous) => {\n        const next = new Set(previous)\n        if (isSaving) {\n          next.add(panelId)\n        } else {\n          next.delete(panelId)\n        }\n        return next\n      })\n    },\n    onStateChange: setPanelSaveState,\n    runSave: async ({ storyboardId, snapshot }) => {\n      await savePanelMutation.mutateAsync({\n        storyboardId,\n        panelIndex: snapshot.panelIndex,\n        id: snapshot.id,\n        panelNumber: snapshot.panelNumber,\n        shotType: snapshot.shotType,\n        cameraMove: snapshot.cameraMove,\n        description: snapshot.description,\n        location: snapshot.location,\n        characters: JSON.stringify(snapshot.characters),\n        srtStart: snapshot.srtStart,\n        srtEnd: snapshot.srtEnd,\n        duration: snapshot.duration,\n        videoPrompt: snapshot.videoPrompt,\n        photographyRules: snapshot.photographyRules,\n        actingNotes: snapshot.actingNotes,\n      })\n    },\n    resolveErrorMessage: (error) => {\n      _ulogError('保存失败:', error)\n      return getErrorMessage(error, t('common.unknownError'))\n    },\n  })\n\n  const queuePanelSave = useCallback((\n    panelId: string,\n    storyboardId: string,\n    snapshotOverride?: PanelEditData,\n  ): Promise<void> | null => {\n    const sourceSnapshot = snapshotOverride ?? panelEditsRef.current[panelId]\n    return panelSaveCoordinatorRef.current?.queue(panelId, storyboardId, sourceSnapshot) ?? null\n  }, [panelEditsRef])\n\n  const savePanel = useCallback(async (storyboardId: string, panelIdOrData: string | PanelEditData) => {\n    const panelId = typeof panelIdOrData === 'string' ? panelIdOrData : panelIdOrData.id\n    if (!panelId) return\n    const queued = queuePanelSave(\n      panelId,\n      storyboardId,\n      typeof panelIdOrData === 'string' ? undefined : panelIdOrData,\n    )\n    if (queued) {\n      await queued\n    }\n  }, [queuePanelSave])\n\n  const debouncedSave = useCallback((panelId: string, storyboardId: string) => {\n    if (saveTimeouts.current[panelId]) {\n      clearTimeout(saveTimeouts.current[panelId])\n    }\n    saveTimeouts.current[panelId] = setTimeout(() => {\n      void queuePanelSave(panelId, storyboardId)\n    }, 500)\n  }, [queuePanelSave])\n\n  useEffect(() => () => {\n    Object.values(saveTimeouts.current).forEach((timeoutId) => clearTimeout(timeoutId))\n  }, [])\n\n  const retrySave = useCallback((panelId: string) => {\n    if (saveTimeouts.current[panelId]) {\n      clearTimeout(saveTimeouts.current[panelId])\n      delete saveTimeouts.current[panelId]\n    }\n\n    const latestSnapshot = panelEditsRef.current[panelId]\n    const queued = panelSaveCoordinatorRef.current?.retry(panelId, latestSnapshot)\n    if (queued) {\n      void queued\n    }\n  }, [panelEditsRef])\n\n  const hasUnsavedByPanel = useMemo(() => {\n    const panelIds = Object.entries(saveStateByPanel)\n      .filter(([, state]) => state.status === 'error')\n      .map(([panelId]) => panelId)\n    return new Set(panelIds)\n  }, [saveStateByPanel])\n\n  const addPanel = useCallback(async (storyboardId: string) => {\n    try {\n      await createPanelMutation.mutateAsync({\n        storyboardId,\n        shotType: t('variant.defaultShotType'),\n        cameraMove: t('variant.defaultCameraMove'),\n        description: t('panel.newPanelDescription'),\n        videoPrompt: '',\n        characters: '[]',\n      })\n      await onRefresh()\n    } catch (error: unknown) {\n      _ulogError('添加分镜失败:', error)\n      alert(\n        t('messages.addPanelFailed', {\n          error: getErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n    }\n  }, [createPanelMutation, onRefresh, t])\n\n  const deletePanel = useCallback(async (\n    panelId: string,\n    storyboardId: string,\n    setLocalStoryboards: React.Dispatch<React.SetStateAction<NovelPromotionStoryboard[]>>,\n  ) => {\n    if (!confirm(t('confirm.deletePanel'))) return\n    setDeletingPanelIds((previous) => new Set(previous).add(panelId))\n\n    try {\n      await deletePanelMutation.mutateAsync({ panelId })\n      setLocalStoryboards((previous) => previous.map((storyboard) => {\n        if (storyboard.id !== storyboardId) return storyboard\n        const panels = getStoryboardPanels(storyboard)\n        const updatedPanels = panels.filter((panel) => panel.id !== panelId)\n        return { ...storyboard, panels: updatedPanels }\n      }))\n    } catch (error: unknown) {\n      if (isAbortError(error)) {\n        _ulogInfo('请求被中断（可能是页面刷新），后端仍在执行')\n        return\n      }\n      alert(\n        t('messages.deletePanelFailed', {\n          error: getErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n    } finally {\n      setDeletingPanelIds((previous) => {\n        const next = new Set(previous)\n        next.delete(panelId)\n        return next\n      })\n      setSavingPanels((previous) => {\n        const next = new Set(previous)\n        next.delete(panelId)\n        return next\n      })\n      setSaveStateByPanel((previous) => {\n        if (!(panelId in previous)) return previous\n        const rest = { ...previous }\n        delete rest[panelId]\n        return rest\n      })\n      panelSaveCoordinatorRef.current?.clear(panelId)\n      if (saveTimeouts.current[panelId]) {\n        clearTimeout(saveTimeouts.current[panelId])\n        delete saveTimeouts.current[panelId]\n      }\n    }\n  }, [deletePanelMutation, t])\n\n  const addCharacterToPanel = useCallback((\n    panel: StoryboardPanel,\n    characterName: string,\n    appearance: string,\n    storyboardId: string,\n    getPanelEditData: (panel: StoryboardPanel) => PanelEditData,\n    updatePanelEdit: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void,\n  ) => {\n    const currentData = getPanelEditData(panel)\n    const exists = currentData.characters.some(\n      (item) => item.name === characterName && item.appearance === appearance,\n    )\n    if (exists) return\n    updatePanelEdit(panel.id, panel, {\n      characters: [...currentData.characters, { name: characterName, appearance }],\n    })\n    debouncedSave(panel.id, storyboardId)\n  }, [debouncedSave])\n\n  const removeCharacterFromPanel = useCallback((\n    panel: StoryboardPanel,\n    index: number,\n    storyboardId: string,\n    getPanelEditData: (panel: StoryboardPanel) => PanelEditData,\n    updatePanelEdit: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void,\n  ) => {\n    const currentData = getPanelEditData(panel)\n    const synced = syncPanelCharacterDependentJson({\n      characters: currentData.characters,\n      removeIndex: index,\n      actingNotesJson: currentData.actingNotes,\n      photographyRulesJson: currentData.photographyRules,\n    })\n    const updates: Partial<PanelEditData> = {\n      characters: synced.characters,\n    }\n    if (synced.actingNotesJson !== undefined) {\n      updates.actingNotes = synced.actingNotesJson\n    }\n    if (synced.photographyRulesJson !== undefined) {\n      updates.photographyRules = synced.photographyRulesJson\n    }\n    updatePanelEdit(panel.id, panel, {\n      ...updates,\n    })\n    debouncedSave(panel.id, storyboardId)\n  }, [debouncedSave])\n\n  const setPanelLocation = useCallback((\n    panel: StoryboardPanel,\n    locationName: string | null,\n    storyboardId: string,\n    updatePanelEdit: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void,\n  ) => {\n    updatePanelEdit(panel.id, panel, { location: locationName })\n    debouncedSave(panel.id, storyboardId)\n  }, [debouncedSave])\n\n  return {\n    savingPanels,\n    deletingPanelIds,\n    saveStateByPanel,\n    hasUnsavedByPanel,\n    savePanel,\n    savePanelWithData: savePanel,\n    debouncedSave,\n    retrySave,\n    addPanel,\n    deletePanel,\n    addCharacterToPanel,\n    removeCharacterFromPanel,\n    setPanelLocation,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelEpisodeCachePatch.ts",
    "content": "'use client'\n\nimport { useCallback } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport type { NovelPromotionStoryboard } from '@/types/project'\nimport { queryKeys } from '@/lib/query/keys'\n\ntype EpisodeDataCache = Record<string, unknown> & {\n  storyboards?: NovelPromotionStoryboard[]\n}\n\nfunction isEpisodeDataCache(value: unknown): value is EpisodeDataCache {\n  return typeof value === 'object' && value !== null\n}\n\ninterface UsePanelEpisodeCachePatchParams {\n  projectId: string\n  episodeId?: string\n}\n\nexport function usePanelEpisodeCachePatch({\n  projectId,\n  episodeId,\n}: UsePanelEpisodeCachePatchParams) {\n  const queryClient = useQueryClient()\n\n  return useCallback((panelId: string, updates: Record<string, unknown>) => {\n    if (!episodeId) return\n    queryClient.setQueryData(queryKeys.episodeData(projectId, episodeId), (previous: unknown) => {\n      if (!isEpisodeDataCache(previous) || !Array.isArray(previous.storyboards)) return previous\n\n      let changed = false\n      const storyboards = previous.storyboards.map((storyboard) => {\n        const panels = Array.isArray(storyboard?.panels) ? storyboard.panels : []\n        let panelChanged = false\n        const nextPanels = panels.map((panel) => {\n          if (panel?.id !== panelId) return panel\n          panelChanged = true\n          changed = true\n          return {\n            ...panel,\n            ...updates,\n          }\n        })\n\n        if (!panelChanged) return storyboard\n        return {\n          ...storyboard,\n          panels: nextPanels,\n        }\n      })\n\n      if (!changed) return previous\n      return {\n        ...previous,\n        storyboards,\n      }\n    })\n  }, [episodeId, projectId, queryClient])\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelImageDownload.ts",
    "content": "'use client'\n\nimport { useCallback } from 'react'\nimport { useTranslations } from 'next-intl'\nimport type { NovelPromotionStoryboard } from '@/types/project'\nimport { extractErrorMessage } from '@/lib/errors/extract'\n\ninterface DownloadImagesMutationLike {\n  mutateAsync: (payload: { episodeId: string }) => Promise<Blob>\n}\n\ninterface UsePanelImageDownloadParams {\n  localStoryboards: NovelPromotionStoryboard[]\n  downloadImagesMutation: DownloadImagesMutationLike\n  setIsDownloadingImages: React.Dispatch<React.SetStateAction<boolean>>\n}\n\nexport function usePanelImageDownload({\n  localStoryboards,\n  downloadImagesMutation,\n  setIsDownloadingImages,\n}: UsePanelImageDownloadParams) {\n  const t = useTranslations('storyboard')\n  const downloadAllImages = useCallback(async () => {\n    const firstEpisodeId = localStoryboards[0]?.episodeId\n    if (!firstEpisodeId) {\n      alert(t('messages.episodeNotFound'))\n      return\n    }\n\n    setIsDownloadingImages(true)\n    try {\n      const blob = await downloadImagesMutation.mutateAsync({ episodeId: firstEpisodeId })\n      const url = window.URL.createObjectURL(blob)\n      const anchor = document.createElement('a')\n      anchor.href = url\n      anchor.download = 'images.zip'\n      document.body.appendChild(anchor)\n      anchor.click()\n      window.URL.revokeObjectURL(url)\n      document.body.removeChild(anchor)\n    } catch (error: unknown) {\n      alert(\n        t('messages.downloadFailed', {\n          error: extractErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n    } finally {\n      setIsDownloadingImages(false)\n    }\n  }, [downloadImagesMutation, localStoryboards, setIsDownloadingImages, t])\n\n  return {\n    downloadAllImages,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelImageModification.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\nimport { useCallback } from 'react'\nimport type { NovelPromotionStoryboard } from '@/types/project'\nimport { extractErrorMessage } from '@/lib/errors/extract'\nimport type { SelectedAsset } from './useImageGeneration'\nimport {\n  StoryboardImageMutationResult,\n  getStoryboardPanels,\n  isAbortError,\n  updatePanelImageUrlInStoryboards,\n} from './image-generation-runtime'\n\ninterface ModifyPanelMutationLike {\n  mutateAsync: (payload: {\n    storyboardId: string\n    panelIndex: number\n    modifyPrompt: string\n    extraImageUrls: string[]\n    selectedAssets: SelectedAsset[]\n  }) => Promise<unknown>\n}\n\ninterface UsePanelImageModificationParams {\n  localStoryboards: NovelPromotionStoryboard[]\n  setLocalStoryboards: React.Dispatch<React.SetStateAction<NovelPromotionStoryboard[]>>\n  modifyPanelMutation: ModifyPanelMutationLike\n  setModifyingPanels: React.Dispatch<React.SetStateAction<Set<string>>>\n  onSilentRefresh?: (() => void | Promise<void>) | null\n  refreshEpisode: () => void\n  refreshStoryboards: () => void\n}\n\nexport function usePanelImageModification({\n  localStoryboards,\n  setLocalStoryboards,\n  modifyPanelMutation,\n  setModifyingPanels,\n  onSilentRefresh,\n  refreshEpisode,\n  refreshStoryboards,\n}: UsePanelImageModificationParams) {\n  const t = useTranslations('storyboard')\n  const modifyPanelImage = useCallback(\n    async (\n      storyboardId: string,\n      panelIndex: number,\n      prompt: string,\n      images: string[],\n      assets: SelectedAsset[],\n    ) => {\n      const storyboard = localStoryboards.find((item) => item.id === storyboardId)\n      const panels = storyboard ? getStoryboardPanels(storyboard) : []\n      const panel = panels[panelIndex]\n      const panelId = panel?.id\n\n      if (!panelId) {\n        _ulogError('[modifyPanelImage] Panel not found:', { storyboardId, panelIndex })\n        alert(t('messages.panelNotFound'))\n        return\n      }\n\n      setModifyingPanels((previous) => new Set(previous).add(panelId))\n      let isAsync = false\n      try {\n        const data = await modifyPanelMutation.mutateAsync({\n          storyboardId,\n          panelIndex,\n          modifyPrompt: prompt,\n          extraImageUrls: images,\n          selectedAssets: assets,\n        })\n        const result = (data || {}) as StoryboardImageMutationResult\n\n        if (result.async) {\n          _ulogInfo(`[Modify Panel] 异步任务已提交: ${panelId}`)\n          isAsync = true\n          if (onSilentRefresh) {\n            await onSilentRefresh()\n          }\n          refreshEpisode()\n          refreshStoryboards()\n          return\n        }\n\n        if (result.imageUrl) {\n          setLocalStoryboards((previous) =>\n            updatePanelImageUrlInStoryboards(previous, storyboardId, panelIndex, result.imageUrl as string),\n          )\n        }\n      } catch (error: unknown) {\n        if (isAbortError(error)) {\n          _ulogInfo('请求被中断（可能是页面刷新），后端仍在执行')\n          return\n        }\n        alert(\n          t('messages.modifyFailed', {\n            error: extractErrorMessage(error, t('common.unknownError')),\n          }),\n        )\n      } finally {\n        if (!isAsync) {\n          setModifyingPanels((previous) => {\n            const next = new Set(previous)\n            next.delete(panelId)\n            return next\n          })\n        }\n      }\n    },\n    [\n      localStoryboards,\n      modifyPanelMutation,\n      onSilentRefresh,\n      refreshEpisode,\n      refreshStoryboards,\n      setLocalStoryboards,\n      setModifyingPanels,\n      t,\n    ],\n  )\n\n  return {\n    modifyPanelImage,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelImageRegeneration.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logWarn as _ulogWarn } from '@/lib/logging/core'\n\nimport { useCallback } from 'react'\nimport type { NovelPromotionStoryboard } from '@/types/project'\nimport {\n  StoryboardImageMutationResult,\n  getStoryboardPanels,\n  isAbortError,\n} from './image-generation-runtime'\n\ninterface RegeneratePanelMutationLike {\n  mutateAsync: (payload: { panelId: string; count: number }) => Promise<unknown>\n}\n\ninterface UsePanelImageRegenerationParams {\n  localStoryboards: NovelPromotionStoryboard[]\n  setLocalStoryboards: React.Dispatch<React.SetStateAction<NovelPromotionStoryboard[]>>\n  submittingPanelImageIds: Set<string>\n  setSubmittingPanelImageIds: React.Dispatch<React.SetStateAction<Set<string>>>\n  onSilentRefresh?: (() => void | Promise<void>) | null\n  refreshEpisode: () => void\n  refreshStoryboards: () => void\n  regeneratePanelMutation: RegeneratePanelMutationLike\n  selectPanelCandidateIndex: (panelId: string, index: number) => void\n}\n\nexport function usePanelImageRegeneration({\n  localStoryboards,\n  submittingPanelImageIds,\n  setSubmittingPanelImageIds,\n  onSilentRefresh,\n  refreshEpisode,\n  refreshStoryboards,\n  regeneratePanelMutation,\n  selectPanelCandidateIndex,\n}: UsePanelImageRegenerationParams) {\n  const regeneratePanelImage = useCallback(\n    async (panelId: string, count: number = 1, force: boolean = false) => {\n      if (!force && submittingPanelImageIds.has(panelId)) return\n\n      setSubmittingPanelImageIds((previous) => new Set(previous).add(panelId))\n\n      let handoffToTaskState = false\n      try {\n        const data = await regeneratePanelMutation.mutateAsync({ panelId, count })\n        const result = (data || {}) as StoryboardImageMutationResult\n\n        if (result.async) {\n          _ulogInfo(`[regeneratePanelImage] async submitted: ${panelId}`)\n          handoffToTaskState = true\n          if (onSilentRefresh) {\n            await onSilentRefresh()\n          }\n          refreshEpisode()\n          refreshStoryboards()\n          return\n        }\n\n        if (onSilentRefresh) {\n          await onSilentRefresh()\n        }\n        refreshEpisode()\n        refreshStoryboards()\n        selectPanelCandidateIndex(panelId, 0)\n      } catch (error: unknown) {\n        if (isAbortError(error)) return\n        // Mutation errors (e.g. network failure, API 500) are transient.\n        // The task was never created in the database, so we log and let user retry.\n        _ulogWarn(`[regeneratePanelImage] mutation failed for panel ${panelId}:`, error)\n      } finally {\n        if (handoffToTaskState) return\n        setSubmittingPanelImageIds((previous) => {\n          const next = new Set(previous)\n          next.delete(panelId)\n          return next\n        })\n      }\n    },\n    [\n      onSilentRefresh,\n      refreshEpisode,\n      refreshStoryboards,\n      regeneratePanelMutation,\n      selectPanelCandidateIndex,\n      setSubmittingPanelImageIds,\n      submittingPanelImageIds,\n    ],\n  )\n\n  const regenerateAllPanelsIndividually = useCallback(async (storyboardId: string) => {\n    const storyboard = localStoryboards.find((item) => item.id === storyboardId)\n    if (!storyboard) return\n\n    const panels = getStoryboardPanels(storyboard)\n    if (panels.length === 0) return\n\n    const panelsToGenerate = panels.filter(\n      (panel) => !panel.imageUrl && !panel.imageTaskRunning && !submittingPanelImageIds.has(panel.id),\n    )\n    if (panelsToGenerate.length === 0) return\n\n    await Promise.all(panelsToGenerate.map((panel) => regeneratePanelImage(panel.id)))\n  }, [localStoryboards, regeneratePanelImage, submittingPanelImageIds])\n\n  return {\n    regeneratePanelImage,\n    regenerateAllPanelsIndividually,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelInsertActions.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\nimport { useCallback, useState } from 'react'\nimport { useInsertProjectPanel } from '@/lib/query/hooks'\nimport { waitForTaskResult } from '@/lib/task/client'\nimport { getErrorMessage, isAbortError, type InsertPanelMutationResult } from './panel-operations-shared'\n\ninterface UsePanelInsertActionsProps {\n  projectId: string\n  onRefresh: () => Promise<void> | void\n}\n\nexport function usePanelInsertActions({\n  projectId,\n  onRefresh,\n}: UsePanelInsertActionsProps) {\n  const t = useTranslations('storyboard')\n  const [insertingAfterPanelId, setInsertingAfterPanelId] = useState<string | null>(null)\n  const insertPanelMutation = useInsertProjectPanel(projectId)\n\n  const insertPanel = useCallback(async (storyboardId: string, panelId: string, userInput: string) => {\n    if (insertingAfterPanelId) return\n    setInsertingAfterPanelId(panelId)\n\n    try {\n      const data = await insertPanelMutation.mutateAsync({\n        storyboardId,\n        insertAfterPanelId: panelId,\n        userInput,\n      })\n      const result = (data || {}) as InsertPanelMutationResult\n      if (result.async && result.taskId) {\n        const taskId = result.taskId\n        _ulogInfo(`[Insert Panel] 占位分镜已创建: #${result.panelNumber}，后台生成内容...`)\n        setInsertingAfterPanelId(null)\n        await onRefresh()\n\n        ; (async () => {\n          try {\n            await waitForTaskResult(taskId, {\n              intervalMs: 3000,\n              timeoutMs: 120000,\n            })\n            _ulogInfo('[Insert Panel] AI内容+图片生成完成，刷新数据')\n          } catch (error: unknown) {\n            _ulogError(`[Insert Panel] 任务终止: ${getErrorMessage(error, t('common.unknownError'))}`)\n          } finally {\n            await onRefresh()\n          }\n        })()\n        return\n      }\n\n      await onRefresh()\n      setInsertingAfterPanelId(null)\n    } catch (error: unknown) {\n      if (isAbortError(error)) {\n        _ulogInfo('请求被中断（可能是页面刷新）')\n        return\n      }\n      _ulogError('插入分镜失败:', error)\n      alert(\n        t('messages.insertPanelFailed', {\n          error: getErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n      setInsertingAfterPanelId(null)\n    }\n  }, [insertPanelMutation, insertingAfterPanelId, onRefresh, t])\n\n  return {\n    insertingAfterPanelId,\n    insertPanel,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelOperations.ts",
    "content": "'use client'\n\nimport type { PanelEditData } from '../../PanelEditForm'\nimport { useRefreshProjectAssets } from '@/lib/query/hooks'\nimport { usePanelCrudActions } from './usePanelCrudActions'\nimport { usePanelInsertActions } from './usePanelInsertActions'\nimport { useStoryboardGroupActions } from './useStoryboardGroupActions'\n\ninterface UsePanelOperationsProps {\n  projectId: string\n  episodeId: string\n  panelEditsRef: React.MutableRefObject<Record<string, PanelEditData>>\n}\n\nexport function usePanelOperations({\n  projectId,\n  episodeId,\n  panelEditsRef,\n}: UsePanelOperationsProps) {\n  const onRefresh = useRefreshProjectAssets(projectId)\n\n  const panelCrud = usePanelCrudActions({\n    projectId,\n    panelEditsRef,\n    onRefresh,\n  })\n\n  const groupActions = useStoryboardGroupActions({\n    projectId,\n    episodeId,\n    onRefresh,\n  })\n\n  const panelInsert = usePanelInsertActions({\n    projectId,\n    onRefresh,\n  })\n\n  return {\n    savingPanels: panelCrud.savingPanels,\n    deletingPanelIds: panelCrud.deletingPanelIds,\n    saveStateByPanel: panelCrud.saveStateByPanel,\n    hasUnsavedByPanel: panelCrud.hasUnsavedByPanel,\n    submittingStoryboardTextIds: groupActions.submittingStoryboardTextIds,\n    addingStoryboardGroup: groupActions.addingStoryboardGroup,\n    movingClipId: groupActions.movingClipId,\n    insertingAfterPanelId: panelInsert.insertingAfterPanelId,\n\n    savePanel: panelCrud.savePanel,\n    savePanelWithData: panelCrud.savePanelWithData,\n    debouncedSave: panelCrud.debouncedSave,\n    retrySave: panelCrud.retrySave,\n    addPanel: panelCrud.addPanel,\n    deletePanel: panelCrud.deletePanel,\n    deleteStoryboard: groupActions.deleteStoryboard,\n    regenerateStoryboardText: groupActions.regenerateStoryboardText,\n    addStoryboardGroup: groupActions.addStoryboardGroup,\n    moveStoryboardGroup: groupActions.moveStoryboardGroup,\n    addCharacterToPanel: panelCrud.addCharacterToPanel,\n    removeCharacterFromPanel: panelCrud.removeCharacterFromPanel,\n    setPanelLocation: panelCrud.setPanelLocation,\n    insertPanel: panelInsert.insertPanel,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelVariant.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\nimport { useState, useCallback } from 'react'\nimport { useCreateProjectPanelVariant, useRefreshEpisodeData } from '@/lib/query/hooks'\nimport { NovelPromotionStoryboard, NovelPromotionPanel } from '@/types/project'\n\n/**\n * usePanelVariant - 镜头变体操作 Hook\n * \n * 管理镜头变体相关的状态和操作\n * 🔥 使用乐观更新：点击后立即插入占位 panel，不等待 API 响应\n */\n\nexport interface VariantData {\n    title: string\n    description: string\n    shot_type: string\n    camera_move: string\n    video_prompt: string\n}\n\nexport interface VariantOptions {\n    includeCharacterAssets: boolean\n    includeLocationAsset: boolean\n}\n\ninterface VariantModalState {\n    panelId: string\n    panelNumber: number | null\n    description: string | null\n    imageUrl: string | null\n    storyboardId: string\n}\n\ninterface UsePanelVariantProps {\n    projectId: string\n    episodeId: string\n    // 🔥 需要 setLocalStoryboards 来实现乐观更新\n    setLocalStoryboards: React.Dispatch<React.SetStateAction<NovelPromotionStoryboard[]>>\n}\n\nexport function usePanelVariant({ projectId, episodeId, setLocalStoryboards }: UsePanelVariantProps) {\n    const t = useTranslations('storyboard')\n    // 🔥 使用 React Query 刷新 - 刷新 episodeData（包含 storyboards 和 panels）\n    const onRefresh = useRefreshEpisodeData(projectId, episodeId)\n    const createPanelVariantMutation = useCreateProjectPanelVariant(projectId)\n    // 变体模态框状态\n    const [variantModalState, setVariantModalState] = useState<VariantModalState | null>(null)\n\n    // 正在提交变体任务的 Panel ID\n    const [submittingVariantPanelId, setSubmittingVariantPanelId] = useState<string | null>(null)\n\n    // 打开变体模态框\n    const openVariantModal = useCallback((panel: VariantModalState) => {\n        setVariantModalState(panel)\n    }, [])\n\n    // 关闭变体模态框\n    const closeVariantModal = useCallback(() => {\n        setVariantModalState(null)\n    }, [])\n\n    // 执行变体生成\n    const generatePanelVariant = useCallback(async (\n        sourcePanelId: string,\n        storyboardId: string,\n        insertAfterPanelId: string,\n        variant: VariantData,\n        options: VariantOptions\n    ): Promise<void> => {\n        setSubmittingVariantPanelId(sourcePanelId)\n\n        // 🔥 乐观更新：立即在本地状态中插入临时占位 panel\n        const tempPanelId = `temp-variant-${Date.now()}`\n        setLocalStoryboards(prev => prev.map(sb => {\n            if (sb.id !== storyboardId) return sb\n\n            // 找到插入位置\n            const panels: NovelPromotionPanel[] = sb.panels || []\n            const insertIndex = panels.findIndex((panel) => panel.id === insertAfterPanelId)\n            if (insertIndex === -1) return sb\n\n            // 创建临时占位 panel\n            const tempPanel: NovelPromotionPanel = {\n                id: tempPanelId,\n                storyboardId,\n                panelIndex: insertIndex + 1,\n                panelNumber: (panels[insertIndex]?.panelNumber || 0) + 0.5, // 临时编号\n                description: variant.description || t('variant.generating'),\n                shotType: variant.shot_type || null,\n                cameraMove: variant.camera_move || null,\n                videoPrompt: variant.video_prompt || null,\n                imageUrl: null,\n                imageTaskRunning: true, // 🔥 显示加载状态\n                characters: null,\n                location: null,\n                candidateImages: null,\n                srtSegment: null,\n                srtStart: null,\n                srtEnd: null,\n                duration: null,\n                imagePrompt: null,\n                media: null,\n                imageHistory: null,\n                videoUrl: null,\n                videoMedia: null,\n                lipSyncVideoUrl: null,\n                lipSyncVideoMedia: null,\n                sketchImageUrl: null,\n                sketchImageMedia: null,\n                previousImageUrl: null,\n                previousImageMedia: null,\n                photographyRules: null,\n                actingNotes: null,\n                imageErrorMessage: null,\n            }\n\n            // 插入临时 panel\n            const newPanels = [\n                ...panels.slice(0, insertIndex + 1),\n                tempPanel,\n                ...panels.slice(insertIndex + 1)\n            ]\n\n            _ulogInfo('[usePanelVariant] 🎯 乐观更新：插入临时占位 panel', tempPanelId)\n\n            return {\n                ...sb,\n                panels: newPanels\n            }\n        }))\n\n        // 🔥 立即关闭模态框（不等待 API）\n        setVariantModalState(null)\n\n        try {\n            const data = await createPanelVariantMutation.mutateAsync({\n                storyboardId,\n                insertAfterPanelId,\n                sourcePanelId,\n                variant,\n                includeCharacterAssets: options.includeCharacterAssets,\n                includeLocationAsset: options.includeLocationAsset,\n            })\n\n            // API 成功：Panel 已在服务端创建（无图片），用真实 panelId 替换临时 ID\n            // 这样 task state 监控能正确匹配到这个 panel\n            const realPanelId = data?.panelId\n            _ulogInfo('[usePanelVariant] ✅ API 成功，realPanelId:', realPanelId)\n\n            if (realPanelId) {\n                setLocalStoryboards(prev => prev.map(sb => {\n                    if (sb.id !== storyboardId) return sb\n                    const panels = (sb.panels || []).map(p =>\n                        p.id === tempPanelId ? { ...p, id: realPanelId } : p,\n                    )\n                    return { ...sb, panels }\n                }))\n            }\n\n            // 刷新获取完整的服务端状态\n            if (onRefresh) {\n                await onRefresh()\n            }\n        } catch (error) {\n            // API 失败：移除临时 panel 并显示错误\n            setLocalStoryboards(prev => prev.map(sb => {\n                if (sb.id !== storyboardId) return sb\n                const panels = (sb.panels || []).filter((panel) => panel.id !== tempPanelId)\n                return { ...sb, panels }\n            }))\n            _ulogError('[usePanelVariant] 生成变体失败:', error)\n            throw error\n        } finally {\n            setSubmittingVariantPanelId(null)\n        }\n    }, [createPanelVariantMutation, onRefresh, setLocalStoryboards, t])\n\n    // 处理模态框中的变体选择\n    const handleVariantSelect = useCallback(async (\n        variant: VariantData,\n        options: VariantOptions\n    ) => {\n        if (!variantModalState) return\n\n        // 在原 panel 之后插入变体\n        await generatePanelVariant(\n            variantModalState.panelId,\n            variantModalState.storyboardId,\n            variantModalState.panelId, // 在当前 panel 之后插入\n            variant,\n            options\n        )\n    }, [variantModalState, generatePanelVariant])\n\n    return {\n        // 状态\n        variantModalState,\n        submittingVariantPanelId,\n        isVariantModalOpen: !!variantModalState,\n        isSubmittingVariantTask: !!submittingVariantPanelId,\n\n        // 操作\n        openVariantModal,\n        closeVariantModal,\n        generatePanelVariant,\n        handleVariantSelect\n    }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardAiDataRuntime.ts",
    "content": "'use client'\n\nimport { logWarn as _ulogWarn, logError as _ulogError } from '@/lib/logging/core'\nimport { useCallback, useMemo } from 'react'\nimport type { NovelPromotionStoryboard } from '@/types/project'\nimport type { PanelEditData } from '../../PanelEditForm'\nimport type { StoryboardPanel } from './useStoryboardState'\nimport { serializeStructuredJsonField } from '@/lib/novel-promotion/panel-ai-data-sync'\n\ninterface AIDataPanelRef {\n  storyboardId: string\n  panelIndex: number\n}\n\ninterface PhotographyPlanMutation {\n  mutateAsync: (payload: { storyboardId: string; photographyPlan: string }) => Promise<unknown>\n}\n\ninterface ActingNotesMutation {\n  mutateAsync: (payload: { storyboardId: string; panelIndex: number; actingNotes: string }) => Promise<unknown>\n}\n\ninterface UseStoryboardAiDataRuntimeParams {\n  aiDataPanel: AIDataPanelRef | null\n  localStoryboards: NovelPromotionStoryboard[]\n  getTextPanels: (storyboard: NovelPromotionStoryboard) => StoryboardPanel[]\n  getPanelEditData: (panel: StoryboardPanel) => PanelEditData\n  updatePanelEdit: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void\n  savePanelWithData: (storyboardId: string, panelIdOrData: string | PanelEditData) => void | Promise<void>\n  updatePhotographyPlanMutation: PhotographyPlanMutation\n  updatePanelActingNotesMutation: ActingNotesMutation\n}\n\nfunction parseJsonSafely(value: unknown, logLabel: string) {\n  if (!value) return null\n  try {\n    return typeof value === 'string' ? JSON.parse(value) : value\n  } catch (error) {\n    _ulogWarn(`Failed to parse ${logLabel}:`, error)\n    return null\n  }\n}\n\nexport function useStoryboardAiDataRuntime({\n  aiDataPanel,\n  localStoryboards,\n  getTextPanels,\n  getPanelEditData,\n  updatePanelEdit,\n  savePanelWithData,\n  updatePhotographyPlanMutation,\n  updatePanelActingNotesMutation,\n}: UseStoryboardAiDataRuntimeParams) {\n  const aiDataRuntime = useMemo(() => {\n    if (!aiDataPanel) return null\n\n    const storyboard = localStoryboards.find((item) => item.id === aiDataPanel.storyboardId)\n    if (!storyboard) return null\n\n    const textPanels = getTextPanels(storyboard)\n    const panel = textPanels[aiDataPanel.panelIndex]\n    if (!panel) return null\n\n    const panelData = getPanelEditData(panel)\n    const photographyRules = parseJsonSafely(panel.photographyRules, 'photographyRules')\n    const actingNotes = parseJsonSafely(panel.actingNotes, 'actingNotes')\n    const characterNames = panelData.characters.map((character) => character.name)\n\n    return {\n      panelData,\n      panel,\n      storyboardId: storyboard.id,\n      characterNames,\n      photographyRules,\n      actingNotes,\n    }\n  }, [aiDataPanel, getPanelEditData, getTextPanels, localStoryboards])\n\n  const handleSaveAIData = useCallback(async (data: {\n    shotType: string | null\n    cameraMove: string | null\n    description: string | null\n    videoPrompt: string | null\n    photographyRules: unknown\n    actingNotes: unknown\n  }) => {\n    if (!aiDataRuntime) return\n\n    const { panelData, panel, storyboardId } = aiDataRuntime\n    const serializedPhotographyRules = serializeStructuredJsonField(data.photographyRules, 'photographyRules')\n    const serializedActingNotes = serializeStructuredJsonField(data.actingNotes, 'actingNotes')\n    const updatedPanelData: PanelEditData = {\n      ...panelData,\n      shotType: data.shotType,\n      cameraMove: data.cameraMove,\n      description: data.description,\n      videoPrompt: data.videoPrompt,\n      photographyRules: serializedPhotographyRules,\n      actingNotes: serializedActingNotes,\n    }\n\n    updatePanelEdit(panel.id, panel, {\n      shotType: data.shotType,\n      cameraMove: data.cameraMove,\n      description: data.description,\n      videoPrompt: data.videoPrompt,\n      photographyRules: serializedPhotographyRules,\n      actingNotes: serializedActingNotes,\n    })\n\n    await savePanelWithData(storyboardId, updatedPanelData)\n\n    if (data.photographyRules) {\n      try {\n        const photographyPlan =\n          typeof data.photographyRules === 'string'\n            ? data.photographyRules\n            : JSON.stringify(data.photographyRules)\n        await updatePhotographyPlanMutation.mutateAsync({\n          storyboardId,\n          photographyPlan,\n        })\n      } catch (error) {\n        _ulogError('保存摄影规则失败:', error)\n      }\n    }\n\n    if (data.actingNotes !== undefined) {\n      try {\n        const actingNotesPayload =\n          typeof data.actingNotes === 'string'\n            ? data.actingNotes\n            : JSON.stringify(data.actingNotes ?? null)\n        await updatePanelActingNotesMutation.mutateAsync({\n          storyboardId,\n          panelIndex: panel.panelIndex,\n          actingNotes: actingNotesPayload,\n        })\n      } catch (error) {\n        _ulogError('保存演技指导失败:', error)\n      }\n    }\n  }, [\n    aiDataRuntime,\n    savePanelWithData,\n    updatePanelActingNotesMutation,\n    updatePanelEdit,\n    updatePhotographyPlanMutation,\n  ])\n\n  return {\n    aiDataRuntime,\n    handleSaveAIData,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardBatchPanelGeneration.ts",
    "content": "'use client'\n\nimport { useCallback, useMemo } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport type { NovelPromotionStoryboard } from '@/types/project'\nimport type { StoryboardPanel } from './useStoryboardState'\nimport { getErrorMessage } from './storyboard-panel-asset-utils'\n\ninterface UseStoryboardBatchPanelGenerationProps {\n  sortedStoryboards: NovelPromotionStoryboard[]\n  submittingPanelImageIds: Set<string>\n  getTextPanels: (storyboard: NovelPromotionStoryboard) => StoryboardPanel[]\n  regeneratePanelImage: (panelId: string, count?: number, force?: boolean) => Promise<void>\n  setIsEpisodeBatchSubmitting: (value: boolean) => void\n}\n\nexport function useStoryboardBatchPanelGeneration({\n  sortedStoryboards,\n  submittingPanelImageIds,\n  getTextPanels,\n  regeneratePanelImage,\n  setIsEpisodeBatchSubmitting,\n}: UseStoryboardBatchPanelGenerationProps) {\n  const t = useTranslations('storyboard')\n  const runningCount = useMemo(() => {\n    return sortedStoryboards.reduce((count, storyboard) => {\n      const panels = getTextPanels(storyboard)\n      return count + panels.filter((panel) => panel.imageTaskRunning || submittingPanelImageIds.has(panel.id)).length\n    }, 0)\n  }, [getTextPanels, sortedStoryboards, submittingPanelImageIds])\n\n  const pendingPanelCount = useMemo(() => {\n    return sortedStoryboards.reduce((count, storyboard) => {\n      const panels = getTextPanels(storyboard)\n      return (\n        count +\n        panels.filter(\n          (panel) => !panel.imageUrl && !panel.imageTaskRunning && !submittingPanelImageIds.has(panel.id),\n        ).length\n      )\n    }, 0)\n  }, [getTextPanels, sortedStoryboards, submittingPanelImageIds])\n\n  const handleGenerateAllPanels = useCallback(async () => {\n    setIsEpisodeBatchSubmitting(true)\n    try {\n      const panelsToGenerate: string[] = []\n      sortedStoryboards.forEach((storyboard) => {\n        const panels = getTextPanels(storyboard)\n        panels.forEach((panel) => {\n          const isTaskRunning =\n            Boolean((panel as { imageTaskRunning?: boolean }).imageTaskRunning) ||\n            submittingPanelImageIds.has(panel.id)\n          if (!panel.imageUrl && !isTaskRunning) {\n            panelsToGenerate.push(panel.id)\n          }\n        })\n      })\n\n      if (panelsToGenerate.length === 0) {\n        _ulogInfo('[批量生成] 没有需要生成的分镜图片')\n        return\n      }\n\n      _ulogInfo(`[批量生成] 开始生成 ${panelsToGenerate.length} 个分镜图片`)\n\n      const concurrencyLimit = 10\n      const results: Array<PromiseSettledResult<unknown>> = []\n      for (let index = 0; index < panelsToGenerate.length; index += concurrencyLimit) {\n        const batch = panelsToGenerate.slice(index, index + concurrencyLimit)\n        const currentBatch = Math.floor(index / concurrencyLimit) + 1\n        const totalBatches = Math.ceil(panelsToGenerate.length / concurrencyLimit)\n        _ulogInfo(`[批量生成] 处理第 ${currentBatch}/${totalBatches} 批 (${batch.length} 个)`)\n\n        const batchResults = await Promise.allSettled(\n          batch.map((panelId) => regeneratePanelImage(panelId, 1)),\n        )\n        results.push(...batchResults)\n\n        const completed = Math.min(index + concurrencyLimit, panelsToGenerate.length)\n        _ulogInfo(`[批量生成] 已完成 ${completed}/${panelsToGenerate.length}`)\n      }\n\n      const succeeded = results.filter((result) => result.status === 'fulfilled').length\n      const failed = results.filter((result) => result.status === 'rejected').length\n      _ulogInfo(`[批量生成] 完成: 成功 ${succeeded}, 失败 ${failed}`)\n\n      if (failed > 0) {\n        const failedReasons = results\n          .filter((result): result is PromiseRejectedResult => result.status === 'rejected')\n          .map((result) => result.reason?.message || result.reason)\n          .slice(0, 3)\n          .join('; ')\n        alert(\n          t('messages.batchGenerateCompleted', {\n            succeeded,\n            failed,\n            errors: failedReasons || t('common.none'),\n          }),\n        )\n      } else if (succeeded > 0) {\n        _ulogInfo(`[批量生成] 全部成功生成 ${succeeded} 个分镜图片`)\n      }\n    } catch (error: unknown) {\n      _ulogError('[批量生成] 发生意外错误:', error)\n      alert(\n        t('messages.batchGenerateFailed', {\n          error: getErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n    } finally {\n      setIsEpisodeBatchSubmitting(false)\n    }\n  }, [getTextPanels, regeneratePanelImage, setIsEpisodeBatchSubmitting, sortedStoryboards, submittingPanelImageIds, t])\n\n  return {\n    runningCount,\n    pendingPanelCount,\n    handleGenerateAllPanels,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardGroupActions.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\nimport { useCallback, useState } from 'react'\nimport {\n  useCreateProjectStoryboardGroup,\n  useDeleteProjectStoryboardGroup,\n  useMoveProjectStoryboardGroup,\n  useRegenerateProjectStoryboardText,\n} from '@/lib/query/hooks'\nimport { isAsyncTaskResponse, waitForTaskResult } from '@/lib/task/client'\nimport { getErrorMessage, isAbortError } from './panel-operations-shared'\n\ninterface UseStoryboardGroupActionsProps {\n  projectId: string\n  episodeId: string\n  onRefresh: () => Promise<void> | void\n}\n\nexport function useStoryboardGroupActions({\n  projectId,\n  episodeId,\n  onRefresh,\n}: UseStoryboardGroupActionsProps) {\n  const t = useTranslations('storyboard')\n  const [submittingStoryboardTextIds, setSubmittingStoryboardTextIds] = useState<Set<string>>(new Set())\n  const [addingStoryboardGroup, setAddingStoryboardGroup] = useState(false)\n  const [movingClipId, setMovingClipId] = useState<string | null>(null)\n\n  const deleteStoryboardMutation = useDeleteProjectStoryboardGroup(projectId)\n  const regenerateStoryboardTextMutation = useRegenerateProjectStoryboardText(projectId)\n  const addStoryboardGroupMutation = useCreateProjectStoryboardGroup(projectId)\n  const moveStoryboardGroupMutation = useMoveProjectStoryboardGroup(projectId)\n\n  const deleteStoryboard = useCallback(async (storyboardId: string, panelCount: number) => {\n    if (!confirm(t('confirm.deleteGroup', { count: panelCount }))) {\n      return\n    }\n    try {\n      await deleteStoryboardMutation.mutateAsync({ storyboardId })\n      await onRefresh()\n    } catch (error: unknown) {\n      _ulogError('删除分镜组失败:', error)\n      alert(\n        t('messages.deleteGroupFailed', {\n          error: getErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n    }\n  }, [deleteStoryboardMutation, onRefresh, t])\n\n  const regenerateStoryboardText = useCallback(async (storyboardId: string) => {\n    if (submittingStoryboardTextIds.has(storyboardId)) return\n    setSubmittingStoryboardTextIds((previous) => new Set(previous).add(storyboardId))\n\n    try {\n      const taskData = await regenerateStoryboardTextMutation.mutateAsync({ storyboardId })\n      if (!isAsyncTaskResponse(taskData)) {\n        throw new Error('TASK_ID_MISSING')\n      }\n      await waitForTaskResult(taskData.taskId, { intervalMs: 2000 })\n      _ulogInfo('[重新生成分镜] 任务完成')\n      await onRefresh()\n    } catch (error: unknown) {\n      if (isAbortError(error)) {\n        _ulogInfo('请求被中断（可能是页面刷新），后端仍在执行')\n        return\n      }\n      _ulogError('重新生成分镜失败:', error)\n      alert(\n        t('messages.regenerateGroupFailed', {\n          error: getErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n    } finally {\n      setSubmittingStoryboardTextIds((previous) => {\n        const next = new Set(previous)\n        next.delete(storyboardId)\n        return next\n      })\n    }\n  }, [onRefresh, regenerateStoryboardTextMutation, submittingStoryboardTextIds, t])\n\n  const addStoryboardGroup = useCallback(async (insertIndex: number) => {\n    if (addingStoryboardGroup) return\n    setAddingStoryboardGroup(true)\n    try {\n      await addStoryboardGroupMutation.mutateAsync({ episodeId, insertIndex })\n      await onRefresh()\n    } catch (error: unknown) {\n      _ulogError('添加分镜组失败:', error)\n      alert(\n        t('messages.addGroupFailed', {\n          error: getErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n    } finally {\n      setAddingStoryboardGroup(false)\n    }\n  }, [addingStoryboardGroup, addStoryboardGroupMutation, episodeId, onRefresh, t])\n\n  const moveStoryboardGroup = useCallback(async (clipId: string, direction: 'up' | 'down') => {\n    if (movingClipId) return\n    setMovingClipId(clipId)\n    try {\n      await moveStoryboardGroupMutation.mutateAsync({ episodeId, clipId, direction })\n      await onRefresh()\n    } catch (error: unknown) {\n      _ulogError('移动分镜组失败:', error)\n      alert(\n        t('messages.moveGroupFailed', {\n          error: getErrorMessage(error, t('common.unknownError')),\n        }),\n      )\n    } finally {\n      setMovingClipId(null)\n    }\n  }, [episodeId, moveStoryboardGroupMutation, movingClipId, onRefresh, t])\n\n  return {\n    submittingStoryboardTextIds,\n    addingStoryboardGroup,\n    movingClipId,\n    deleteStoryboard,\n    regenerateStoryboardText,\n    addStoryboardGroup,\n    moveStoryboardGroup,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardGroupTaskErrors.ts",
    "content": "'use client'\n\nimport { useCallback, useMemo } from 'react'\nimport { useTaskList } from '@/lib/query/hooks/useTaskStatus'\nimport { resolveErrorDisplay } from '@/lib/errors/display'\nimport { useDismissFailedTasks } from '@/lib/query/mutations/task-mutations'\n\ninterface UseStoryboardGroupTaskErrorsParams {\n  projectId: string\n  episodeId: string\n}\n\n/**\n * 从数据库查询 panel 级别的 failed tasks，并提供 dismiss 能力。\n * dismiss 通过 API 将 task 状态改为 'dismissed'，数据库为唯一来源。\n */\nexport function useStoryboardGroupTaskErrors({\n  projectId,\n}: UseStoryboardGroupTaskErrorsParams) {\n  const panelFailedTasksQuery = useTaskList({\n    projectId,\n    targetType: 'NovelPromotionPanel',\n    statuses: ['failed'],\n    limit: 200,\n    enabled: !!projectId,\n  })\n\n  const dismissMutation = useDismissFailedTasks(projectId)\n\n  const panelTaskErrorMap = useMemo(() => {\n    const map = new Map<string, { taskId: string; message: string }>()\n    for (const task of panelFailedTasksQuery.data || []) {\n      const display = resolveErrorDisplay(task.error || null)\n      if (!display) continue\n      if (!map.has(task.targetId)) {\n        map.set(task.targetId, { taskId: task.id, message: display.message })\n      }\n    }\n    return map\n  }, [panelFailedTasksQuery.data])\n\n  const clearPanelTaskError = useCallback((panelId: string) => {\n    const taskIds = (panelFailedTasksQuery.data || [])\n      .filter((task) => task.targetId === panelId)\n      .map((task) => task.id)\n    if (taskIds.length === 0) return\n    dismissMutation.mutate(taskIds)\n  }, [dismissMutation, panelFailedTasksQuery.data])\n\n  return {\n    panelTaskErrorMap,\n    clearPanelTaskError,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardInsertVariantRuntime.ts",
    "content": "'use client'\n\nimport { useCallback, useState } from 'react'\nimport type { StoryboardPanel } from './useStoryboardState'\nimport type { VariantData, VariantOptions } from './usePanelVariant'\n\ninterface PanelRuntimeSnapshot {\n  id: string\n  panelNumber: number | null\n  description: string | null\n  imageUrl: string | null\n}\n\ninterface VariantPanelRuntimeSnapshot extends PanelRuntimeSnapshot {\n  storyboardId: string\n}\n\ninterface UseStoryboardInsertVariantRuntimeParams {\n  storyboardId: string\n  textPanels: StoryboardPanel[]\n  onInsertPanel: (storyboardId: string, insertAfterPanelId: string, userInput: string) => Promise<void>\n  onPanelVariant: (\n    sourcePanelId: string,\n    storyboardId: string,\n    insertAfterPanelId: string,\n    variant: VariantData,\n    options: VariantOptions,\n  ) => Promise<void>\n}\n\nexport function useStoryboardInsertVariantRuntime({\n  storyboardId,\n  textPanels,\n  onInsertPanel,\n  onPanelVariant,\n}: UseStoryboardInsertVariantRuntimeParams) {\n  const [insertModalOpen, setInsertModalOpen] = useState(false)\n  const [insertAfterPanel, setInsertAfterPanel] = useState<PanelRuntimeSnapshot | null>(null)\n  const [nextPanelForInsert, setNextPanelForInsert] = useState<PanelRuntimeSnapshot | null>(null)\n  const [variantModalPanel, setVariantModalPanel] = useState<VariantPanelRuntimeSnapshot | null>(null)\n\n  const handleOpenInsertModal = useCallback((panelIndex: number) => {\n    const previousPanel = textPanels[panelIndex]\n    const nextPanel = textPanels[panelIndex + 1] || null\n    if (!previousPanel) return\n\n    setInsertAfterPanel({\n      id: previousPanel.id,\n      panelNumber: previousPanel.panel_number,\n      description: previousPanel.description,\n      imageUrl: previousPanel.imageUrl ?? null,\n    })\n\n    setNextPanelForInsert(\n      nextPanel\n        ? {\n          id: nextPanel.id,\n          panelNumber: nextPanel.panel_number,\n          description: nextPanel.description,\n          imageUrl: nextPanel.imageUrl ?? null,\n        }\n        : null,\n    )\n\n    setInsertModalOpen(true)\n  }, [textPanels])\n\n  const handleCloseInsertModal = useCallback(() => {\n    setInsertModalOpen(false)\n    setInsertAfterPanel(null)\n    setNextPanelForInsert(null)\n  }, [])\n\n  const handleInsert = useCallback(async (userInput: string) => {\n    if (!insertAfterPanel) return\n    await onInsertPanel(storyboardId, insertAfterPanel.id, userInput)\n    handleCloseInsertModal()\n  }, [handleCloseInsertModal, insertAfterPanel, onInsertPanel, storyboardId])\n\n  const handleOpenVariantModal = useCallback((panelIndex: number) => {\n    const panel = textPanels[panelIndex]\n    if (!panel) return\n    setVariantModalPanel({\n      id: panel.id,\n      panelNumber: panel.panel_number,\n      description: panel.description,\n      imageUrl: panel.imageUrl ?? null,\n      storyboardId,\n    })\n  }, [storyboardId, textPanels])\n\n  const handleCloseVariantModal = useCallback(() => {\n    setVariantModalPanel(null)\n  }, [])\n\n  const handleVariant = useCallback(async (variant: VariantData, options: VariantOptions) => {\n    if (!variantModalPanel) return\n    await onPanelVariant(\n      variantModalPanel.id,\n      variantModalPanel.storyboardId,\n      variantModalPanel.id,\n      variant,\n      options,\n    )\n    setVariantModalPanel(null)\n  }, [onPanelVariant, variantModalPanel])\n\n  return {\n    insertModalOpen,\n    insertAfterPanel,\n    nextPanelForInsert,\n    variantModalPanel,\n    handleOpenInsertModal,\n    handleCloseInsertModal,\n    handleInsert,\n    handleOpenVariantModal,\n    handleCloseVariantModal,\n    handleVariant,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardModalRuntime.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport type { NovelPromotionStoryboard } from '@/types/project'\nimport type { PanelEditData } from '../../PanelEditForm'\nimport type { StoryboardPanel } from './useStoryboardState'\nimport type { SelectedAsset } from './useImageGeneration'\nimport { useStoryboardAiDataRuntime } from './useStoryboardAiDataRuntime'\n\ninterface AssetPickerPanelRef {\n  panelId: string\n  type: 'character' | 'location'\n}\n\ninterface AIDataPanelRef {\n  storyboardId: string\n  panelIndex: number\n}\n\ninterface PhotographyPlanMutation {\n  mutateAsync: (payload: { storyboardId: string; photographyPlan: string }) => Promise<unknown>\n}\n\ninterface ActingNotesMutation {\n  mutateAsync: (payload: { storyboardId: string; panelIndex: number; actingNotes: string }) => Promise<unknown>\n}\n\ninterface UseStoryboardModalRuntimeParams {\n  projectId: string\n  videoRatio: string\n  localStoryboards: NovelPromotionStoryboard[]\n  editingPanel: { storyboardId: string; panelIndex: number } | null\n  setEditingPanel: (panel: { storyboardId: string; panelIndex: number } | null) => void\n  assetPickerPanel: AssetPickerPanelRef | null\n  setAssetPickerPanel: (panel: AssetPickerPanelRef | null) => void\n  aiDataPanel: AIDataPanelRef | null\n  setAIDataPanel: (panel: AIDataPanelRef | null) => void\n  previewImage: string | null\n  setPreviewImage: (url: string | null) => void\n  getTextPanels: (storyboard: NovelPromotionStoryboard) => StoryboardPanel[]\n  getPanelEditData: (panel: StoryboardPanel) => PanelEditData\n  updatePanelEdit: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void\n  savePanelWithData: (storyboardId: string, panelIdOrData: string | PanelEditData) => void | Promise<void>\n  getDefaultAssetsForClip: (clipId: string) => SelectedAsset[]\n  handleEditSubmit: (prompt: string, images: string[], assets: SelectedAsset[]) => Promise<void>\n  handleAddCharacter: (characterName: string, appearance: string) => void\n  handleSetLocation: (locationName: string) => void\n  updatePhotographyPlanMutation: PhotographyPlanMutation\n  updatePanelActingNotesMutation: ActingNotesMutation\n}\n\ninterface StoryboardPanelReference {\n  storyboardId: string\n  panel: StoryboardPanel\n}\n\nfunction findPanelById(\n  localStoryboards: NovelPromotionStoryboard[],\n  getTextPanels: (storyboard: NovelPromotionStoryboard) => StoryboardPanel[],\n  panelId: string,\n): StoryboardPanelReference | null {\n  for (const storyboard of localStoryboards) {\n    const panel = getTextPanels(storyboard).find((candidate) => candidate.id === panelId)\n    if (panel) {\n      return {\n        storyboardId: storyboard.id,\n        panel,\n      }\n    }\n  }\n  return null\n}\n\nexport function useStoryboardModalRuntime({\n  projectId,\n  videoRatio,\n  localStoryboards,\n  editingPanel,\n  setEditingPanel,\n  assetPickerPanel,\n  setAssetPickerPanel,\n  aiDataPanel,\n  setAIDataPanel,\n  previewImage,\n  setPreviewImage,\n  getTextPanels,\n  getPanelEditData,\n  updatePanelEdit,\n  savePanelWithData,\n  getDefaultAssetsForClip,\n  handleEditSubmit,\n  handleAddCharacter,\n  handleSetLocation,\n  updatePhotographyPlanMutation,\n  updatePanelActingNotesMutation,\n}: UseStoryboardModalRuntimeParams) {\n  const imageEditDefaults = useMemo(() => {\n    if (!editingPanel) return []\n    const clipId = localStoryboards.find((storyboard) => storyboard.id === editingPanel.storyboardId)?.clipId || ''\n    return getDefaultAssetsForClip(clipId)\n  }, [editingPanel, getDefaultAssetsForClip, localStoryboards])\n\n  const { aiDataRuntime, handleSaveAIData } = useStoryboardAiDataRuntime({\n    aiDataPanel,\n    localStoryboards,\n    getTextPanels,\n    getPanelEditData,\n    updatePanelEdit,\n    savePanelWithData,\n    updatePhotographyPlanMutation,\n    updatePanelActingNotesMutation,\n  })\n\n  const pickerPanelRuntime = useMemo(() => {\n    if (!assetPickerPanel) return null\n    return findPanelById(localStoryboards, getTextPanels, assetPickerPanel.panelId)\n  }, [assetPickerPanel, getTextPanels, localStoryboards])\n\n  return {\n    projectId,\n    videoRatio,\n    editingPanel,\n    imageEditDefaults,\n    handleEditSubmit,\n    closeImageEditModal: () => setEditingPanel(null),\n\n    aiDataPanel,\n    aiDataRuntime,\n    closeAIDataModal: () => setAIDataPanel(null),\n    handleSaveAIData,\n\n    previewImage,\n    closePreviewImage: () => setPreviewImage(null),\n\n    assetPickerPanel,\n    pickerPanelRuntime,\n    closeAssetPicker: () => setAssetPickerPanel(null),\n    handleAddCharacter,\n    handleSetLocation,\n    hasCharacterPicker: assetPickerPanel?.type === 'character',\n    hasLocationPicker: assetPickerPanel?.type === 'location',\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardPanelAssetActions.ts",
    "content": "'use client'\n\nimport { useCallback } from 'react'\nimport { Character, Location, NovelPromotionClip, NovelPromotionStoryboard } from '@/types/project'\nimport { PanelEditData } from '../../PanelEditForm'\nimport { SelectedAsset } from './useImageGeneration'\nimport { StoryboardPanel } from './useStoryboardState'\nimport { buildDefaultAssetsForClip } from './storyboard-panel-asset-utils'\nimport { useStoryboardBatchPanelGeneration } from './useStoryboardBatchPanelGeneration'\n\ninterface UseStoryboardPanelAssetActionsProps {\n  clips: NovelPromotionClip[]\n  characters: Character[]\n  locations: Location[]\n  localStoryboards: NovelPromotionStoryboard[]\n  sortedStoryboards: NovelPromotionStoryboard[]\n  submittingPanelImageIds: Set<string>\n  editingPanel: { storyboardId: string; panelIndex: number } | null\n  setEditingPanel: (panel: { storyboardId: string; panelIndex: number } | null) => void\n  setIsEpisodeBatchSubmitting: (value: boolean) => void\n  getTextPanels: (storyboard: NovelPromotionStoryboard) => StoryboardPanel[]\n  getPanelEditData: (panel: StoryboardPanel) => PanelEditData\n  updatePanelEdit: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void\n  debouncedSave: (panelId: string, storyboardId: string) => void\n  regeneratePanelImage: (panelId: string, count?: number, force?: boolean) => Promise<void>\n  modifyPanelImage: (\n    storyboardId: string,\n    panelIndex: number,\n    prompt: string,\n    images: string[],\n    assets: SelectedAsset[],\n  ) => Promise<void>\n  addCharacterToPanel: (\n    panel: StoryboardPanel,\n    characterName: string,\n    appearance: string,\n    storyboardId: string,\n    getPanelEditData: (panel: StoryboardPanel) => PanelEditData,\n    updatePanelEdit: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void,\n  ) => void\n  removeCharacterFromPanel: (\n    panel: StoryboardPanel,\n    index: number,\n    storyboardId: string,\n    getPanelEditData: (panel: StoryboardPanel) => PanelEditData,\n    updatePanelEdit: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void,\n  ) => void\n  setPanelLocation: (\n    panel: StoryboardPanel,\n    locationName: string | null,\n    storyboardId: string,\n    updatePanelEdit: (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => void,\n  ) => void\n  assetPickerPanel: {\n    panelId: string\n    type: 'character' | 'location'\n  } | null\n  setAssetPickerPanel: (panel: { panelId: string; type: 'character' | 'location' } | null) => void\n}\n\nexport function useStoryboardPanelAssetActions({\n  clips,\n  characters,\n  locations,\n  localStoryboards,\n  sortedStoryboards,\n  submittingPanelImageIds,\n  editingPanel,\n  setEditingPanel,\n  setIsEpisodeBatchSubmitting,\n  getTextPanels,\n  getPanelEditData,\n  updatePanelEdit,\n  debouncedSave,\n  regeneratePanelImage,\n  modifyPanelImage,\n  addCharacterToPanel,\n  removeCharacterFromPanel,\n  setPanelLocation,\n  assetPickerPanel,\n  setAssetPickerPanel,\n}: UseStoryboardPanelAssetActionsProps) {\n  const getDefaultAssetsForClip = useCallback(\n    (clipId: string): SelectedAsset[] =>\n      buildDefaultAssetsForClip({ clipId, clips, characters, locations }),\n    [characters, clips, locations],\n  )\n\n  const handleEditSubmit = useCallback(\n    async (prompt: string, images: string[], assets: SelectedAsset[]) => {\n      if (!editingPanel) return\n      const { storyboardId, panelIndex } = editingPanel\n      setEditingPanel(null)\n      await modifyPanelImage(storyboardId, panelIndex, prompt, images, assets)\n    },\n    [editingPanel, modifyPanelImage, setEditingPanel],\n  )\n\n  const handlePanelUpdate = useCallback(\n    (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => {\n      updatePanelEdit(panelId, panel, updates)\n      const storyboard = localStoryboards.find((item) => getTextPanels(item).some((itemPanel) => itemPanel.id === panelId))\n      if (storyboard) {\n        debouncedSave(panelId, storyboard.id)\n      }\n    },\n    [debouncedSave, getTextPanels, localStoryboards, updatePanelEdit],\n  )\n\n  const handleAddCharacter = useCallback(\n    (characterName: string, appearance: string) => {\n      if (!assetPickerPanel || assetPickerPanel.type !== 'character') return\n\n      const storyboard = localStoryboards.find((item) =>\n        getTextPanels(item).some((panel) => panel.id === assetPickerPanel.panelId),\n      )\n      const panel = storyboard\n        ? getTextPanels(storyboard).find((item) => item.id === assetPickerPanel.panelId)\n        : null\n\n      if (storyboard && panel) {\n        addCharacterToPanel(panel, characterName, appearance, storyboard.id, getPanelEditData, updatePanelEdit)\n      }\n      setAssetPickerPanel(null)\n    },\n    [\n      addCharacterToPanel,\n      assetPickerPanel,\n      getPanelEditData,\n      getTextPanels,\n      localStoryboards,\n      setAssetPickerPanel,\n      updatePanelEdit,\n    ],\n  )\n\n  const handleSetLocation = useCallback(\n    (locationName: string) => {\n      if (!assetPickerPanel || assetPickerPanel.type !== 'location') return\n\n      const storyboard = localStoryboards.find((item) =>\n        getTextPanels(item).some((panel) => panel.id === assetPickerPanel.panelId),\n      )\n      const panel = storyboard\n        ? getTextPanels(storyboard).find((item) => item.id === assetPickerPanel.panelId)\n        : null\n\n      if (storyboard && panel) {\n        setPanelLocation(panel, locationName, storyboard.id, updatePanelEdit)\n      }\n      setAssetPickerPanel(null)\n    },\n    [assetPickerPanel, getTextPanels, localStoryboards, setAssetPickerPanel, setPanelLocation, updatePanelEdit],\n  )\n\n  const handleRemoveCharacter = useCallback(\n    (panel: StoryboardPanel, index: number, storyboardId: string) => {\n      removeCharacterFromPanel(panel, index, storyboardId, getPanelEditData, updatePanelEdit)\n    },\n    [getPanelEditData, removeCharacterFromPanel, updatePanelEdit],\n  )\n\n  const handleRemoveLocation = useCallback(\n    (panel: StoryboardPanel, storyboardId: string) => {\n      setPanelLocation(panel, null, storyboardId, updatePanelEdit)\n    },\n    [setPanelLocation, updatePanelEdit],\n  )\n  const { runningCount, pendingPanelCount, handleGenerateAllPanels } =\n    useStoryboardBatchPanelGeneration({\n      sortedStoryboards,\n      submittingPanelImageIds,\n      getTextPanels,\n      regeneratePanelImage,\n      setIsEpisodeBatchSubmitting,\n    })\n\n  return {\n    getDefaultAssetsForClip,\n    handleEditSubmit,\n    handlePanelUpdate,\n    handleAddCharacter,\n    handleSetLocation,\n    handleRemoveCharacter,\n    handleRemoveLocation,\n    runningCount,\n    pendingPanelCount,\n    handleGenerateAllPanels,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardStageController.ts",
    "content": "'use client'\n\nimport { useCallback, useMemo } from 'react'\nimport {\n  NovelPromotionStoryboard,\n  NovelPromotionClip,\n  Character,\n  Location,\n} from '@/types/project'\nimport { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'\nimport {\n  useUpdateProjectPhotographyPlan,\n  useUpdateProjectPanelActingNotes,\n} from '@/lib/query/hooks'\nimport { useStoryboardState } from './useStoryboardState'\nimport { usePanelOperations } from './usePanelOperations'\nimport { useStoryboardImageGeneration } from './useImageGeneration'\nimport { usePanelVariant } from './usePanelVariant'\nimport { useStoryboardTaskAwareStoryboards } from './useStoryboardTaskAwareStoryboards'\nimport { useStoryboardPanelAssetActions } from './useStoryboardPanelAssetActions'\nimport { useStoryboardStageUiState } from './useStoryboardStageUiState'\nimport { useStoryboardStageStatus } from './useStoryboardStageStatus'\n\ninterface UseStoryboardStageControllerProps {\n  projectId: string\n  episodeId: string\n  initialStoryboards: NovelPromotionStoryboard[]\n  clips: NovelPromotionClip[]\n  isTransitioning: boolean\n}\n\nexport function useStoryboardStageController({\n  projectId,\n  episodeId,\n  initialStoryboards,\n  clips,\n  isTransitioning,\n}: UseStoryboardStageControllerProps) {\n  const isRunningPhase = useCallback((phase: string | null | undefined) => {\n    return phase === 'queued' || phase === 'processing'\n  }, [])\n\n  const { data: assets } = useProjectAssets(projectId)\n  const characters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters])\n  const locations: Location[] = useMemo(() => assets?.locations ?? [], [assets?.locations])\n\n  const { taskAwareStoryboards } = useStoryboardTaskAwareStoryboards({\n    projectId,\n    initialStoryboards,\n    isRunningPhase,\n  })\n\n  const storyboardState = useStoryboardState({\n    projectId,\n    episodeId,\n    initialStoryboards: taskAwareStoryboards,\n    clips,\n  })\n\n  const {\n    localStoryboards,\n    setLocalStoryboards,\n    sortedStoryboards,\n    expandedClips,\n    toggleExpandedClip,\n    panelEditsRef,\n    getClipInfo,\n    getTextPanels,\n    getPanelEditData,\n    updatePanelEdit,\n    formatClipTitle,\n    totalPanels,\n    storyboardStartIndex,\n  } = storyboardState\n\n  const panelOps = usePanelOperations({\n    projectId,\n    episodeId,\n    panelEditsRef,\n  })\n\n  const {\n    savingPanels,\n    deletingPanelIds,\n    saveStateByPanel,\n    hasUnsavedByPanel,\n    submittingStoryboardTextIds,\n    addingStoryboardGroup,\n    movingClipId,\n    insertingAfterPanelId,\n    savePanelWithData,\n    debouncedSave,\n    retrySave,\n    addPanel,\n    deletePanel,\n    deleteStoryboard,\n    regenerateStoryboardText,\n    addStoryboardGroup,\n    moveStoryboardGroup,\n    addCharacterToPanel,\n    removeCharacterFromPanel,\n    setPanelLocation,\n    insertPanel,\n  } = panelOps\n\n  const variantOps = usePanelVariant({\n    projectId,\n    episodeId,\n    setLocalStoryboards,\n  })\n\n  const { submittingVariantPanelId, generatePanelVariant } = variantOps\n\n  const imageOps = useStoryboardImageGeneration({\n    projectId,\n    episodeId,\n    localStoryboards,\n    setLocalStoryboards,\n  })\n\n  const {\n    submittingStoryboardIds,\n    submittingPanelImageIds,\n    selectingCandidateIds,\n    editingPanel,\n    setEditingPanel,\n    modifyingPanels,\n    isDownloadingImages,\n    previewImage,\n    setPreviewImage,\n    regeneratePanelImage,\n    regenerateAllPanelsIndividually,\n    selectPanelCandidate,\n    selectPanelCandidateIndex,\n    cancelPanelCandidate,\n    getPanelCandidates,\n    modifyPanelImage,\n    downloadAllImages,\n    clearStoryboardError,\n  } = imageOps\n\n  const updatePhotographyPlanMutation = useUpdateProjectPhotographyPlan(projectId)\n  const updatePanelActingNotesMutation = useUpdateProjectPanelActingNotes(projectId)\n\n  const {\n    assetPickerPanel,\n    setAssetPickerPanel,\n    aiDataPanel,\n    setAIDataPanel,\n    isEpisodeBatchSubmitting,\n    setIsEpisodeBatchSubmitting,\n  } = useStoryboardStageUiState()\n\n  const {\n    getDefaultAssetsForClip,\n    handleEditSubmit,\n    handlePanelUpdate,\n    handleAddCharacter,\n    handleSetLocation,\n    handleRemoveCharacter,\n    handleRemoveLocation,\n    runningCount,\n    pendingPanelCount,\n    handleGenerateAllPanels,\n  } = useStoryboardPanelAssetActions({\n    clips,\n    characters,\n    locations,\n    localStoryboards,\n    sortedStoryboards,\n    submittingPanelImageIds,\n    editingPanel,\n    setEditingPanel,\n    setIsEpisodeBatchSubmitting,\n    getTextPanels,\n    getPanelEditData,\n    updatePanelEdit,\n    debouncedSave,\n    regeneratePanelImage,\n    modifyPanelImage,\n    addCharacterToPanel,\n    removeCharacterFromPanel,\n    setPanelLocation,\n    assetPickerPanel,\n    setAssetPickerPanel,\n  })\n\n  const { addingStoryboardGroupState, transitioningState } = useStoryboardStageStatus({\n    addingStoryboardGroup,\n    isTransitioning,\n  })\n\n  return {\n    localStoryboards, setLocalStoryboards, sortedStoryboards, expandedClips, toggleExpandedClip,\n    getClipInfo, getTextPanels, getPanelEditData, updatePanelEdit, formatClipTitle, totalPanels, storyboardStartIndex,\n    savingPanels, deletingPanelIds, saveStateByPanel, hasUnsavedByPanel, submittingStoryboardTextIds, addingStoryboardGroup, movingClipId, insertingAfterPanelId,\n    savePanelWithData, addPanel, deletePanel, deleteStoryboard, regenerateStoryboardText, addStoryboardGroup, moveStoryboardGroup, insertPanel,\n    submittingVariantPanelId, generatePanelVariant,\n    submittingStoryboardIds, submittingPanelImageIds, selectingCandidateIds,\n    editingPanel, setEditingPanel, modifyingPanels, isDownloadingImages, previewImage, setPreviewImage,\n    regeneratePanelImage, regenerateAllPanelsIndividually, selectPanelCandidate, selectPanelCandidateIndex,\n    cancelPanelCandidate, getPanelCandidates, modifyPanelImage, downloadAllImages, clearStoryboardError,\n    assetPickerPanel, setAssetPickerPanel, aiDataPanel, setAIDataPanel, isEpisodeBatchSubmitting,\n    getDefaultAssetsForClip, handleEditSubmit, handlePanelUpdate, handleAddCharacter, handleSetLocation, handleRemoveCharacter, handleRemoveLocation,\n    retrySave,\n    updatePhotographyPlanMutation, updatePanelActingNotesMutation,\n    addingStoryboardGroupState, transitioningState, runningCount, pendingPanelCount, handleGenerateAllPanels,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardStageStatus.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { resolveTaskPresentationState, type TaskPresentationState } from '@/lib/task/presentation'\n\ninterface UseStoryboardStageStatusProps {\n  addingStoryboardGroup: boolean\n  isTransitioning: boolean\n}\n\nexport function useStoryboardStageStatus({\n  addingStoryboardGroup,\n  isTransitioning,\n}: UseStoryboardStageStatusProps) {\n  const addingStoryboardGroupState = useMemo<TaskPresentationState | null>(() => {\n    if (!addingStoryboardGroup) return null\n    return resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'text',\n      hasOutput: false,\n    })\n  }, [addingStoryboardGroup])\n\n  const transitioningState = useMemo<TaskPresentationState | null>(() => {\n    if (!isTransitioning) return null\n    return resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'video',\n      hasOutput: false,\n    })\n  }, [isTransitioning])\n\n  return {\n    addingStoryboardGroupState,\n    transitioningState,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardStageUiState.ts",
    "content": "'use client'\n\nimport { useState } from 'react'\n\nexport function useStoryboardStageUiState() {\n  const [assetPickerPanel, setAssetPickerPanel] = useState<{\n    panelId: string\n    type: 'character' | 'location'\n  } | null>(null)\n\n  const [aiDataPanel, setAIDataPanel] = useState<{\n    storyboardId: string\n    panelIndex: number\n  } | null>(null)\n\n  const [isEpisodeBatchSubmitting, setIsEpisodeBatchSubmitting] = useState(false)\n\n  return {\n    assetPickerPanel,\n    setAssetPickerPanel,\n    aiDataPanel,\n    setAIDataPanel,\n    isEpisodeBatchSubmitting,\n    setIsEpisodeBatchSubmitting,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardState.ts",
    "content": "'use client'\n\nimport { useCallback, useMemo, useRef, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '@/lib/query/keys'\nimport { NovelPromotionStoryboard, NovelPromotionClip, NovelPromotionPanel } from '@/types/project'\nimport { PanelEditData } from '../../PanelEditForm'\nimport {\n  computeStoryboardStartIndex,\n  computeTotalPanels,\n  formatClipTitle,\n  getStoryboardPanels,\n  sortStoryboardsByClipOrder,\n} from './storyboard-state-utils'\n\nexport interface StoryboardPanel {\n  id: string\n  panelIndex: number\n  panel_number: number\n  shot_type: string\n  camera_move: string | null\n  description: string\n  characters: { name: string; appearance: string }[]\n  location?: string\n  srt_range?: string\n  duration?: number\n  video_prompt?: string\n  source_text?: string\n  candidateImages?: string\n  imageUrl?: string | null\n  photographyRules?: string | null  // 单镜头摄影规则JSON\n  actingNotes?: string | null       // 演技指导数据JSON\n  imageTaskRunning?: boolean  // 任务态运行状态（由 tasks 派生）\n}\n\ninterface UseStoryboardStateProps {\n  projectId: string\n  episodeId: string\n  initialStoryboards: NovelPromotionStoryboard[]\n  clips: NovelPromotionClip[]\n}\n\nexport function useStoryboardState({\n  projectId,\n  episodeId,\n  initialStoryboards,\n  clips,\n}: UseStoryboardStateProps) {\n  const queryClient = useQueryClient()\n  const localStoryboards = useMemo(\n    () => sortStoryboardsByClipOrder(initialStoryboards, clips),\n    [clips, initialStoryboards],\n  )\n\n  const setLocalStoryboards = useCallback<React.Dispatch<React.SetStateAction<NovelPromotionStoryboard[]>>>(\n    (nextStoryboardsOrUpdater) => {\n      const resolveNextStoryboards = (previousStoryboards: NovelPromotionStoryboard[]) => (\n        typeof nextStoryboardsOrUpdater === 'function'\n          ? (nextStoryboardsOrUpdater as (previous: NovelPromotionStoryboard[]) => NovelPromotionStoryboard[])(previousStoryboards)\n          : nextStoryboardsOrUpdater\n      )\n\n      queryClient.setQueryData(queryKeys.episodeData(projectId, episodeId), (previous: unknown) => {\n        if (!previous || typeof previous !== 'object') return previous\n        const episode = previous as { storyboards?: NovelPromotionStoryboard[] }\n        const previousStoryboards = Array.isArray(episode.storyboards) ? episode.storyboards : []\n        const nextStoryboards = resolveNextStoryboards(previousStoryboards)\n        if (nextStoryboards === previousStoryboards) return previous\n        return {\n          ...episode,\n          storyboards: nextStoryboards,\n        }\n      })\n\n      queryClient.setQueryData(queryKeys.storyboards.all(episodeId), (previous: unknown) => {\n        if (!previous || typeof previous !== 'object') return previous\n        const payload = previous as { storyboards?: NovelPromotionStoryboard[] }\n        const previousStoryboards = Array.isArray(payload.storyboards) ? payload.storyboards : []\n        const nextStoryboards = resolveNextStoryboards(previousStoryboards)\n        if (nextStoryboards === previousStoryboards) return previous\n        return {\n          ...payload,\n          storyboards: nextStoryboards,\n        }\n      })\n    },\n    [episodeId, projectId, queryClient],\n  )\n\n  const [expandedClips, setExpandedClips] = useState<Set<string>>(new Set())\n\n  const [panelEdits, setPanelEdits] = useState<Record<string, PanelEditData>>({})\n  // Keep latest panel edits for async callbacks without adding unstable deps.\n  const panelEditsRef = useRef<Record<string, PanelEditData>>({})\n  panelEditsRef.current = panelEdits\n\n  const getClipInfo = (clipId: string) => clips.find(c => c.id === clipId)\n\n  const getPanelImages = (storyboard: NovelPromotionStoryboard): Array<string | null> => {\n    const panels = getStoryboardPanels(storyboard)\n    if (panels.length > 0) {\n      return panels.map((p) => p.imageUrl || null)\n    }\n    return []\n  }\n\n  const getTextPanels = (storyboard: NovelPromotionStoryboard): StoryboardPanel[] => {\n    const panels = getStoryboardPanels(storyboard)\n    const sortedPanels = [...panels].sort((a: NovelPromotionPanel, b: NovelPromotionPanel) =>\n      (a.panelIndex || 0) - (b.panelIndex || 0)\n    )\n    return sortedPanels.map((p) => {\n      const parsedChars = p.characters ? JSON.parse(p.characters) : []\n      const characters = Array.isArray(parsedChars)\n        ? parsedChars.filter((item): item is { name: string; appearance: string } => (\n          typeof item === 'object'\n          && item !== null\n          && typeof (item as { name?: unknown }).name === 'string'\n          && typeof (item as { appearance?: unknown }).appearance === 'string'\n        ))\n        : []\n      return {\n        id: p.id,\n        panelIndex: p.panelIndex,\n        panel_number: p.panelNumber ?? p.panelIndex + 1,\n        shot_type: p.shotType ?? '',\n        camera_move: p.cameraMove,\n        description: p.description ?? '',\n        location: p.location || undefined,\n        characters,\n        srt_range: p.srtStart && p.srtEnd ? `${p.srtStart}-${p.srtEnd}` : undefined,\n        duration: p.duration ?? undefined,\n        video_prompt: p.videoPrompt || undefined,\n        source_text: p.srtSegment || undefined,\n        candidateImages: p.candidateImages || undefined,\n        imageUrl: p.imageUrl,\n        photographyRules: p.photographyRules,\n        actingNotes: p.actingNotes,\n        imageTaskRunning: p.imageTaskRunning || false\n      }\n    })\n  }\n\n  const getPanelEditData = (panel: StoryboardPanel): PanelEditData => {\n    if (panelEdits[panel.id]) {\n      return panelEdits[panel.id]\n    }\n    return {\n      id: panel.id,\n      panelIndex: panel.panelIndex,\n      panelNumber: panel.panel_number,\n      shotType: panel.shot_type,\n      cameraMove: panel.camera_move,\n      description: panel.description,\n      location: panel.location || null,\n      characters: panel.characters || [],\n      srtStart: null,\n      srtEnd: null,\n      duration: panel.duration || null,\n      videoPrompt: panel.video_prompt || null,\n      photographyRules: panel.photographyRules ?? null,\n      actingNotes: panel.actingNotes ?? null,\n      sourceText: panel.source_text\n    }\n  }\n\n  const updatePanelEdit = (panelId: string, panel: StoryboardPanel, updates: Partial<PanelEditData>) => {\n    setPanelEdits(prev => {\n      const currentData = prev[panelId] || getPanelEditData(panel)\n      return {\n        ...prev,\n        [panelId]: { ...currentData, ...updates }\n      }\n    })\n  }\n\n  const toggleExpandedClip = (storyboardId: string) => {\n    setExpandedClips(prev => {\n      const next = new Set(prev)\n      if (next.has(storyboardId)) {\n        next.delete(storyboardId)\n      } else {\n        next.add(storyboardId)\n      }\n      return next\n    })\n  }\n\n  const sortedStoryboards = [...localStoryboards].sort((a, b) => {\n    const clipIndexA = clips.findIndex(c => c.id === a.clipId)\n    const clipIndexB = clips.findIndex(c => c.id === b.clipId)\n    return clipIndexA - clipIndexB\n  })\n\n  const totalPanels = computeTotalPanels(localStoryboards)\n  const storyboardStartIndex = computeStoryboardStartIndex(sortedStoryboards)\n\n  return {\n    localStoryboards,\n    setLocalStoryboards,\n    sortedStoryboards,\n    expandedClips,\n    toggleExpandedClip,\n    panelEdits,\n    setPanelEdits,\n    panelEditsRef,\n    getClipInfo,\n    getPanelImages,\n    getTextPanels,\n    getPanelEditData,\n    updatePanelEdit,\n    formatClipTitle,\n    totalPanels,\n    storyboardStartIndex\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardTaskAwareStoryboards.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { NovelPromotionStoryboard } from '@/types/project'\nimport { useStoryboardTaskPresentation } from '@/lib/query/hooks/useTaskPresentation'\n\ninterface TaskTarget {\n  key: string\n  targetType: string\n  targetId: string\n  types: string[]\n  resource: 'text' | 'image' | 'video'\n  hasOutput: boolean\n}\n\ninterface UseStoryboardTaskAwareStoryboardsProps {\n  projectId: string\n  initialStoryboards: NovelPromotionStoryboard[]\n  isRunningPhase: (phase: string | null | undefined) => boolean\n}\n\nfunction buildStoryboardTextTargets(storyboards: NovelPromotionStoryboard[]): TaskTarget[] {\n  const targets: TaskTarget[] = []\n\n  for (const storyboard of storyboards) {\n    targets.push({\n      key: `storyboard:${storyboard.id}`,\n      targetType: 'NovelPromotionStoryboard',\n      targetId: storyboard.id,\n      types: ['regenerate_storyboard_text', 'insert_panel'],\n      resource: 'text',\n      hasOutput: !!(storyboard.panels || []).length,\n    })\n    if (storyboard.episodeId) {\n      targets.push({\n        key: `episode:${storyboard.episodeId}`,\n        targetType: 'NovelPromotionEpisode',\n        targetId: storyboard.episodeId,\n        types: ['regenerate_storyboard_text', 'insert_panel'],\n        resource: 'text',\n        hasOutput: !!(storyboard.panels || []).length,\n      })\n    }\n  }\n\n  return targets\n}\n\nfunction buildPanelTargets(storyboards: NovelPromotionStoryboard[], type: 'image' | 'video' | 'lip-sync'): TaskTarget[] {\n  const targets: TaskTarget[] = []\n\n  for (const storyboard of storyboards) {\n    for (const panel of storyboard.panels || []) {\n      if (type === 'image') {\n        targets.push({\n          key: `panel-image:${panel.id}`,\n          targetType: 'NovelPromotionPanel',\n          targetId: panel.id,\n          types: ['image_panel', 'panel_variant', 'modify_asset_image'],\n          resource: 'image',\n          hasOutput: !!panel.imageUrl,\n        })\n      } else if (type === 'video') {\n        targets.push({\n          key: `panel-video:${panel.id}`,\n          targetType: 'NovelPromotionPanel',\n          targetId: panel.id,\n          types: ['video_panel'],\n          resource: 'video',\n          hasOutput: !!panel.videoUrl,\n        })\n      } else {\n        targets.push({\n          key: `panel-lip:${panel.id}`,\n          targetType: 'NovelPromotionPanel',\n          targetId: panel.id,\n          types: ['lip_sync'],\n          resource: 'video',\n          hasOutput: !!panel.lipSyncVideoUrl,\n        })\n      }\n    }\n  }\n\n  return targets\n}\n\nexport function useStoryboardTaskAwareStoryboards({\n  projectId,\n  initialStoryboards,\n  isRunningPhase,\n}: UseStoryboardTaskAwareStoryboardsProps) {\n  const storyboardTextTargets = useMemo(\n    () => buildStoryboardTextTargets(initialStoryboards),\n    [initialStoryboards],\n  )\n  const panelImageTargets = useMemo(\n    () => buildPanelTargets(initialStoryboards, 'image'),\n    [initialStoryboards],\n  )\n  const panelVideoTargets = useMemo(\n    () => buildPanelTargets(initialStoryboards, 'video'),\n    [initialStoryboards],\n  )\n  const panelLipSyncTargets = useMemo(\n    () => buildPanelTargets(initialStoryboards, 'lip-sync'),\n    [initialStoryboards],\n  )\n\n  const storyboardTextStates = useStoryboardTaskPresentation(\n    projectId,\n    storyboardTextTargets,\n    !!projectId && storyboardTextTargets.length > 0,\n  )\n  const panelImageStates = useStoryboardTaskPresentation(\n    projectId,\n    panelImageTargets,\n    !!projectId && panelImageTargets.length > 0,\n  )\n  const panelVideoStates = useStoryboardTaskPresentation(\n    projectId,\n    panelVideoTargets,\n    !!projectId && panelVideoTargets.length > 0,\n  )\n  const panelLipSyncStates = useStoryboardTaskPresentation(\n    projectId,\n    panelLipSyncTargets,\n    !!projectId && panelLipSyncTargets.length > 0,\n  )\n\n  const taskAwareStoryboards = useMemo(() => {\n    return initialStoryboards.map((storyboard) => ({\n      ...storyboard,\n      storyboardTaskRunning:\n        isRunningPhase(storyboardTextStates.getTaskState(`storyboard:${storyboard.id}`)?.phase) ||\n        isRunningPhase(storyboardTextStates.getTaskState(`episode:${storyboard.episodeId}`)?.phase),\n      panels: (storyboard.panels || []).map((panel) => {\n        const panelImageTaskState = panelImageStates.getTaskState(`panel-image:${panel.id}`)\n        const panelImageRunning = isRunningPhase(panelImageTaskState?.phase)\n        return {\n          ...panel,\n          imageTaskRunning: panelImageRunning,\n          imageTaskIntent: panelImageTaskState?.intent,\n          videoTaskRunning: isRunningPhase(panelVideoStates.getTaskState(`panel-video:${panel.id}`)?.phase),\n          lipSyncTaskRunning: isRunningPhase(panelLipSyncStates.getTaskState(`panel-lip:${panel.id}`)?.phase),\n        }\n      }),\n    }))\n  }, [\n    initialStoryboards,\n    isRunningPhase,\n    panelImageStates,\n    panelLipSyncStates,\n    panelVideoStates,\n    storyboardTextStates,\n  ])\n\n  return {\n    taskAwareStoryboards,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/index.tsx",
    "content": "'use client'\n\nimport { NovelPromotionStoryboard, NovelPromotionClip } from '@/types/project'\nimport { CharacterPickerModal, LocationPickerModal } from '../PanelEditForm'\nimport ImageEditModal from './ImageEditModal'\nimport AIDataModal from './AIDataModal'\nimport ImagePreviewModal from '@/components/ui/ImagePreviewModal'\nimport StoryboardStageShell from './StoryboardStageShell'\nimport StoryboardToolbar from './StoryboardToolbar'\nimport StoryboardCanvas from './StoryboardCanvas'\nimport { useStoryboardStageController } from './hooks/useStoryboardStageController'\nimport { useStoryboardModalRuntime } from './hooks/useStoryboardModalRuntime'\n\ninterface StoryboardStageProps {\n  projectId: string\n  episodeId: string\n  storyboards: NovelPromotionStoryboard[]\n  clips: NovelPromotionClip[]\n  videoRatio: string\n  onBack: () => void\n  onNext: () => void\n  isTransitioning?: boolean\n}\n\nexport default function StoryboardStage({\n  projectId,\n  episodeId,\n  storyboards: initialStoryboards,\n  clips,\n  videoRatio,\n  onBack,\n  onNext,\n  isTransitioning = false,\n}: StoryboardStageProps) {\n  const controller = useStoryboardStageController({\n    projectId,\n    episodeId,\n    initialStoryboards,\n    clips,\n    isTransitioning,\n  })\n\n  const {\n    localStoryboards,\n    setLocalStoryboards,\n    sortedStoryboards,\n    expandedClips,\n    toggleExpandedClip,\n    getClipInfo,\n    getTextPanels,\n    getPanelEditData,\n    updatePanelEdit,\n    formatClipTitle,\n    totalPanels,\n    storyboardStartIndex,\n\n    savingPanels,\n    deletingPanelIds,\n    saveStateByPanel,\n    hasUnsavedByPanel,\n    submittingStoryboardTextIds,\n    addingStoryboardGroup,\n    movingClipId,\n    insertingAfterPanelId,\n    savePanelWithData,\n    addPanel,\n    deletePanel,\n    deleteStoryboard,\n    regenerateStoryboardText,\n    addStoryboardGroup,\n    moveStoryboardGroup,\n    insertPanel,\n\n    submittingVariantPanelId,\n    generatePanelVariant,\n\n    submittingStoryboardIds,\n    submittingPanelImageIds,\n    selectingCandidateIds,\n\n    editingPanel,\n    setEditingPanel,\n    modifyingPanels,\n    isDownloadingImages,\n    previewImage,\n    setPreviewImage,\n    regeneratePanelImage,\n    regenerateAllPanelsIndividually,\n    selectPanelCandidate,\n    selectPanelCandidateIndex,\n    cancelPanelCandidate,\n    getPanelCandidates,\n    downloadAllImages,\n    clearStoryboardError,\n\n    assetPickerPanel,\n    setAssetPickerPanel,\n    aiDataPanel,\n    setAIDataPanel,\n    isEpisodeBatchSubmitting,\n\n    getDefaultAssetsForClip,\n    handleEditSubmit,\n    handlePanelUpdate,\n    handleAddCharacter,\n    handleSetLocation,\n    handleRemoveCharacter,\n    handleRemoveLocation,\n    retrySave,\n\n    updatePhotographyPlanMutation,\n    updatePanelActingNotesMutation,\n\n    addingStoryboardGroupState,\n    transitioningState,\n    runningCount,\n    pendingPanelCount,\n    handleGenerateAllPanels,\n  } = controller\n\n  const modalRuntime = useStoryboardModalRuntime({\n    projectId,\n    videoRatio,\n    localStoryboards,\n    editingPanel,\n    setEditingPanel,\n    assetPickerPanel,\n    setAssetPickerPanel,\n    aiDataPanel,\n    setAIDataPanel,\n    previewImage,\n    setPreviewImage,\n    getTextPanels,\n    getPanelEditData,\n    updatePanelEdit,\n    savePanelWithData,\n    getDefaultAssetsForClip,\n    handleEditSubmit,\n    handleAddCharacter,\n    handleSetLocation,\n    updatePhotographyPlanMutation,\n    updatePanelActingNotesMutation,\n  })\n\n  return (\n      <StoryboardStageShell\n        isTransitioning={isTransitioning}\n        isNextDisabled={isTransitioning || localStoryboards.length === 0}\n        transitioningState={transitioningState}\n        onNext={onNext}\n      >\n        <StoryboardToolbar\n          totalSegments={sortedStoryboards.length}\n          totalPanels={totalPanels}\n          isDownloadingImages={isDownloadingImages}\n          runningCount={runningCount}\n          pendingPanelCount={pendingPanelCount}\n          isBatchSubmitting={isEpisodeBatchSubmitting}\n          addingStoryboardGroup={addingStoryboardGroup}\n          addingStoryboardGroupState={addingStoryboardGroupState}\n          onDownloadAllImages={downloadAllImages}\n          onGenerateAllPanels={handleGenerateAllPanels}\n          onAddStoryboardGroupAtStart={() => addStoryboardGroup(0)}\n          onBack={onBack}\n        />\n\n        <StoryboardCanvas\n          sortedStoryboards={sortedStoryboards}\n          videoRatio={videoRatio}\n          expandedClips={expandedClips}\n          submittingStoryboardIds={submittingStoryboardIds}\n          selectingCandidateIds={selectingCandidateIds}\n          submittingStoryboardTextIds={submittingStoryboardTextIds}\n          savingPanels={savingPanels}\n          deletingPanelIds={deletingPanelIds}\n          saveStateByPanel={saveStateByPanel}\n          hasUnsavedByPanel={hasUnsavedByPanel}\n          modifyingPanels={modifyingPanels}\n          submittingPanelImageIds={submittingPanelImageIds}\n\n          movingClipId={movingClipId}\n          insertingAfterPanelId={insertingAfterPanelId}\n          submittingVariantPanelId={submittingVariantPanelId}\n          projectId={projectId}\n          episodeId={episodeId}\n          storyboardStartIndex={storyboardStartIndex}\n          getClipInfo={getClipInfo}\n          getTextPanels={getTextPanels}\n          getPanelEditData={getPanelEditData}\n          formatClipTitle={formatClipTitle}\n          onToggleExpandedClip={toggleExpandedClip}\n          onMoveStoryboardGroup={moveStoryboardGroup}\n          onRegenerateStoryboardText={regenerateStoryboardText}\n          onAddPanel={addPanel}\n          onDeleteStoryboard={deleteStoryboard}\n          onGenerateAllIndividually={regenerateAllPanelsIndividually}\n          onPreviewImage={setPreviewImage}\n          onCloseStoryboardError={clearStoryboardError}\n          onPanelUpdate={handlePanelUpdate}\n          onPanelDelete={deletePanel}\n          onOpenCharacterPicker={(panelId) => setAssetPickerPanel({ panelId, type: 'character' })}\n          onOpenLocationPicker={(panelId) => setAssetPickerPanel({ panelId, type: 'location' })}\n          onRemoveCharacter={handleRemoveCharacter}\n          onRemoveLocation={handleRemoveLocation}\n          onRetryPanelSave={retrySave}\n          onRegeneratePanelImage={regeneratePanelImage}\n          onOpenEditModal={(storyboardId, panelIndex) => setEditingPanel({ storyboardId, panelIndex })}\n          onOpenAIDataModal={(storyboardId, panelIndex) => setAIDataPanel({ storyboardId, panelIndex })}\n          getPanelCandidates={getPanelCandidates}\n          onSelectPanelCandidateIndex={selectPanelCandidateIndex}\n          onConfirmPanelCandidate={selectPanelCandidate}\n          onCancelPanelCandidate={cancelPanelCandidate}\n\n          onInsertPanel={insertPanel}\n          onPanelVariant={generatePanelVariant}\n          addStoryboardGroup={addStoryboardGroup}\n          addingStoryboardGroup={addingStoryboardGroup}\n          setLocalStoryboards={setLocalStoryboards}\n        />\n\n        {modalRuntime.editingPanel && (\n          <ImageEditModal\n            projectId={modalRuntime.projectId}\n            defaultAssets={modalRuntime.imageEditDefaults}\n            onSubmit={modalRuntime.handleEditSubmit}\n            onClose={modalRuntime.closeImageEditModal}\n          />\n        )}\n\n        {modalRuntime.aiDataPanel && modalRuntime.aiDataRuntime && (\n          <AIDataModal\n            isOpen={true}\n            onClose={modalRuntime.closeAIDataModal}\n            syncKey={modalRuntime.aiDataRuntime.panel.id}\n            panelNumber={modalRuntime.aiDataRuntime.panelData.panelNumber || modalRuntime.aiDataPanel.panelIndex + 1}\n            shotType={modalRuntime.aiDataRuntime.panelData.shotType}\n            cameraMove={modalRuntime.aiDataRuntime.panelData.cameraMove}\n            description={modalRuntime.aiDataRuntime.panelData.description}\n            location={modalRuntime.aiDataRuntime.panelData.location}\n            characters={modalRuntime.aiDataRuntime.characterNames}\n            videoPrompt={modalRuntime.aiDataRuntime.panelData.videoPrompt}\n            photographyRules={modalRuntime.aiDataRuntime.photographyRules}\n            actingNotes={modalRuntime.aiDataRuntime.actingNotes}\n            videoRatio={modalRuntime.videoRatio}\n            onSave={modalRuntime.handleSaveAIData}\n          />\n        )}\n\n        {modalRuntime.previewImage && (\n          <ImagePreviewModal imageUrl={modalRuntime.previewImage} onClose={modalRuntime.closePreviewImage} />\n        )}\n\n        {modalRuntime.hasCharacterPicker && (\n          <CharacterPickerModal\n            projectId={projectId}\n            currentCharacters={modalRuntime.pickerPanelRuntime ? getPanelEditData(modalRuntime.pickerPanelRuntime.panel).characters : []}\n            onSelect={modalRuntime.handleAddCharacter}\n            onClose={modalRuntime.closeAssetPicker}\n          />\n        )}\n\n        {modalRuntime.hasLocationPicker && (\n          <LocationPickerModal\n            projectId={projectId}\n            currentLocation={modalRuntime.pickerPanelRuntime ? getPanelEditData(modalRuntime.pickerPanelRuntime.panel).location || null : null}\n            onSelect={modalRuntime.handleSetLocation}\n            onClose={modalRuntime.closeAssetPicker}\n          />\n        )}\n      </StoryboardStageShell>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/FirstLastFramePanel.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\n\nimport type { VideoGenerationOptions, VideoModelOption, VideoPanel } from './types'\nimport type { CapabilityValue } from '@/lib/model-config-contract'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { ModelCapabilityDropdown } from '@/components/ui/config-modals/ModelCapabilityDropdown'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface FirstLastFramePanelProps {\n  panel: VideoPanel\n  nextPanel: VideoPanel\n  panelIndex: number\n  panelKey: string\n  isVideoTaskRunning: boolean\n  flModel: string\n  flModelOptions: VideoModelOption[]\n  flGenerationOptions: VideoGenerationOptions\n  flCapabilityFields: Array<{\n    field: string\n    label: string\n    options: CapabilityValue[]\n    disabledOptions?: CapabilityValue[]\n    value: CapabilityValue | undefined\n  }>\n  customPrompt: string\n  defaultPrompt: string\n  hasMissingCapabilities?: boolean\n  videoRatio?: string  // 视频比例，如 \"16:9\", \"3:2\" 等\n  onFlModelChange: (model: string) => void\n  onFlCapabilityChange: (field: string, rawValue: string) => void\n  onCustomPromptChange: (panelKey: string, value: string) => void\n  onResetPrompt: (panelKey: string) => void\n  onToggleLink: (panelKey: string, storyboardId: string, panelIndex: number) => void\n  onGenerate: (\n    firstStoryboardId: string,\n    firstPanelIndex: number,\n    lastStoryboardId: string,\n    lastPanelIndex: number,\n    panelKey: string,\n    generationOptions?: VideoGenerationOptions,\n    firstPanelId?: string,\n  ) => void\n  onPreviewImage?: (imageUrl: string) => void\n}\n\nexport default function FirstLastFramePanel({\n  panel,\n  nextPanel,\n  panelIndex,\n  panelKey,\n  isVideoTaskRunning,\n  flModel,\n  flModelOptions,\n  flGenerationOptions,\n  flCapabilityFields,\n  customPrompt,\n  defaultPrompt,\n  hasMissingCapabilities = false,\n  videoRatio = '16:9',\n  onFlModelChange,\n  onFlCapabilityChange,\n  onCustomPromptChange,\n  onResetPrompt,\n  onToggleLink,\n  onGenerate,\n  onPreviewImage\n}: FirstLastFramePanelProps) {\n  const t = useTranslations('video')\n  const renderCapabilityLabel = (field: string, fallback: string): string => {\n    try {\n      return t(`capability.${field}` as never)\n    } catch {\n      return fallback\n    }\n  }\n  const isFirstLastFrameGenerated = panel.videoGenerationMode === 'firstlastframe' && !!panel.videoUrl\n  const videoTaskRunningState = isVideoTaskRunning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: isFirstLastFrameGenerated ? 'regenerate' : 'generate',\n      resource: 'video',\n      hasOutput: isFirstLastFrameGenerated,\n    })\n    : null\n  const currentPrompt = customPrompt || defaultPrompt\n  const hasCustomPrompt = customPrompt !== ''\n\n  // 根据视频比例设置 aspect ratio（支持任意比例）\n  const cssAspectRatio = videoRatio.replace(':', '/')\n\n  return (\n    <div className=\"mb-2 space-y-2\">\n      <div className=\"p-2 bg-[var(--glass-tone-info-bg)] border border-[var(--glass-stroke-focus)] rounded-lg\">\n        <div className=\"flex items-center gap-2 text-xs text-[var(--glass-tone-info-fg)] mb-2\">\n          <span>{t(\"firstLastFrame.title\")}</span>\n          <span className=\"text-[var(--glass-tone-info-fg)]\">{t(\"firstLastFrame.range\", { from: panelIndex + 1, to: panelIndex + 2 })}</span>\n          <button\n            onClick={() => onToggleLink(panelKey, panel.storyboardId, panel.panelIndex)}\n            className=\"ml-auto text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-text-primary)] underline\"\n          >\n            {t(\"firstLastFrame.unlinkAction\")}\n          </button>\n        </div>\n        <div className=\"flex gap-2 items-center\">\n          <div className=\"flex-1 bg-[var(--glass-bg-muted)] rounded overflow-hidden relative\" style={{ aspectRatio: cssAspectRatio }}>\n            {panel.imageUrl && (\n              <MediaImageWithLoading\n                src={panel.imageUrl}\n                alt={t(\"firstLastFrame.firstFrame\")}\n                containerClassName=\"w-full h-full\"\n                className={`w-full h-full object-cover ${onPreviewImage ? 'cursor-zoom-in' : ''}`}\n                onClick={() => {\n                  if (panel.imageUrl) onPreviewImage?.(panel.imageUrl)\n                }}\n              />\n            )}\n            <span className=\"absolute bottom-1 left-1 bg-[var(--glass-accent-from)] text-white text-[10px] px-1 rounded\">{t(\"firstLastFrame.firstFrame\")}</span>\n          </div>\n          <AppIcon name=\"arrowRight\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n          <div className=\"flex-1 bg-[var(--glass-bg-muted)] rounded overflow-hidden relative\" style={{ aspectRatio: cssAspectRatio }}>\n            {nextPanel.imageUrl && (\n              <MediaImageWithLoading\n                src={nextPanel.imageUrl}\n                alt={t(\"firstLastFrame.lastFrame\")}\n                containerClassName=\"w-full h-full\"\n                className={`w-full h-full object-cover ${onPreviewImage ? 'cursor-zoom-in' : ''}`}\n                onClick={() => {\n                  if (nextPanel.imageUrl) onPreviewImage?.(nextPanel.imageUrl)\n                }}\n              />\n            )}\n            <span className=\"absolute bottom-1 left-1 bg-[var(--glass-tone-warning-fg)] text-white text-[10px] px-1 rounded\">{t(\"firstLastFrame.lastFrame\")}</span>\n          </div>\n        </div>\n        {/* 首尾帧提示词编辑 */}\n        <div className=\"mt-2\">\n          <div className=\"flex items-center justify-between mb-1\">\n            <span className=\"text-xs text-[var(--glass-tone-info-fg)] font-medium\">{t(\"firstLastFrame.customPrompt\")}</span>\n            {hasCustomPrompt && (\n              <button\n                onClick={() => onResetPrompt(panelKey)}\n                className=\"text-xs text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-tone-info-fg)] underline\"\n              >\n                {t(\"firstLastFrame.useDefault\")}\n              </button>\n            )}\n          </div>\n          <textarea\n            value={currentPrompt}\n            onChange={(e) => onCustomPromptChange(panelKey, e.target.value)}\n            className=\"w-full text-xs p-2 border border-[var(--glass-stroke-focus)] rounded bg-[var(--glass-bg-surface)] text-[var(--glass-text-secondary)] focus:outline-none focus:ring-1 focus:ring-[var(--glass-tone-info-fg)] resize-none\"\n            rows={3}\n            placeholder={t(\"firstLastFrame.promptPlaceholder\")}\n          />\n        </div>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <button\n          onClick={() => onGenerate(panel.storyboardId, panel.panelIndex, nextPanel.storyboardId, nextPanel.panelIndex, panelKey, flGenerationOptions, panel.panelId)}\n          disabled={isVideoTaskRunning || !panel.imageUrl || !nextPanel.imageUrl || !flModel || hasMissingCapabilities}\n          className={`glass-btn-base flex-1 py-2 text-sm font-medium disabled:opacity-50 ${isFirstLastFrameGenerated\n            ? 'bg-[var(--glass-tone-success-fg)] text-white'\n            : isVideoTaskRunning\n              ? 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)]'\n              : 'bg-[var(--glass-accent-from)] text-white hover:bg-[var(--glass-accent-to)]'\n            }`}\n        >\n          {isFirstLastFrameGenerated ? t(\"firstLastFrame.generated\") : isVideoTaskRunning ? (\n            <TaskStatusInline state={videoTaskRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n          ) : t(\"firstLastFrame.generate\")}\n        </button>\n        <div className=\"min-w-[220px] max-w-[280px]\">\n          <ModelCapabilityDropdown\n            compact\n            models={flModelOptions}\n            value={flModel || undefined}\n            onModelChange={onFlModelChange}\n            capabilityFields={flCapabilityFields.map((field) => ({\n              field: field.field,\n              label: renderCapabilityLabel(field.field, field.label),\n              options: field.options,\n              disabledOptions: field.disabledOptions,\n            }))}\n            capabilityOverrides={flGenerationOptions}\n            onCapabilityChange={(field, rawValue) => onFlCapabilityChange(field, rawValue)}\n            placeholder={t('panelCard.selectModel')}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/VideoPanelCard.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport VideoPanelCardShell, { type VideoPanelCardShellProps } from './panel-card/VideoPanelCardShell'\n\nexport type { VideoPanelCardShellProps as VideoPanelCardProps } from './panel-card/VideoPanelCardShell'\n\nfunction VideoPanelCard(props: VideoPanelCardShellProps) {\n  return <VideoPanelCardShell {...props} />\n}\n\nexport default React.memo(VideoPanelCard)\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/VideoPromptModal.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { VideoPanel } from './types'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface VideoPromptModalProps {\n  panel: VideoPanel | undefined\n  panelIndex: number\n  editValue: string\n  onEditValueChange: (value: string) => void\n  onSave: () => void\n  onCancel: () => void\n}\n\nexport default function VideoPromptModal({\n  panel,\n  panelIndex,\n  editValue,\n  onEditValueChange,\n  onSave,\n  onCancel\n}: VideoPromptModalProps) {\n  const t = useTranslations('video')\n  if (!panel) return null\n\n  return (\n    <div className=\"fixed inset-0 bg-[var(--glass-overlay)] flex items-center justify-center z-50\" onClick={onCancel}>\n      <div className=\"bg-[var(--glass-bg-surface)] rounded-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto\" onClick={(e) => e.stopPropagation()}>\n        {/* 标题栏 */}\n        <div className=\"sticky top-0 bg-[var(--glass-bg-surface)] border-b px-6 py-4 flex items-center justify-between\">\n          <h3 className=\"text-lg font-bold\">{t('promptModal.title', { number: panelIndex + 1 })}</h3>\n          <button onClick={onCancel} className=\"text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]\">\n            <AppIcon name=\"close\" className=\"w-6 h-6\" />\n          </button>\n        </div>\n\n        <div className=\"p-6 space-y-4\">\n          {/* 镜头信息 */}\n          <div className=\"p-3 bg-[var(--glass-bg-muted)] rounded-lg text-sm space-y-1\">\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-[var(--glass-text-tertiary)]\">{t('promptModal.shotType')}</span>\n              <span className=\"px-2 py-0.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded\">{panel.textPanel?.shot_type}</span>\n              {panel.textPanel?.camera_move && (\n                <span className=\"px-2 py-0.5 bg-[var(--glass-tone-warning-bg)] text-[var(--glass-tone-warning-fg)] rounded\">{panel.textPanel.camera_move}</span>\n              )}\n              {panel.textPanel?.duration && (\n                <span className=\"inline-flex items-center gap-1 px-2 py-0.5 bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] rounded\">\n                  <AppIcon name=\"clock\" className=\"w-3 h-3\" />\n                  {panel.textPanel.duration}\n                  {t('promptModal.duration')}\n                </span>\n              )}\n            </div>\n            <div><span className=\"text-[var(--glass-text-tertiary)]\">{t('promptModal.location')}</span>{panel.textPanel?.location || t('promptModal.locationUnknown')}</div>\n            <div><span className=\"text-[var(--glass-text-tertiary)]\">{t('promptModal.characters')}</span>{panel.textPanel?.characters?.join('、') || t('promptModal.charactersNone')}</div>\n            <div><span className=\"text-[var(--glass-text-tertiary)]\">{t('promptModal.description')}</span>{panel.textPanel?.description}</div>\n            {panel.textPanel?.text_segment && (\n              <div className=\"border-t pt-2 mt-2\">\n                <span className=\"text-[var(--glass-text-tertiary)]\">{t('promptModal.text')}</span>\n                <span className=\"text-[var(--glass-text-secondary)] italic\">&quot;{panel.textPanel.text_segment}&quot;</span>\n              </div>\n            )}\n          </div>\n\n          {/* 视频提示词编辑 */}\n          <div>\n            <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">\n              {t('promptModal.promptLabel')}\n            </label>\n            <textarea\n              value={editValue}\n              onChange={(e) => onEditValueChange(e.target.value)}\n              className=\"w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]\"\n              rows={6}\n              placeholder={t('promptModal.placeholder')}\n            />\n            <p className=\"text-xs text-[var(--glass-text-tertiary)] mt-1\">\n              {t('promptModal.tip')}\n            </p>\n          </div>\n\n          {/* 按钮 */}\n          <div className=\"flex justify-end gap-3 pt-4 border-t\">\n            <button\n              onClick={onCancel}\n              className=\"glass-btn-base px-4 py-2 bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]\"\n            >\n              {t('promptModal.cancel')}\n            </button>\n            <button\n              onClick={onSave}\n              className=\"glass-btn-base px-4 py-2 bg-[var(--glass-accent-from)] text-white hover:bg-[var(--glass-accent-to)]\"\n            >\n              {t('promptModal.save')}\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/VideoToolbar.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface VideoToolbarProps {\n  totalPanels: number\n  runningCount: number\n  videosWithUrl: number\n  failedCount: number\n  isAnyTaskRunning: boolean\n  isDownloading: boolean\n  onGenerateAll: () => void\n  onDownloadAll: () => void\n  onBack: () => void\n  onEnterEditor?: () => void  // 进入剪辑器\n  videosReady?: boolean  // 是否有视频可以剪辑\n}\n\nexport default function VideoToolbar({\n  totalPanels,\n  runningCount,\n  videosWithUrl,\n  failedCount,\n  isAnyTaskRunning,\n  isDownloading,\n  onGenerateAll,\n  onDownloadAll,\n  onBack,\n  onEnterEditor,\n  videosReady = false\n}: VideoToolbarProps) {\n  const t = useTranslations('video')\n  const videoTaskRunningState = isAnyTaskRunning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'video',\n      hasOutput: videosWithUrl > 0,\n    })\n    : null\n  const videoDownloadState = isDownloading\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'video',\n      hasOutput: videosWithUrl > 0,\n    })\n    : null\n  return (\n    <div className=\"glass-surface p-4\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-4\">\n          <span className=\"text-sm font-semibold text-[var(--glass-text-secondary)]\">\n             {t('toolbar.title')}\n          </span>\n          <span className=\"text-sm text-[var(--glass-text-tertiary)]\">\n            {t('toolbar.totalShots', { count: totalPanels })}\n            {runningCount > 0 && (\n              <span className=\"text-[var(--glass-tone-info-fg)] ml-2 animate-pulse\">({t('toolbar.generatingShots', { count: runningCount })})</span>\n            )}\n            {videosWithUrl > 0 && (\n              <span className=\"text-[var(--glass-tone-success-fg)] ml-2\">({t('toolbar.completedShots', { count: videosWithUrl })})</span>\n            )}\n            {failedCount > 0 && (\n              <span className=\"text-[var(--glass-tone-danger-fg)] ml-2\">({t('toolbar.failedShots', { count: failedCount })})</span>\n            )}\n          </span>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <button\n            onClick={onGenerateAll}\n            disabled={isAnyTaskRunning}\n            className=\"glass-btn-base glass-btn-primary flex items-center gap-2 px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            {isAnyTaskRunning ? (\n              <TaskStatusInline state={videoTaskRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n            ) : (\n              <>\n                <AppIcon name=\"plus\" className=\"w-4 h-4\" />\n                <span>{t('toolbar.generateAll')}</span>\n              </>\n            )}\n          </button>\n          <button\n            onClick={onDownloadAll}\n            disabled={videosWithUrl === 0 || isDownloading}\n            className=\"glass-btn-base glass-btn-tone-info flex items-center gap-2 px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n            title={videosWithUrl === 0 ? t('toolbar.noVideos') : t('toolbar.downloadCount', { count: videosWithUrl })}\n          >\n            {isDownloading ? (\n              <TaskStatusInline state={videoDownloadState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n            ) : (\n              <>\n                <AppIcon name=\"image\" className=\"w-4 h-4\" />\n                <span>{t('toolbar.downloadAll')}</span>\n              </>\n            )}\n          </button>\n          {onEnterEditor && (\n            <button\n              onClick={onEnterEditor}\n              disabled={!videosReady}\n              className=\"glass-btn-base glass-btn-secondary flex items-center gap-2 px-4 py-2 text-sm font-medium border border-[var(--glass-stroke-base)] disabled:opacity-50 disabled:cursor-not-allowed\"\n              title={videosReady ? t('toolbar.enterEditor') : t('panelCard.needVideo')}\n            >\n              <AppIcon name=\"wandOff\" className=\"w-4 h-4\" />\n              <span>{t('toolbar.enterEdit')}</span>\n            </button>\n          )}\n          <button\n            onClick={onBack}\n            className=\"glass-btn-base glass-btn-secondary flex items-center gap-2 px-4 py-2 text-sm font-medium border border-[var(--glass-stroke-base)] hover:text-[var(--glass-tone-info-fg)]\"\n          >\n            <AppIcon name=\"chevronLeft\" className=\"w-4 h-4\" />\n            <span>{t('toolbar.back')}</span>\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/index.ts",
    "content": "export { default as VideoToolbar } from './VideoToolbar'\nexport { default as VideoPanelCard } from './VideoPanelCard'\nexport { default as FirstLastFramePanel } from './FirstLastFramePanel'\nexport * from './types'\n\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/VideoPanelCardBody.tsx",
    "content": "import React from 'react'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { ModelCapabilityDropdown } from '@/components/ui/config-modals/ModelCapabilityDropdown'\nimport { AppIcon } from '@/components/ui/icons'\nimport type { VideoPanelRuntime } from './hooks/useVideoPanelActions'\n\ninterface VideoPanelCardBodyProps {\n  runtime: VideoPanelRuntime\n}\n\nexport default function VideoPanelCardBody({ runtime }: VideoPanelCardBodyProps) {\n  const {\n    t,\n    tCommon,\n    panel,\n    panelIndex,\n    panelKey,\n    layout,\n    actions,\n    taskStatus,\n    videoModel,\n    promptEditor,\n    voiceManager,\n    lipSync,\n    computed,\n  } = runtime\n  const safeTranslate = (key: string | undefined, fallback = ''): string => {\n    if (!key) return fallback\n    try {\n      return t(key as never)\n    } catch {\n      return fallback\n    }\n  }\n\n  const renderCapabilityLabel = (field: {\n    field: string\n    label: string\n    labelKey?: string\n    unitKey?: string\n  }): string => {\n    const labelText = safeTranslate(field.labelKey, safeTranslate(`capability.${field.field}`, field.label))\n    const unitText = safeTranslate(field.unitKey)\n    return unitText ? `${labelText} (${unitText})` : labelText\n  }\n\n  const isFirstLastFrameGenerated = panel.videoGenerationMode === 'firstlastframe' && !!panel.videoUrl\n  const showsIncomingLinkBadge = layout.isLastFrame && !!layout.prevPanel\n  const showsOutgoingLinkBadge = layout.isLinked && !!layout.nextPanel\n  const showsPromptEditor = !layout.isLastFrame || layout.isLinked\n  const showsFirstLastFrameActions = layout.isLinked && !!layout.nextPanel\n\n  return (\n    <div className=\"p-4 space-y-2\">\n      <div className=\"flex items-center justify-between text-xs\">\n        <span className=\"px-2 py-0.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded font-medium\">{panel.textPanel?.shot_type || t('panelCard.unknownShotType')}</span>\n        {panel.textPanel?.duration && <span className=\"text-[var(--glass-text-tertiary)]\">{panel.textPanel.duration}{t('promptModal.duration')}</span>}\n      </div>\n\n      <p className=\"text-sm text-[var(--glass-text-secondary)] line-clamp-2\">{panel.textPanel?.description}</p>\n\n      <div className=\"mt-3 pt-3 border-t border-[var(--glass-stroke-base)]\">\n        {(showsIncomingLinkBadge || showsOutgoingLinkBadge) && (\n          <div className=\"mb-2 flex flex-wrap gap-1.5\">\n            {showsIncomingLinkBadge && (\n              <span\n                className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${showsOutgoingLinkBadge\n                    ? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'\n                    : 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)] border border-[var(--glass-stroke-base)]'\n                  }`}\n              >\n                <AppIcon name={showsOutgoingLinkBadge ? 'link' : 'unplug'} className=\"w-3 h-3\" />\n                {t('firstLastFrame.asLastFrameFor', { number: panelIndex })}\n              </span>\n            )}\n            {showsOutgoingLinkBadge && (\n              <span className=\"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]\">\n                <AppIcon name=\"link\" className=\"w-3 h-3\" />\n                {t('firstLastFrame.asFirstFrameFor', { number: panelIndex + 2 })}\n              </span>\n            )}\n          </div>\n        )}\n\n        {showsPromptEditor && (\n          <>\n            <div className=\"flex items-center justify-between mb-1\">\n              <span className=\"text-xs font-medium text-[var(--glass-text-tertiary)]\">{t('promptModal.promptLabel')}</span>\n              {!promptEditor.isEditing && (\n                <button onClick={promptEditor.handleStartEdit} className=\"text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-info-fg)] transition-colors p-0.5\">\n                  <AppIcon name=\"edit\" className=\"w-3.5 h-3.5\" />\n                </button>\n              )}\n            </div>\n\n            {promptEditor.isEditing ? (\n              <div className=\"relative mb-3\">\n                <textarea\n                  value={promptEditor.editingPrompt}\n                  onChange={(event) => promptEditor.setEditingPrompt(event.target.value)}\n                  autoFocus\n                  className=\"w-full text-xs p-2 pr-16 border border-[var(--glass-stroke-focus)] rounded-lg bg-[var(--glass-bg-surface)] text-[var(--glass-text-secondary)] focus:outline-none focus:ring-1 focus:ring-[var(--glass-tone-info-fg)] resize-none\"\n                  rows={3}\n                  placeholder={t('promptModal.placeholder')}\n                />\n                <div className=\"absolute right-1 top-1 flex flex-col gap-1\">\n                  <button onClick={promptEditor.handleSave} disabled={promptEditor.isSavingPrompt} className=\"px-2 py-1 text-[10px] bg-[var(--glass-accent-from)] text-white rounded\">{promptEditor.isSavingPrompt ? '...' : t('panelCard.save')}</button>\n                  <button onClick={promptEditor.handleCancelEdit} disabled={promptEditor.isSavingPrompt} className=\"px-2 py-1 text-[10px] bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] rounded\">{t('panelCard.cancel')}</button>\n                </div>\n              </div>\n            ) : (\n              <div onClick={promptEditor.handleStartEdit} className=\"text-xs p-2 border border-[var(--glass-stroke-base)] rounded-lg bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] cursor-pointer\">\n                {promptEditor.localPrompt || <span className=\"text-[var(--glass-text-tertiary)] italic\">{t('panelCard.clickToEditPrompt')}</span>}\n              </div>\n            )}\n\n            {showsFirstLastFrameActions ? (() => {\n              const linkedNextPanel = layout.nextPanel!\n              return (\n                <div className=\"mt-2 flex items-center gap-2\">\n                  <button\n                    onClick={() => actions.onGenerateFirstLastFrame(\n                      panel.storyboardId,\n                      panel.panelIndex,\n                      linkedNextPanel.storyboardId,\n                      linkedNextPanel.panelIndex,\n                      panelKey,\n                      layout.flGenerationOptions,\n                      panel.panelId,\n                    )}\n                    disabled={\n                      taskStatus.isVideoTaskRunning\n                      || !panel.imageUrl\n                      || !linkedNextPanel.imageUrl\n                      || !layout.flModel\n                      || layout.flMissingCapabilityFields.length > 0\n                    }\n                    className=\"flex-shrink-0 min-w-[120px] py-2 px-3 text-sm font-medium rounded-lg shadow-sm transition-all disabled:opacity-50 bg-[var(--glass-accent-from)] text-white\"\n                  >\n                    {isFirstLastFrameGenerated ? t('firstLastFrame.generated') : taskStatus.isVideoTaskRunning ? taskStatus.taskRunningVideoLabel : t('firstLastFrame.generate')}\n                  </button>\n                  <div className=\"flex-1 min-w-0\">\n                    <ModelCapabilityDropdown\n                      compact\n                      models={layout.flModelOptions}\n                      value={layout.flModel || undefined}\n                      onModelChange={actions.onFlModelChange}\n                      capabilityFields={layout.flCapabilityFields.map((field) => ({\n                        field: field.field,\n                        label: field.label,\n                        options: field.options,\n                        disabledOptions: field.disabledOptions,\n                      }))}\n                      capabilityOverrides={layout.flGenerationOptions}\n                      onCapabilityChange={(field, rawValue) => actions.onFlCapabilityChange(field, rawValue)}\n                      placeholder={t('panelCard.selectModel')}\n                    />\n                  </div>\n                </div>\n              )\n            })() : (\n              <>\n                <div className=\"flex items-center gap-2\">\n                  <button\n                    onClick={() =>\n                      actions.onGenerateVideo(\n                        panel.storyboardId,\n                        panel.panelIndex,\n                        videoModel.selectedModel,\n                        undefined,\n                        videoModel.generationOptions,\n                        panel.panelId,\n                      )}\n                    disabled={\n                      taskStatus.isVideoTaskRunning\n                      || !panel.imageUrl\n                      || !videoModel.selectedModel\n                      || videoModel.missingCapabilityFields.length > 0\n                    }\n                    className=\"flex-shrink-0 min-w-[90px] py-2 px-3 text-sm font-medium rounded-lg shadow-sm transition-all disabled:opacity-50 bg-[var(--glass-accent-from)] text-white\"\n                  >\n                    {panel.videoUrl ? t('stage.hasSynced') : taskStatus.isVideoTaskRunning ? taskStatus.taskRunningVideoLabel : t('panelCard.generateVideo')}\n                  </button>\n                  <div className=\"flex-1 min-w-0\">\n                    <ModelCapabilityDropdown\n                      compact\n                      models={videoModel.videoModelOptions}\n                      value={videoModel.selectedModel || undefined}\n                      onModelChange={(modelKey) => {\n                        videoModel.setSelectedModel(modelKey)\n                      }}\n                      capabilityFields={videoModel.capabilityFields.map((field) => ({\n                        field: field.field,\n                        label: renderCapabilityLabel(field),\n                        options: field.options,\n                        disabledOptions: field.disabledOptions,\n                      }))}\n                      capabilityOverrides={videoModel.generationOptions}\n                      onCapabilityChange={(field, rawValue) => videoModel.setCapabilityValue(field, rawValue)}\n                      placeholder={t('panelCard.selectModel')}\n                    />\n                  </div>\n                </div>\n\n                {computed.showLipSyncSection && (\n                  <div className=\"mt-2\">\n                    <div className=\"flex gap-2\">\n                      <button\n                        onClick={computed.canLipSync ? lipSync.handleStartLipSync : undefined}\n                        disabled={!computed.canLipSync || taskStatus.isLipSyncTaskRunning || lipSync.executingLipSync}\n                        className=\"flex-1 py-1.5 text-xs rounded-lg transition-all flex items-center justify-center gap-1 bg-[var(--glass-accent-from)] text-white disabled:opacity-50\"\n                      >\n                        {taskStatus.isLipSyncTaskRunning || lipSync.executingLipSync ? (\n                          <TaskStatusInline state={taskStatus.lipSyncInlineState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                        ) : (\n                          <>{t('panelCard.lipSync')}</>\n                        )}\n                      </button>\n\n                      {(taskStatus.isLipSyncTaskRunning || panel.lipSyncVideoUrl) && voiceManager.hasMatchedAudio && (\n                        <button onClick={lipSync.handleStartLipSync} disabled={lipSync.executingLipSync} className=\"flex-shrink-0 px-3 py-1.5 text-xs rounded-lg bg-[var(--glass-tone-warning-fg)] text-white\">\n                          {t('panelCard.redo')}\n                        </button>\n                      )}\n                    </div>\n\n                    {voiceManager.audioGenerateError && (\n                      <div className=\"mt-1 p-1.5 bg-[var(--glass-tone-danger-bg)] border border-[var(--glass-stroke-danger)] rounded text-[10px] text-[var(--glass-tone-danger-fg)]\">\n                        {voiceManager.audioGenerateError}\n                      </div>\n                    )}\n\n                    {voiceManager.localVoiceLines.length > 0 && (\n                      <div className=\"mt-2 space-y-1\">\n                        {voiceManager.localVoiceLines.map((voiceLine) => {\n                          const isVoiceTaskRunning = voiceManager.isVoiceLineTaskRunning(voiceLine.id)\n                          const voiceAudioRunningState = isVoiceTaskRunning\n                            ? resolveTaskPresentationState({ phase: 'processing', intent: 'generate', resource: 'audio', hasOutput: !!voiceLine.audioUrl })\n                            : null\n\n                          return (\n                            <div key={voiceLine.id} className=\"flex items-start gap-1.5 p-1.5 bg-[var(--glass-bg-muted)] rounded text-[10px]\">\n                              {voiceLine.audioUrl ? (\n                                <button\n                                  onClick={(event) => {\n                                    event.stopPropagation()\n                                    voiceManager.handlePlayVoiceLine(voiceLine)\n                                  }}\n                                  className=\"flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center transition-colors bg-[var(--glass-bg-muted)]\"\n                                  title={voiceManager.playingVoiceLineId === voiceLine.id ? t('panelCard.stopVoice') : t('panelCard.play')}\n                                >\n                                  <AppIcon name=\"play\" className=\"w-3 h-3\" />\n                                </button>\n                              ) : (\n                                <button\n                                  onClick={(event) => {\n                                    event.stopPropagation()\n                                    void voiceManager.handleGenerateAudio(voiceLine)\n                                  }}\n                                  disabled={isVoiceTaskRunning}\n                                  className=\"flex-shrink-0 px-1.5 py-0.5 bg-[var(--glass-accent-from)] text-white rounded disabled:opacity-50\"\n                                  title={t('panelCard.generateAudio')}\n                                >\n                                  {isVoiceTaskRunning ? (\n                                    <TaskStatusInline state={voiceAudioRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                                  ) : (\n                                    tCommon('generate')\n                                  )}\n                                </button>\n                              )}\n                              <div className=\"flex-1 min-w-0\">\n                                <span className=\"text-[var(--glass-text-tertiary)]\">{voiceLine.speaker}: </span>\n                                <span className=\"text-[var(--glass-text-secondary)]\">&ldquo;{voiceLine.content}&rdquo;</span>\n                              </div>\n                            </div>\n                          )\n                        })}\n                      </div>\n                    )}\n                  </div>\n                )}\n              </>\n            )}\n          </>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/VideoPanelCardFooter.tsx",
    "content": "import TaskStatusInline from '@/components/task/TaskStatusInline'\nimport type { VideoPanelRuntime } from './hooks/useVideoPanelActions'\n\ninterface VideoPanelCardFooterProps {\n  runtime: VideoPanelRuntime\n}\n\nexport default function VideoPanelCardFooter({ runtime }: VideoPanelCardFooterProps) {\n  const { t, lipSync, taskStatus, voiceManager } = runtime\n\n  if (!lipSync.showLipSyncPanel) return null\n\n  return (\n    <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50\" onClick={() => !lipSync.executingLipSync && lipSync.closeLipSyncPanel()}>\n      <div className=\"glass-surface-modal rounded-xl p-6 max-w-md w-full mx-4\" onClick={(event) => event.stopPropagation()}>\n        <div className=\"flex items-center justify-between mb-4\">\n          <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">{t('panelCard.lipSyncTitle')}</h3>\n          {!lipSync.executingLipSync && (\n            <button onClick={lipSync.closeLipSyncPanel} className=\"text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]\">×</button>\n          )}\n        </div>\n\n        {lipSync.lipSyncError && (\n          <div className=\"mb-4 p-3 bg-[var(--glass-tone-danger-bg)] border border-[var(--glass-stroke-danger)] rounded-lg text-[var(--glass-tone-danger-fg)] text-sm\">\n            {lipSync.lipSyncError}\n          </div>\n        )}\n\n        {lipSync.executingLipSync && (\n          <div className=\"flex flex-col items-center py-8\">\n            <TaskStatusInline state={taskStatus.lipSyncInlineState} className=\"text-[var(--glass-text-secondary)] [&>span]:text-[var(--glass-text-secondary)] [&_svg]:text-[var(--glass-tone-info-fg)]\" />\n            <p className=\"text-xs text-[var(--glass-text-tertiary)] mt-2\">{t('panelCard.lipSyncMayTakeMinutes')}</p>\n          </div>\n        )}\n\n        {!lipSync.executingLipSync && (\n          <div>\n            <p className=\"text-sm text-[var(--glass-text-secondary)] mb-3\">{t('panelCard.selectVoice')}</p>\n            <div className=\"space-y-2\">\n              {voiceManager.localVoiceLines\n                .filter((voiceLine) => voiceLine.audioUrl)\n                .map((voiceLine) => (\n                  <button\n                    key={voiceLine.id}\n                    onClick={() => void lipSync.executeLipSync(voiceLine)}\n                    className=\"w-full text-left p-3 border border-[var(--glass-stroke-base)] rounded-lg hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-colors\"\n                  >\n                    <div className=\"flex items-center justify-between\">\n                      <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{voiceLine.speaker}</span>\n                      {voiceLine.audioDuration && <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{(voiceLine.audioDuration / 1000).toFixed(1)}s</span>}\n                    </div>\n                    <div className=\"text-sm text-[var(--glass-text-primary)]\">&ldquo;{voiceLine.content}&rdquo;</div>\n                  </button>\n                ))}\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/VideoPanelCardHeader.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport TaskStatusOverlay from '@/components/task/TaskStatusOverlay'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\n\nimport type { VideoPanelRuntime } from './hooks/useVideoPanelActions'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface VideoPanelCardHeaderProps {\n  runtime: VideoPanelRuntime\n}\n\nexport default function VideoPanelCardHeader({ runtime }: VideoPanelCardHeaderProps) {\n  const {\n    t,\n    panel,\n    panelIndex,\n    panelKey,\n    layout,\n    media,\n    taskStatus,\n    videoModel,\n    player,\n    actions,\n  } = runtime\n\n  const [errorDismissed, setErrorDismissed] = useState(false)\n  const [showTooltip, setShowTooltip] = useState(false)\n\n  useEffect(() => {\n    setErrorDismissed(false)\n  }, [taskStatus.panelErrorDisplay?.message])\n\n  const hasVisibleBaseVideo = !!media.baseVideoUrl\n  const showFirstLastFrameSwitch = layout.hasNext\n\n  return (\n    <div className=\"bg-[var(--glass-bg-muted)] flex items-center justify-center relative\" style={{ aspectRatio: player.cssAspectRatio }}>\n      {hasVisibleBaseVideo && player.isPlaying ? (\n        <video\n          ref={player.videoRef}\n          key={`video-${panel.storyboardId}-${panel.panelIndex}-${media.currentVideoUrl}`}\n          src={media.currentVideoUrl}\n          controls\n          playsInline\n          className=\"w-full h-full object-contain bg-black\"\n          onEnded={() => player.setIsPlaying(false)}\n        />\n      ) : hasVisibleBaseVideo ? (\n        <div\n          className=\"relative w-full h-full group cursor-pointer\"\n          onClick={() => void player.handlePlayClick()}\n        >\n          <MediaImageWithLoading\n            src={panel.imageUrl || ''}\n            alt={t('panelCard.shot', { number: panelIndex + 1 })}\n            containerClassName=\"w-full h-full bg-black\"\n            className=\"w-full h-full object-contain bg-black\"\n          />\n          <div className=\"absolute inset-0 flex items-center justify-center bg-[var(--glass-overlay)] group-hover:bg-[var(--glass-overlay)] transition-colors pointer-events-none\">\n            <div className=\"w-16 h-16 bg-[var(--glass-bg-surface-strong)] rounded-full flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform\">\n              <AppIcon name=\"play\" className=\"w-8 h-8 text-white\" />\n            </div>\n          </div>\n        </div>\n      ) : panel.imageUrl ? (\n        <MediaImageWithLoading\n          src={panel.imageUrl}\n          alt={t('panelCard.shot', { number: panelIndex + 1 })}\n          containerClassName=\"w-full h-full bg-[var(--glass-bg-muted)]\"\n          className={`w-full h-full object-contain bg-[var(--glass-bg-muted)] ${media.onPreviewImage ? 'cursor-zoom-in' : ''}`}\n          onClick={media.onPreviewImage ? player.handlePreviewImage : undefined}\n        />\n      ) : (\n        <AppIcon name=\"playCircle\" className=\"w-16 h-16 text-[var(--glass-text-tertiary)]\" />\n      )}\n\n      {/* 镜头编号 */}\n      <div className=\"absolute top-2 left-2 bg-[var(--glass-overlay)] text-white px-2 py-0.5 rounded text-xs font-medium\">\n        {panelIndex + 1}\n      </div>\n\n      {/* 两卡片中间唯一的链接/断开按钮 */}\n\n      {showFirstLastFrameSwitch && (\n        <div className=\"absolute -right-6 top-1/2 -translate-y-1/2 z-30\">\n          <div className=\"relative\">\n            <button\n              onClick={(event) => {\n                event.stopPropagation()\n                actions.onToggleLink(panelKey, panel.storyboardId, panel.panelIndex)\n              }}\n              onMouseEnter={() => setShowTooltip(true)}\n              onMouseLeave={() => setShowTooltip(false)}\n              className={`h-8 w-8 rounded-full flex items-center justify-center shadow-[var(--glass-shadow-sm)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--glass-stroke-focus)] ${layout.isLinked\n                ? 'bg-[var(--glass-accent-from)] text-white shadow-[0_0_12px_rgba(99,102,241,0.5)]'\n                : 'bg-[var(--glass-bg-surface)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-tone-info-bg)] hover:text-[var(--glass-tone-info-fg)]'\n                }`}\n            >\n              <AppIcon name=\"unplug\" size={16} />\n            </button>\n\n            {/* 自定义 Tooltip */}\n            {showTooltip && (\n              <div className=\"absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-50 pointer-events-none\">\n                <div className=\"bg-[var(--glass-bg-surface-strong)] text-[var(--glass-text-primary)] text-xs rounded-lg px-3 py-1.5 shadow-[var(--glass-shadow-md)] whitespace-nowrap border border-[var(--glass-stroke-base)]\">\n                  {layout.isLinked ? t('firstLastFrame.unlinkAction') : t('firstLastFrame.linkToNext')}\n                  <div className=\"absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-[var(--glass-bg-surface-strong)]\" />\n                </div>\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* 口型同步切换 */}\n      {panel.lipSyncVideoUrl && hasVisibleBaseVideo ? (\n        <div\n          className=\"absolute top-2 right-2 flex items-center bg-[var(--glass-overlay)] rounded-full p-0.5 cursor-pointer\"\n          onClick={(event) => {\n            event.stopPropagation()\n            media.onToggleLipSyncVideo(panelKey, !media.showLipSyncVideo)\n            player.setIsPlaying(false)\n          }}\n        >\n          <div className={`px-2 py-0.5 rounded-full text-[10px] font-medium transition-all ${!media.showLipSyncVideo ? 'bg-[var(--glass-tone-success-fg)] text-white' : 'text-[var(--glass-text-tertiary)] hover:text-white'}`}>\n            {t('panelCard.original')}\n          </div>\n          <div className={`px-2 py-0.5 rounded-full text-[10px] font-medium transition-all ${media.showLipSyncVideo ? 'bg-[var(--glass-accent-from)] text-white' : 'text-[var(--glass-text-tertiary)] hover:text-white'}`}>\n            {t('panelCard.synced')}\n          </div>\n        </div>\n      ) : null}\n\n      {/* 重新生成按钮 */}\n      {!layout.isLinked && !layout.isLastFrame && (hasVisibleBaseVideo || taskStatus.isVideoTaskRunning) && (\n        <button\n          onClick={() =>\n            actions.onGenerateVideo(\n              panel.storyboardId,\n              panel.panelIndex,\n              videoModel.selectedModel,\n              undefined,\n              videoModel.generationOptions,\n              panel.panelId,\n            )}\n          disabled={\n            taskStatus.isVideoTaskRunning\n            || !videoModel.selectedModel\n            || videoModel.missingCapabilityFields.length > 0\n          }\n          className=\"absolute bottom-2 right-2 bg-[var(--glass-overlay)] hover:bg-[var(--glass-overlay-strong)] text-white p-2 rounded-full transition-all z-20 disabled:cursor-not-allowed disabled:opacity-50\"\n        >\n          <AppIcon name=\"refresh\" className=\"w-4 h-4\" />\n        </button>\n      )}\n\n      {/* 任务进度遮罩 */}\n      {(taskStatus.isVideoTaskRunning || taskStatus.isLipSyncTaskRunning) && (\n        <TaskStatusOverlay state={taskStatus.overlayPresentation} className=\"z-10\" />\n      )}\n\n      {/* 错误提示 */}\n      {taskStatus.panelErrorDisplay && !taskStatus.isVideoTaskRunning && !taskStatus.isLipSyncTaskRunning && !errorDismissed && (\n        <div className=\"absolute inset-0 bg-[var(--glass-tone-danger-bg)] flex flex-col items-center justify-center z-10 p-4\">\n          <button\n            onClick={(e) => { e.stopPropagation(); setErrorDismissed(true) }}\n            className=\"absolute top-2 right-2 w-5 h-5 flex items-center justify-center rounded-full bg-black/30 hover:bg-black/50 text-white text-xs transition-colors\"\n          >\n            <AppIcon name=\"close\" className=\"w-3 h-3\" />\n          </button>\n          <span className=\"text-white text-xs text-center break-all\">{taskStatus.panelErrorDisplay.message}</span>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/VideoPanelCardLayout.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport VideoPanelCardHeader from './VideoPanelCardHeader'\nimport VideoPanelCardBody from './VideoPanelCardBody'\nimport VideoPanelCardFooter from './VideoPanelCardFooter'\nimport { useVideoPanelActions, type VideoPanelCardShellProps } from './hooks/useVideoPanelActions'\n\nexport type { VideoPanelCardShellProps }\n\nfunction VideoPanelCardLayout(props: VideoPanelCardShellProps) {\n  const runtime = useVideoPanelActions(props)\n\n  return (\n    <div className=\"glass-surface-elevated overflow-visible\">\n      <VideoPanelCardHeader runtime={runtime} />\n      <VideoPanelCardBody runtime={runtime} />\n      <VideoPanelCardFooter runtime={runtime} />\n    </div>\n  )\n}\n\nexport default React.memo(VideoPanelCardLayout)\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/VideoPanelCardShell.tsx",
    "content": "'use client'\n\nexport { default, type VideoPanelCardShellProps } from './VideoPanelCardLayout'\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/hooks/useVideoPanelActions.tsx",
    "content": "'use client'\n\nexport {\n  useVideoPanelActions,\n  type VideoPanelRuntime,\n} from '../runtime/videoPanelRuntimeCore'\n\nexport type { VideoPanelCardShellProps } from '../types'\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/hooks/usePanelLipSync.ts",
    "content": "import { useCallback, useState } from 'react'\nimport type { MatchedVoiceLine } from '../../../types'\nimport type { VideoPanelCardShellProps } from '../../types'\nimport { getErrorMessage } from '../shared'\n\ninterface UsePanelLipSyncParams {\n  panel: VideoPanelCardShellProps['panel']\n  matchedVoiceLines: MatchedVoiceLine[]\n  onLipSync?: (storyboardId: string, panelIndex: number, voiceLineId: string, panelId?: string) => Promise<void>\n}\n\nexport function usePanelLipSync({\n  panel,\n  matchedVoiceLines,\n  onLipSync,\n}: UsePanelLipSyncParams) {\n  const [showLipSyncPanel, setShowLipSyncPanel] = useState(false)\n  const [executingLipSync, setExecutingLipSync] = useState(false)\n  const [lipSyncError, setLipSyncError] = useState<string | null>(null)\n\n  const closeLipSyncPanel = useCallback(() => {\n    setShowLipSyncPanel(false)\n  }, [])\n\n  const executeLipSync = useCallback(async (voiceLine: MatchedVoiceLine) => {\n    if (!onLipSync) return\n    setLipSyncError(null)\n    setExecutingLipSync(true)\n    try {\n      await onLipSync(panel.storyboardId, panel.panelIndex, voiceLine.id, panel.panelId)\n      setShowLipSyncPanel(false)\n    } catch (error: unknown) {\n      setLipSyncError(getErrorMessage(error))\n    } finally {\n      setExecutingLipSync(false)\n    }\n  }, [onLipSync, panel.panelId, panel.panelIndex, panel.storyboardId])\n\n  const handleStartLipSync = useCallback(() => {\n    if (!panel.videoUrl || matchedVoiceLines.length === 0) return\n    if (matchedVoiceLines.length === 1) {\n      void executeLipSync(matchedVoiceLines[0])\n      return\n    }\n    setShowLipSyncPanel(true)\n    setLipSyncError(null)\n  }, [executeLipSync, matchedVoiceLines, panel.videoUrl])\n\n  return {\n    showLipSyncPanel,\n    executingLipSync,\n    lipSyncError,\n    closeLipSyncPanel,\n    handleStartLipSync,\n    executeLipSync,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/hooks/usePanelPlayer.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { useCallback, useRef, useState, type MouseEvent } from 'react'\n\ninterface UsePanelPlayerParams {\n  videoRatio: string\n  imageUrl?: string\n  videoUrl?: string\n  lipSyncVideoUrl?: string\n  showLipSyncVideo: boolean\n  onPreviewImage?: (imageUrl: string) => void\n}\n\nexport function usePanelPlayer({\n  videoRatio,\n  imageUrl,\n  videoUrl,\n  lipSyncVideoUrl,\n  showLipSyncVideo,\n  onPreviewImage,\n}: UsePanelPlayerParams) {\n  const [isPlaying, setIsPlaying] = useState(false)\n  const videoRef = useRef<HTMLVideoElement>(null)\n  const cssAspectRatio = videoRatio.replace(':', '/')\n  const currentVideoUrl = videoUrl\n    ? (showLipSyncVideo && lipSyncVideoUrl ? lipSyncVideoUrl : videoUrl)\n    : undefined\n\n  const handlePreviewImage = useCallback((event?: MouseEvent) => {\n    if (event) event.stopPropagation()\n    if (!imageUrl || !onPreviewImage) return\n    onPreviewImage(imageUrl)\n  }, [imageUrl, onPreviewImage])\n\n  const handlePlayClick = useCallback(async () => {\n    setIsPlaying(true)\n    setTimeout(async () => {\n      if (!videoRef.current) return\n      try {\n        await videoRef.current.play()\n      } catch (error: unknown) {\n        if ((error as { name?: string }).name !== 'AbortError') {\n          _ulogError('Video play error:', error)\n        }\n      }\n    }, 100)\n  }, [])\n\n  return {\n    cssAspectRatio,\n    currentVideoUrl,\n    isPlaying,\n    setIsPlaying,\n    videoRef,\n    handlePreviewImage,\n    handlePlayClick,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/hooks/usePanelPromptEditor.ts",
    "content": "import { useCallback, useState } from 'react'\n\ninterface UsePanelPromptEditorParams {\n  localPrompt: string\n  onUpdateLocalPrompt: (value: string) => void\n  onSavePrompt: (value: string) => Promise<void>\n}\n\nexport function usePanelPromptEditor({\n  localPrompt,\n  onUpdateLocalPrompt,\n  onSavePrompt,\n}: UsePanelPromptEditorParams) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [editingPrompt, setEditingPrompt] = useState(localPrompt)\n\n  const handleStartEdit = useCallback(() => {\n    setEditingPrompt(localPrompt)\n    setIsEditing(true)\n  }, [localPrompt])\n\n  const handleSave = useCallback(async () => {\n    onUpdateLocalPrompt(editingPrompt)\n    setIsEditing(false)\n    await onSavePrompt(editingPrompt)\n  }, [editingPrompt, onSavePrompt, onUpdateLocalPrompt])\n\n  const handleCancelEdit = useCallback(() => {\n    setEditingPrompt(localPrompt)\n    setIsEditing(false)\n  }, [localPrompt])\n\n  return {\n    isEditing,\n    editingPrompt,\n    setEditingPrompt,\n    handleStartEdit,\n    handleSave,\n    handleCancelEdit,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/hooks/usePanelTaskStatus.ts",
    "content": "import { resolveErrorDisplay } from '@/lib/errors/display'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport type { VideoPanelCardShellProps } from '../../types'\n\ninterface UsePanelTaskStatusParams {\n  panel: VideoPanelCardShellProps['panel']\n  hasVisibleBaseVideo: boolean\n  tCommon: (key: string) => string\n}\n\nexport function usePanelTaskStatus({ panel, hasVisibleBaseVideo, tCommon }: UsePanelTaskStatusParams) {\n  const isVideoTaskRunning = !!panel.videoTaskRunning\n  const isLipSyncTaskRunning = !!panel.lipSyncTaskRunning\n  const rawErrorMessage = panel.videoErrorMessage || panel.lipSyncErrorMessage || null\n  const panelErrorDisplayBase = resolveErrorDisplay({\n    code: panel.videoErrorCode || panel.lipSyncErrorCode || null,\n    message: rawErrorMessage,\n  })\n  const panelErrorDisplay =\n    panelErrorDisplayBase && rawErrorMessage && (\n      panelErrorDisplayBase.code === 'EXTERNAL_ERROR' || panelErrorDisplayBase.code === 'INTERNAL_ERROR'\n    )\n      ? { ...panelErrorDisplayBase, message: rawErrorMessage }\n      : panelErrorDisplayBase\n\n  const videoRunningPresentation = isVideoTaskRunning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: hasVisibleBaseVideo ? 'regenerate' : 'generate',\n      resource: 'video',\n      hasOutput: hasVisibleBaseVideo,\n    })\n    : null\n\n  const lipSyncRunningPresentation = isLipSyncTaskRunning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'process',\n      resource: 'video',\n      hasOutput: !!panel.lipSyncVideoUrl || hasVisibleBaseVideo,\n    })\n    : null\n\n  const taskRunningVideoLabel = isLipSyncTaskRunning\n    ? (lipSyncRunningPresentation?.labelKey\n      ? tCommon(lipSyncRunningPresentation.labelKey)\n      : tCommon('taskStatus.intent.process.running.video'))\n    : (videoRunningPresentation?.labelKey\n      ? tCommon(videoRunningPresentation.labelKey)\n      : tCommon('taskStatus.intent.generate.running.video'))\n\n  const overlayPresentation = (() => {\n    if (!isVideoTaskRunning && !isLipSyncTaskRunning) return null\n    if (isLipSyncTaskRunning) {\n      return (\n        lipSyncRunningPresentation ||\n        resolveTaskPresentationState({\n          phase: 'processing',\n          intent: 'process',\n          resource: 'video',\n          hasOutput: !!panel.lipSyncVideoUrl || hasVisibleBaseVideo,\n        })\n      )\n    }\n    return (\n      videoRunningPresentation ||\n      resolveTaskPresentationState({\n        phase: 'processing',\n        intent: 'generate',\n        resource: 'video',\n        hasOutput: hasVisibleBaseVideo,\n      })\n    )\n  })()\n\n  const lipSyncInlineState = resolveTaskPresentationState({\n    phase: 'processing',\n    intent: 'process',\n    resource: 'video',\n    hasOutput: !!panel.lipSyncVideoUrl || hasVisibleBaseVideo,\n  })\n\n  return {\n    isVideoTaskRunning,\n    isLipSyncTaskRunning,\n    panelErrorDisplay,\n    videoRunningPresentation,\n    lipSyncRunningPresentation,\n    taskRunningVideoLabel,\n    overlayPresentation,\n    lipSyncInlineState,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/hooks/usePanelVideoModel.ts",
    "content": "import { useEffect, useMemo, useState } from 'react'\nimport type { VideoModelOption, VideoGenerationOptionValue, VideoGenerationOptions } from '../../../types'\nimport type { CapabilitySelections } from '@/lib/model-config-contract'\nimport {\n  normalizeVideoGenerationSelections,\n  resolveEffectiveVideoCapabilityDefinitions,\n  resolveEffectiveVideoCapabilityFields,\n} from '@/lib/model-capabilities/video-effective'\nimport { projectVideoPricingTiersByFixedSelections } from '@/lib/model-pricing/video-tier'\n\ninterface UsePanelVideoModelParams {\n  defaultVideoModel: string\n  capabilityOverrides?: CapabilitySelections\n  userVideoModels?: VideoModelOption[]\n}\n\ninterface CapabilityField {\n  field: string\n  label: string\n  labelKey?: string\n  unitKey?: string\n  optionLabelKeys?: Record<string, string>\n  options: VideoGenerationOptionValue[]\n  disabledOptions?: VideoGenerationOptionValue[]\n  value: VideoGenerationOptionValue | undefined\n}\n\nfunction toFieldLabel(field: string): string {\n  return field.replace(/([A-Z])/g, ' $1').replace(/^./, (char) => char.toUpperCase())\n}\n\nfunction parseByOptionType(\n  input: string,\n  sample: VideoGenerationOptionValue,\n): VideoGenerationOptionValue {\n  if (typeof sample === 'number') return Number(input)\n  if (typeof sample === 'boolean') return input === 'true'\n  return input\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isGenerationOptionValue(value: unknown): value is VideoGenerationOptionValue {\n  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction readSelectionForModel(\n  capabilityOverrides: CapabilitySelections | undefined,\n  modelKey: string,\n): VideoGenerationOptions {\n  if (!modelKey || !capabilityOverrides) return {}\n  const rawSelection = capabilityOverrides[modelKey]\n  if (!isRecord(rawSelection)) return {}\n\n  const selection: VideoGenerationOptions = {}\n  for (const [field, value] of Object.entries(rawSelection)) {\n    if (field === 'aspectRatio') continue\n    if (!isGenerationOptionValue(value)) continue\n    selection[field] = value\n  }\n  return selection\n}\n\nexport function usePanelVideoModel({\n  defaultVideoModel,\n  capabilityOverrides,\n  userVideoModels,\n}: UsePanelVideoModelParams) {\n  const [selectedModel, setSelectedModel] = useState(defaultVideoModel || '')\n  const [generationOptions, setGenerationOptions] = useState<VideoGenerationOptions>(() =>\n    readSelectionForModel(capabilityOverrides, defaultVideoModel || ''),\n  )\n  const videoModelOptions = userVideoModels ?? []\n  const selectedOption = videoModelOptions.find((option) => option.value === selectedModel)\n  const pricingTiers = useMemo(\n    () => projectVideoPricingTiersByFixedSelections({\n      tiers: selectedOption?.videoPricingTiers ?? [],\n      fixedSelections: {\n        generationMode: 'normal',\n      },\n    }),\n    [selectedOption?.videoPricingTiers],\n  )\n\n  useEffect(() => {\n    setSelectedModel(defaultVideoModel || '')\n  }, [defaultVideoModel])\n\n  useEffect(() => {\n    if (!selectedModel) {\n      if (videoModelOptions.length > 0) {\n        setSelectedModel(videoModelOptions[0].value)\n      }\n      return\n    }\n    if (videoModelOptions.some((option) => option.value === selectedModel)) return\n    setSelectedModel(videoModelOptions[0]?.value || '')\n  }, [selectedModel, videoModelOptions])\n\n  const capabilityDefinitions = useMemo(\n    () => resolveEffectiveVideoCapabilityDefinitions({\n      videoCapabilities: selectedOption?.capabilities?.video,\n      pricingTiers,\n    }),\n    [pricingTiers, selectedOption?.capabilities?.video],\n  )\n\n  const selectedModelOverrides = useMemo(\n    () => readSelectionForModel(capabilityOverrides, selectedModel),\n    [capabilityOverrides, selectedModel],\n  )\n  const selectedModelOverridesSignature = useMemo(\n    () => JSON.stringify(selectedModelOverrides),\n    [selectedModelOverrides],\n  )\n\n  useEffect(() => {\n    setGenerationOptions(normalizeVideoGenerationSelections({\n      definitions: capabilityDefinitions,\n      pricingTiers,\n      selection: selectedModelOverrides,\n    }))\n  }, [selectedModel, selectedModelOverridesSignature, capabilityDefinitions, pricingTiers, selectedModelOverrides])\n\n  useEffect(() => {\n    setGenerationOptions((previous) => normalizeVideoGenerationSelections({\n      definitions: capabilityDefinitions,\n      pricingTiers,\n      selection: previous,\n    }))\n  }, [capabilityDefinitions, pricingTiers])\n\n  const effectiveFields = useMemo(\n    () => resolveEffectiveVideoCapabilityFields({\n      definitions: capabilityDefinitions,\n      pricingTiers,\n      selection: generationOptions,\n    }),\n    [capabilityDefinitions, generationOptions, pricingTiers],\n  )\n  const missingCapabilityFields = useMemo(\n    () => effectiveFields\n      .filter((field) => field.options.length === 0 || field.value === undefined)\n      .map((field) => field.field),\n    [effectiveFields],\n  )\n  const effectiveFieldMap = useMemo(\n    () => new Map(effectiveFields.map((field) => [field.field, field])),\n    [effectiveFields],\n  )\n  const definitionFieldMap = useMemo(\n    () => new Map(capabilityDefinitions.map((definition) => [definition.field, definition])),\n    [capabilityDefinitions],\n  )\n  const capabilityFields: CapabilityField[] = useMemo(() => {\n    return capabilityDefinitions.map((definition) => {\n      const effectiveField = effectiveFieldMap.get(definition.field)\n      const enabledOptions = effectiveField?.options ?? []\n      return {\n        field: definition.field,\n        label: toFieldLabel(definition.field),\n        labelKey: definition.fieldI18n?.labelKey,\n        unitKey: definition.fieldI18n?.unitKey,\n        optionLabelKeys: definition.fieldI18n?.optionLabelKeys,\n        options: definition.options as VideoGenerationOptionValue[],\n        disabledOptions: (definition.options as VideoGenerationOptionValue[])\n          .filter((option) => !enabledOptions.includes(option)),\n        value: effectiveField?.value as VideoGenerationOptionValue | undefined,\n      }\n    })\n  }, [capabilityDefinitions, effectiveFieldMap])\n\n  const setCapabilityValue = (field: string, rawValue: string) => {\n    const definitionField = definitionFieldMap.get(field)\n    if (!definitionField || definitionField.options.length === 0) return\n    const parsedValue = parseByOptionType(rawValue, definitionField.options[0])\n    if (!definitionField.options.includes(parsedValue)) return\n    setGenerationOptions((previous) => ({\n      ...normalizeVideoGenerationSelections({\n        definitions: capabilityDefinitions,\n        pricingTiers,\n        selection: {\n          ...previous,\n          [field]: parsedValue,\n        },\n        pinnedFields: [field],\n      }),\n    }))\n  }\n\n  return {\n    selectedModel,\n    setSelectedModel,\n    generationOptions,\n    capabilityFields,\n    setCapabilityValue,\n    missingCapabilityFields,\n    videoModelOptions,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/hooks/usePanelVoiceManager.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { queryKeys } from '@/lib/query/keys'\nimport { useGenerateProjectVoice } from '@/lib/query/hooks'\nimport type { MatchedVoiceLinesData } from '@/lib/query/hooks/useVoiceLines'\nimport { isAsyncTaskResponse } from '@/lib/task/client'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport type { MatchedVoiceLine } from '../../../types'\nimport { EMPTY_RUNNING_VOICE_LINE_IDS, getErrorMessage } from '../shared'\n\ninterface UsePanelVoiceManagerParams {\n  projectId: string\n  episodeId?: string\n  matchedVoiceLines: MatchedVoiceLine[]\n  runningVoiceLineIds?: Set<string>\n  audioFailedMessage: string\n}\n\nexport function usePanelVoiceManager({\n  projectId,\n  episodeId,\n  matchedVoiceLines,\n  runningVoiceLineIds = EMPTY_RUNNING_VOICE_LINE_IDS,\n  audioFailedMessage,\n}: UsePanelVoiceManagerParams) {\n  const generateProjectVoiceMutation = useGenerateProjectVoice(projectId)\n  const queryClient = useQueryClient()\n  const [submittingAudioIds, setSubmittingAudioIds] = useState<Set<string>>(new Set())\n  const [submittingVoiceAudioIds, setSubmittingVoiceAudioIds] = useState<Set<string>>(new Set())\n  const [audioGenerateError, setAudioGenerateError] = useState<string | null>(null)\n  const [playingVoiceLineId, setPlayingVoiceLineId] = useState<string | null>(null)\n  const audioRef = useRef<HTMLAudioElement | null>(null)\n  const localVoiceLines = matchedVoiceLines\n\n  const activeVoiceAudioIds = useMemo(() => {\n    const ids = new Set<string>()\n    for (const line of localVoiceLines) {\n      if (runningVoiceLineIds.has(line.id)) ids.add(line.id)\n    }\n    return ids\n  }, [localVoiceLines, runningVoiceLineIds])\n\n  useEffect(() => {\n    if (submittingVoiceAudioIds.size === 0) return\n    setSubmittingVoiceAudioIds((prev) => {\n      const next = new Set(prev)\n      for (const lineId of prev) {\n        const line = localVoiceLines.find((item) => item.id === lineId)\n        if (!line || line.audioUrl || activeVoiceAudioIds.has(lineId)) next.delete(lineId)\n      }\n      return next.size === prev.size ? prev : next\n    })\n  }, [activeVoiceAudioIds, localVoiceLines, submittingVoiceAudioIds.size])\n\n  useEffect(() => {\n    return () => {\n      if (!audioRef.current) return\n      audioRef.current.pause()\n      audioRef.current = null\n    }\n  }, [])\n\n  const isVoiceLineTaskRunning = useCallback((lineId: string) => {\n    return submittingAudioIds.has(lineId) || submittingVoiceAudioIds.has(lineId) || activeVoiceAudioIds.has(lineId)\n  }, [activeVoiceAudioIds, submittingAudioIds, submittingVoiceAudioIds])\n\n  const handlePlayVoiceLine = useCallback((voiceLine: MatchedVoiceLine) => {\n    if (!voiceLine.audioUrl) return\n    if (playingVoiceLineId === voiceLine.id) {\n      if (audioRef.current) {\n        audioRef.current.pause()\n        audioRef.current = null\n      }\n      setPlayingVoiceLineId(null)\n      return\n    }\n\n    if (audioRef.current) audioRef.current.pause()\n    const audio = new Audio(voiceLine.audioUrl)\n    audioRef.current = audio\n    setPlayingVoiceLineId(voiceLine.id)\n    audio.onended = () => {\n      setPlayingVoiceLineId(null)\n      audioRef.current = null\n    }\n    audio.onerror = () => {\n      setPlayingVoiceLineId(null)\n      audioRef.current = null\n    }\n    audio.play().catch(() => {\n      setPlayingVoiceLineId(null)\n      audioRef.current = null\n    })\n  }, [playingVoiceLineId])\n\n  const handleGenerateAudio = useCallback(async (voiceLine: MatchedVoiceLine) => {\n    if (!episodeId) return\n    setSubmittingAudioIds((prev) => new Set(prev).add(voiceLine.id))\n    setSubmittingVoiceAudioIds((prev) => new Set(prev).add(voiceLine.id))\n    setAudioGenerateError(null)\n    let handoffToTaskState = false\n\n    try {\n      const data = await generateProjectVoiceMutation.mutateAsync({\n        episodeId,\n        lineId: voiceLine.id,\n      })\n\n      if (isAsyncTaskResponse(data)) {\n        handoffToTaskState = true\n        return\n      }\n\n      const payload = data as { success?: boolean; results?: Array<{ audioUrl?: string }> }\n      if (payload.results?.[0]?.audioUrl) {\n        queryClient.setQueryData<MatchedVoiceLinesData>(\n          queryKeys.voiceLines.matched(projectId, episodeId),\n          (previous) => {\n            if (!previous) return previous\n            return {\n              ...previous,\n              voiceLines: previous.voiceLines.map((line) => (\n                line.id === voiceLine.id ? { ...line, audioUrl: payload.results![0].audioUrl ?? null } : line\n              )),\n            }\n          },\n        )\n      } else if (payload.success === false) {\n        throw new Error(audioFailedMessage)\n      }\n    } catch (error: unknown) {\n      _ulogError('Generate audio error:', error)\n      setAudioGenerateError(getErrorMessage(error) || audioFailedMessage)\n    } finally {\n      setSubmittingAudioIds((prev) => {\n        const next = new Set(prev)\n        next.delete(voiceLine.id)\n        return next\n      })\n      if (handoffToTaskState) return\n      setSubmittingVoiceAudioIds((prev) => {\n        const next = new Set(prev)\n        next.delete(voiceLine.id)\n        return next\n      })\n    }\n  }, [audioFailedMessage, episodeId, generateProjectVoiceMutation, projectId, queryClient])\n\n  const hasMatchedVoiceLines = localVoiceLines.length > 0\n  const hasMatchedAudio = localVoiceLines.some((line) => line.audioUrl)\n\n  return {\n    localVoiceLines,\n    audioGenerateError,\n    playingVoiceLineId,\n    isVoiceLineTaskRunning,\n    handlePlayVoiceLine,\n    handleGenerateAudio,\n    hasMatchedVoiceLines,\n    hasMatchedAudio,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/shared.ts",
    "content": "export const EMPTY_RUNNING_VOICE_LINE_IDS: Set<string> = new Set()\n\nexport function getErrorMessage(error: unknown): string {\n  if (error instanceof Error) return error.message\n  if (typeof error === 'object' && error !== null) {\n    const message = (error as { message?: unknown }).message\n    if (typeof message === 'string') return message\n  }\n  return 'Unknown error'\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/videoPanelRuntimeCore.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport type { VideoPanelCardShellProps } from '../types'\nimport { EMPTY_RUNNING_VOICE_LINE_IDS } from './shared'\nimport { usePanelTaskStatus } from './hooks/usePanelTaskStatus'\nimport { usePanelVideoModel } from './hooks/usePanelVideoModel'\nimport { usePanelPlayer } from './hooks/usePanelPlayer'\nimport { usePanelPromptEditor } from './hooks/usePanelPromptEditor'\nimport { usePanelVoiceManager } from './hooks/usePanelVoiceManager'\nimport { usePanelLipSync } from './hooks/usePanelLipSync'\n\nexport function useVideoPanelActions({\n  panel,\n  panelIndex,\n  defaultVideoModel,\n  capabilityOverrides,\n  videoRatio = '16:9',\n  userVideoModels,\n  projectId,\n  episodeId,\n  runningVoiceLineIds = EMPTY_RUNNING_VOICE_LINE_IDS,\n  matchedVoiceLines = [],\n  onLipSync,\n  showLipSyncVideo,\n  onToggleLipSyncVideo,\n  isLinked,\n  isLastFrame,\n  nextPanel,\n  prevPanel,\n  hasNext,\n  flModel,\n  flModelOptions,\n  flGenerationOptions,\n  flCapabilityFields,\n  flMissingCapabilityFields,\n  flCustomPrompt,\n  defaultFlPrompt,\n  localPrompt,\n  isSavingPrompt,\n  onUpdateLocalPrompt,\n  onSavePrompt,\n  onGenerateVideo,\n  onUpdatePanelVideoModel,\n  onToggleLink,\n  onFlModelChange,\n  onFlCapabilityChange,\n  onFlCustomPromptChange,\n  onResetFlPrompt,\n  onGenerateFirstLastFrame,\n  onPreviewImage,\n}: VideoPanelCardShellProps) {\n  const t = useTranslations('video')\n  const tCommon = useTranslations('common')\n  const panelKey = `${panel.storyboardId}-${panel.panelIndex}`\n  const isFirstLastFrameOutput = panel.videoGenerationMode === 'firstlastframe' && !!panel.videoUrl\n  const visibleBaseVideoUrl = (() => {\n    if (isLinked) return isFirstLastFrameOutput ? panel.videoUrl : undefined\n    if (isLastFrame) return undefined\n    return panel.videoUrl\n  })()\n  const hasVisibleBaseVideo = !!visibleBaseVideoUrl\n\n  const taskStatus = usePanelTaskStatus({\n    panel,\n    hasVisibleBaseVideo,\n    tCommon: (key: string) => tCommon(key as never),\n  })\n\n  const videoModel = usePanelVideoModel({\n    defaultVideoModel,\n    capabilityOverrides,\n    userVideoModels,\n  })\n\n  const player = usePanelPlayer({\n    videoRatio,\n    imageUrl: panel.imageUrl,\n    videoUrl: visibleBaseVideoUrl,\n    lipSyncVideoUrl: panel.lipSyncVideoUrl,\n    showLipSyncVideo,\n    onPreviewImage,\n  })\n\n  const promptEditor = usePanelPromptEditor({\n    localPrompt,\n    onUpdateLocalPrompt,\n    onSavePrompt,\n  })\n\n  const voiceManager = usePanelVoiceManager({\n    projectId,\n    episodeId,\n    matchedVoiceLines,\n    runningVoiceLineIds,\n    audioFailedMessage: t('panelCard.error.audioFailed'),\n  })\n\n  const lipSync = usePanelLipSync({\n    panel,\n    matchedVoiceLines,\n    onLipSync,\n  })\n\n  const showLipSyncSection = voiceManager.hasMatchedVoiceLines\n  const canLipSync = hasVisibleBaseVideo && voiceManager.hasMatchedAudio && !taskStatus.isLipSyncTaskRunning\n\n  return {\n    t,\n    tCommon,\n    panel,\n    panelIndex,\n    panelKey,\n    media: {\n      showLipSyncVideo,\n      onToggleLipSyncVideo,\n      onPreviewImage,\n      baseVideoUrl: visibleBaseVideoUrl,\n      currentVideoUrl: player.currentVideoUrl,\n    },\n    taskStatus,\n    videoModel,\n    player,\n    promptEditor: {\n      ...promptEditor,\n      localPrompt,\n      isSavingPrompt,\n    },\n    voiceManager,\n    lipSync,\n    layout: {\n      isLinked,\n      isLastFrame,\n      nextPanel,\n      prevPanel,\n      hasNext,\n      flModel,\n      flModelOptions,\n      flGenerationOptions,\n      flCapabilityFields,\n      flMissingCapabilityFields,\n      flCustomPrompt,\n      defaultFlPrompt,\n      videoRatio,\n    },\n    actions: {\n      onGenerateVideo,\n      onUpdatePanelVideoModel,\n      onToggleLink,\n      onFlModelChange,\n      onFlCapabilityChange,\n      onFlCustomPromptChange,\n      onResetFlPrompt,\n      onGenerateFirstLastFrame,\n    },\n    computed: {\n      showLipSyncSection,\n      canLipSync,\n      hasVisibleBaseVideo,\n    },\n  }\n}\n\nexport type VideoPanelRuntime = ReturnType<typeof useVideoPanelActions>\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/types.ts",
    "content": "import type { VideoPanel, MatchedVoiceLine, VideoModelOption, FirstLastFrameParams, VideoGenerationOptions } from '../types'\nimport type { CapabilitySelections, CapabilityValue } from '@/lib/model-config-contract'\n\nexport interface VideoPanelCardShellProps {\n  panel: VideoPanel\n  panelIndex: number\n  defaultVideoModel: string\n  capabilityOverrides: CapabilitySelections\n  videoRatio?: string\n  userVideoModels?: VideoModelOption[]\n  projectId: string\n  episodeId?: string\n  runningVoiceLineIds?: Set<string>\n  matchedVoiceLines?: MatchedVoiceLine[]\n  onLipSync?: (storyboardId: string, panelIndex: number, voiceLineId: string, panelId?: string) => Promise<void>\n  showLipSyncVideo: boolean\n  onToggleLipSyncVideo: (panelKey: string, value: boolean) => void\n  isLinked: boolean\n  isLastFrame: boolean\n  nextPanel: VideoPanel | null\n  prevPanel: VideoPanel | null\n  hasNext: boolean\n  flModel: string\n  flModelOptions: VideoModelOption[]\n  flGenerationOptions: VideoGenerationOptions\n  flCapabilityFields: Array<{\n    field: string\n    label: string\n    options: CapabilityValue[]\n    disabledOptions?: CapabilityValue[]\n    value: CapabilityValue | undefined\n  }>\n  flMissingCapabilityFields: string[]\n  flCustomPrompt: string\n  defaultFlPrompt: string\n  localPrompt: string\n  isSavingPrompt: boolean\n  onUpdateLocalPrompt: (value: string) => void\n  onSavePrompt: (value: string) => Promise<void>\n  onGenerateVideo: (\n    storyboardId: string,\n    panelIndex: number,\n    videoModel?: string,\n    firstLastFrame?: FirstLastFrameParams,\n    generationOptions?: VideoGenerationOptions,\n    panelId?: string,\n  ) => void\n  onUpdatePanelVideoModel: (storyboardId: string, panelIndex: number, model: string) => void\n  onToggleLink: (panelKey: string, storyboardId: string, panelIndex: number) => void\n  onFlModelChange: (model: string) => void\n  onFlCapabilityChange: (field: string, rawValue: string) => void\n  onFlCustomPromptChange: (panelKey: string, value: string) => void\n  onResetFlPrompt: (panelKey: string) => void\n  onGenerateFirstLastFrame: (\n    firstStoryboardId: string,\n    firstPanelIndex: number,\n    lastStoryboardId: string,\n    lastPanelIndex: number,\n    panelKey: string,\n    generationOptions?: VideoGenerationOptions,\n    firstPanelId?: string,\n  ) => void\n  onPreviewImage?: (imageUrl: string) => void\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/types.ts",
    "content": "// 视频阶段共享类型定义\nimport type { ModelCapabilities } from '@/lib/model-config-contract'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\n\n// 用户视频模型选项\nexport interface VideoModelOption {\n  value: string\n  label: string\n  provider?: string\n  providerName?: string\n  disabled?: boolean\n  capabilities?: ModelCapabilities\n  videoPricingTiers?: VideoPricingTier[]\n}\n\nexport type VideoGenerationMode = 'normal' | 'firstlastframe'\n\nexport interface TextPanel {\n  panel_number: number\n  shot_type: string\n  camera_move?: string\n  description: string\n  characters?: Array<string | { name?: string; appearance?: string }>\n  location?: string\n  text_segment?: string\n  duration?: number\n  video_prompt?: string\n  imagePrompt?: string\n  videoModel?: string\n}\n\nexport interface Panel {\n  id?: string\n  panelIndex: number\n  panelNumber?: number | null\n  shotType?: string | null\n  cameraMove?: string | null\n  description?: string | null\n  characters?: string | null\n  location?: string | null\n  textSegment?: string | null\n  srtSegment?: string | null  // SRT 原文片段\n  duration?: number | null\n  imagePrompt?: string | null\n  imageUrl?: string | null  // 图片URL\n  videoPrompt?: string | null\n  firstLastFramePrompt?: string | null\n  videoUrl?: string | null\n  videoGenerationMode?: VideoGenerationMode | null\n  videoModel?: string | null\n  linkedToNextPanel?: boolean | null\n  videoTaskRunning?: boolean | null\n  videoErrorMessage?: string | null  // 视频生成错误消息\n  videoErrorCode?: string | null\n  imageTaskRunning?: boolean | null\n  // 口型同步相关\n  lipSyncVideoUrl?: string | null\n  lipSyncTaskRunning?: boolean | null\n  lipSyncErrorMessage?: string | null  // 口型同步错误消息\n  lipSyncErrorCode?: string | null\n}\n\nexport interface Storyboard {\n  id: string\n  clipId?: string | null\n  panels?: Panel[]\n  clip?: {\n    start: number\n    end: number\n    summary: string\n  }\n}\n\nexport interface Clip {\n  id: string\n  start: number\n  end: number\n  summary: string\n}\n\nexport interface VideoPanel {\n  panelId?: string  // 任务目标ID\n  storyboardId: string\n  panelIndex: number\n  textPanel?: TextPanel\n  firstLastFramePrompt?: string\n  imageUrl?: string\n  videoUrl?: string\n  videoGenerationMode?: VideoGenerationMode\n  videoTaskRunning?: boolean\n  videoErrorMessage?: string  // 视频生成错误消息\n  videoErrorCode?: string\n  videoModel?: string\n  linkedToNextPanel?: boolean\n  // 口型同步相关\n  lipSyncVideoUrl?: string\n  lipSyncTaskRunning?: boolean\n  lipSyncTaskId?: string\n  lipSyncErrorMessage?: string  // 口型同步错误消息\n  lipSyncErrorCode?: string\n}\n\n// 匹配的配音信息\nexport interface MatchedVoiceLine {\n  id: string\n  lineIndex: number\n  speaker: string\n  content: string\n  audioUrl?: string\n  audioDuration?: number\n  emotionStrength?: number\n}\n\nexport interface FirstLastFrameParams {\n  lastFrameStoryboardId: string\n  lastFramePanelIndex: number\n  flModel: string\n  customPrompt?: string\n}\n\nexport type VideoGenerationOptionValue = string | number | boolean\nexport type VideoGenerationOptions = Record<string, VideoGenerationOptionValue>\n\nexport interface BatchVideoGenerationParams {\n  videoModel: string\n  generationOptions?: VideoGenerationOptions\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video-stage/VideoRenderPanel.tsx",
    "content": "import { getAspectRatioConfig } from '@/lib/constants'\nimport type { MutableRefObject } from 'react'\nimport type { CapabilitySelections, CapabilityValue } from '@/lib/model-config-contract'\nimport { VideoPanelCard, type VideoPanel, type VideoModelOption, type MatchedVoiceLine, type FirstLastFrameParams, type VideoGenerationOptions } from '../video'\nimport type { PromptField } from '@/lib/novel-promotion/stages/video-stage-runtime/useVideoPromptState'\n\ninterface VideoRenderPanelProps {\n  allPanels: VideoPanel[]\n  linkedPanels: Map<string, boolean>\n  highlightedPanelKey: string | null\n  panelRefs: MutableRefObject<Map<string, HTMLDivElement>>\n  videoRatio: string\n  defaultVideoModel: string\n  capabilityOverrides: CapabilitySelections\n  userVideoModels?: VideoModelOption[]\n  projectId: string\n  episodeId: string\n  runningVoiceLineIds: Set<string>\n  panelVoiceLines: Map<string, MatchedVoiceLine[]>\n  panelVideoPreference: Map<string, boolean>\n  savingPrompts: Set<string>\n  flModel: string\n  flModelOptions: VideoModelOption[]\n  flGenerationOptions: VideoGenerationOptions\n  flCapabilityFields: Array<{\n    field: string\n    label: string\n    options: CapabilityValue[]\n    disabledOptions?: CapabilityValue[]\n    value: CapabilityValue | undefined\n  }>\n  flMissingCapabilityFields: string[]\n  flCustomPrompts: Map<string, string>\n  onGenerateVideo: (\n    storyboardId: string,\n    panelIndex: number,\n    videoModel?: string,\n    firstLastFrame?: FirstLastFrameParams,\n    generationOptions?: VideoGenerationOptions,\n    panelId?: string,\n  ) => Promise<void>\n  onUpdatePanelVideoModel: (storyboardId: string, panelIndex: number, model: string) => Promise<void>\n  onLipSync: (storyboardId: string, panelIndex: number, voiceLineId: string, panelId?: string) => Promise<void>\n  onToggleLink: (panelKey: string, storyboardId: string, panelIndex: number) => Promise<void>\n  onFlModelChange: (model: string) => void\n  onFlCapabilityChange: (field: string, rawValue: string) => void\n  onFlCustomPromptChange: (key: string, value: string) => void\n  onResetFlPrompt: (key: string) => void\n  onGenerateFirstLastFrame: (\n    firstStoryboardId: string,\n    firstPanelIndex: number,\n    lastStoryboardId: string,\n    lastPanelIndex: number,\n    panelKey: string,\n    generationOptions?: VideoGenerationOptions,\n    firstPanelId?: string,\n  ) => Promise<void>\n  onPreviewImage: (imageUrl: string | null) => void\n  onToggleLipSyncVideo: (key: string, value: boolean) => void\n  getNextPanel: (currentIndex: number) => VideoPanel | null\n  isLinkedAsLastFrame: (currentIndex: number) => boolean\n  getDefaultFlPrompt: (firstPrompt?: string, lastPrompt?: string) => string\n  getLocalPrompt: (panelKey: string, externalPrompt?: string, field?: PromptField) => string\n  updateLocalPrompt: (panelKey: string, value: string, field?: PromptField) => void\n  savePrompt: (\n    storyboardId: string,\n    panelIndex: number,\n    panelKey: string,\n    value: string,\n    field?: PromptField,\n  ) => Promise<void>\n}\n\nexport default function VideoRenderPanel({\n  allPanels,\n  linkedPanels,\n  highlightedPanelKey,\n  panelRefs,\n  videoRatio,\n  defaultVideoModel,\n  capabilityOverrides,\n  userVideoModels,\n  projectId,\n  episodeId,\n  runningVoiceLineIds,\n  panelVoiceLines,\n  panelVideoPreference,\n  savingPrompts,\n  flModel,\n  flModelOptions,\n  flGenerationOptions,\n  flCapabilityFields,\n  flMissingCapabilityFields,\n  flCustomPrompts,\n  onGenerateVideo,\n  onUpdatePanelVideoModel,\n  onLipSync,\n  onToggleLink,\n  onFlModelChange,\n  onFlCapabilityChange,\n  onFlCustomPromptChange,\n  onResetFlPrompt,\n  onGenerateFirstLastFrame,\n  onPreviewImage,\n  onToggleLipSyncVideo,\n  getNextPanel,\n  isLinkedAsLastFrame,\n  getDefaultFlPrompt,\n  getLocalPrompt,\n  updateLocalPrompt,\n  savePrompt,\n}: VideoRenderPanelProps) {\n  return (\n    <>\n      <div className={`grid gap-4 ${getAspectRatioConfig(videoRatio).isVertical\n        ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'\n        : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'\n      }`}>\n        {allPanels.map((panel, idx) => {\n          const panelKey = `${panel.storyboardId}-${panel.panelIndex}`\n          const isLinked = linkedPanels.get(panelKey) || false\n          const isLastFrame = isLinkedAsLastFrame(idx)\n          const nextPanel = getNextPanel(idx)\n          const prevPanel = idx > 0 ? allPanels[idx - 1] : null\n          const hasNext = idx < allPanels.length - 1\n          const promptField: PromptField = isLinked ? 'firstLastFramePrompt' : 'videoPrompt'\n          const defaultFlPrompt = getDefaultFlPrompt(panel.textPanel?.video_prompt, nextPanel?.textPanel?.video_prompt)\n          const externalPrompt = isLinked\n            ? (panel.firstLastFramePrompt || defaultFlPrompt)\n            : panel.textPanel?.video_prompt\n          const localPrompt = getLocalPrompt(panelKey, externalPrompt, promptField)\n          const isSavingPrompt = savingPrompts.has(`${promptField}:${panelKey}`)\n\n          return (\n            <div\n              key={panelKey}\n              ref={(element) => {\n                if (element) panelRefs.current.set(panelKey, element)\n                else panelRefs.current.delete(panelKey)\n              }}\n              className={`transition-all duration-500 ${highlightedPanelKey === panelKey\n                ? 'ring-4 ring-[var(--glass-stroke-focus)] ring-offset-2 ring-offset-[var(--glass-bg-canvas)] rounded-2xl scale-[1.02]'\n                : ''\n              }`}\n            >\n              <VideoPanelCard\n                panel={{\n                  ...panel,\n                  lipSyncTaskRunning: panel.lipSyncTaskRunning || false,\n                }}\n                panelIndex={idx}\n                defaultVideoModel={defaultVideoModel}\n                capabilityOverrides={capabilityOverrides}\n                videoRatio={videoRatio}\n                userVideoModels={userVideoModels}\n                projectId={projectId}\n                episodeId={episodeId}\n                runningVoiceLineIds={runningVoiceLineIds}\n                matchedVoiceLines={panelVoiceLines.get(panelKey) || []}\n                onLipSync={onLipSync}\n                showLipSyncVideo={panelVideoPreference.get(panelKey) ?? true}\n                onToggleLipSyncVideo={onToggleLipSyncVideo}\n                isLinked={isLinked}\n                isLastFrame={isLastFrame}\n                nextPanel={nextPanel}\n                prevPanel={prevPanel}\n                hasNext={hasNext}\n                flModel={flModel}\n                flModelOptions={flModelOptions}\n                flGenerationOptions={flGenerationOptions}\n                flCapabilityFields={flCapabilityFields}\n                flMissingCapabilityFields={flMissingCapabilityFields}\n                flCustomPrompt={flCustomPrompts.get(panelKey) || panel.firstLastFramePrompt || ''}\n                defaultFlPrompt={defaultFlPrompt}\n                localPrompt={localPrompt}\n                isSavingPrompt={isSavingPrompt}\n                onUpdateLocalPrompt={(value) => {\n                  updateLocalPrompt(panelKey, value, promptField)\n                  if (isLinked) onFlCustomPromptChange(panelKey, value)\n                }}\n                onSavePrompt={(value) => savePrompt(panel.storyboardId, panel.panelIndex, panelKey, value, promptField)}\n                onGenerateVideo={onGenerateVideo}\n                onUpdatePanelVideoModel={onUpdatePanelVideoModel}\n                onToggleLink={onToggleLink}\n                onFlModelChange={onFlModelChange}\n                onFlCapabilityChange={onFlCapabilityChange}\n                onFlCustomPromptChange={onFlCustomPromptChange}\n                onResetFlPrompt={onResetFlPrompt}\n                onGenerateFirstLastFrame={onGenerateFirstLastFrame}\n                onPreviewImage={onPreviewImage}\n              />\n            </div>\n          )\n        })}\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video-stage/VideoStageLayout.tsx",
    "content": "'use client'\n\nimport { useVideoStageRuntime, type VideoStageShellProps } from './hooks/useVideoStageRuntime'\n\nexport type { VideoStageShellProps }\n\nexport default function VideoStageLayout(props: VideoStageShellProps) {\n  return useVideoStageRuntime(props)\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video-stage/VideoStageShell.tsx",
    "content": "'use client'\n\nimport VideoStageLayout, { type VideoStageShellProps } from './VideoStageLayout'\n\nexport type { VideoStageShellProps }\n\nexport default function VideoStageShell(props: VideoStageShellProps) {\n  return <VideoStageLayout {...props} />\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video-stage/VideoTimelinePanel.tsx",
    "content": "import { useTranslations } from 'next-intl'\nimport VoiceStage from '../VoiceStage'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface VoiceLineSummary {\n  audioUrl: string | null\n}\n\ninterface VideoTimelinePanelProps {\n  projectId: string\n  episodeId: string\n  allVoiceLines: VoiceLineSummary[]\n  expanded: boolean\n  onToggleExpanded: () => void\n  onReloadVoiceLines: () => Promise<void>\n  onLocateVoiceLine: (storyboardId: string, panelIndex: number) => void\n  onOpenAssetLibraryForCharacter?: (characterId?: string | null) => void\n}\n\nexport default function VideoTimelinePanel({\n  projectId,\n  episodeId,\n  allVoiceLines,\n  expanded,\n  onToggleExpanded,\n  onReloadVoiceLines,\n  onLocateVoiceLine,\n  onOpenAssetLibraryForCharacter,\n}: VideoTimelinePanelProps) {\n  const tVoice = useTranslations('voice')\n\n  return (\n    <div className=\"glass-surface-elevated overflow-hidden\">\n      <div\n        role=\"button\"\n        tabIndex={0}\n        onClick={onToggleExpanded}\n        onKeyDown={(event) => {\n          if (event.key === 'Enter' || event.key === ' ') {\n            event.preventDefault()\n            onToggleExpanded()\n          }\n        }}\n        className=\"w-full px-6 py-4 flex items-center justify-between hover:bg-[var(--glass-bg-muted)]/50 transition-colors cursor-pointer\"\n      >\n        <div className=\"flex items-center gap-3\">\n          <div className=\"w-10 h-10 bg-[var(--glass-accent-from)] rounded-xl flex items-center justify-center shadow-[var(--glass-shadow-md)]\">\n            <AppIcon name=\"micOutline\" className=\"w-5 h-5 text-white\" />\n          </div>\n          <div className=\"text-left\">\n            <h3 className=\"font-bold text-[var(--glass-text-primary)]\">{tVoice('title')}</h3>\n            <p className=\"text-sm text-[var(--glass-text-tertiary)]\">\n              {tVoice('linesCount', { count: allVoiceLines.length })}\n              {tVoice('audioGeneratedCount', { count: allVoiceLines.filter((line) => line.audioUrl).length })}\n            </p>\n          </div>\n        </div>\n        <AppIcon name=\"chevronDown\" className={`w-5 h-5 text-[var(--glass-text-tertiary)] transition-transform ${expanded ? 'rotate-180' : ''}`} />\n      </div>\n\n      {expanded && (\n        <div className=\"py-4\">\n          <VoiceStage\n            projectId={projectId}\n            episodeId={episodeId}\n            embedded={true}\n            onVoiceLinesChanged={onReloadVoiceLines}\n            onVoiceLineClick={onLocateVoiceLine}\n            onOpenAssetLibraryForCharacter={onOpenAssetLibraryForCharacter}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video-stage/hooks/useVideoStageRuntime.tsx",
    "content": "'use client'\n\nexport {\n  useVideoStageRuntime,\n  type VideoStageShellProps,\n} from '@/lib/novel-promotion/stages/video-stage-runtime-core'\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice/EmbeddedVoiceToolbar.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\n\ninterface EmbeddedVoiceToolbarProps {\n    totalLines: number\n    linesWithAudio: number\n    analyzing: boolean\n    isDownloading: boolean\n    isBatchSubmitting: boolean\n    runningCount: number\n    allSpeakersHaveVoice: boolean\n    onAddLine: () => void\n    onAnalyze: () => void\n    onDownloadAll: () => void\n    onGenerateAll: () => void\n}\n\nexport default function EmbeddedVoiceToolbar({\n    totalLines,\n    linesWithAudio,\n    analyzing,\n    isDownloading,\n    isBatchSubmitting,\n    runningCount,\n    allSpeakersHaveVoice,\n    onAddLine,\n    onAnalyze,\n    onDownloadAll,\n    onGenerateAll\n}: EmbeddedVoiceToolbarProps) {\n    const t = useTranslations('voice')\n    const voiceTaskRunningState = isBatchSubmitting\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'audio',\n            hasOutput: linesWithAudio > 0,\n        })\n        : null\n    const voiceAnalyzingState = analyzing\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'text',\n            hasOutput: false,\n        })\n        : null\n    const voiceDownloadingState = isDownloading\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'audio',\n            hasOutput: linesWithAudio > 0,\n        })\n        : null\n\n    const getGenerateButtonTitle = () => {\n        if (isBatchSubmitting) return t(\"embedded.generatingHint\")\n        if (!allSpeakersHaveVoice) return t(\"embedded.noVoiceHint\")\n        if (totalLines === 0) return t(\"embedded.noLinesHint\")\n        if (linesWithAudio >= totalLines) return t(\"embedded.allDoneHint\")\n        return t(\"embedded.generateHint\", { count: totalLines - linesWithAudio })\n    }\n\n    return (\n        <div className=\"flex items-center justify-end mb-3 px-4\">\n            <div className=\"flex items-center gap-3\">\n                <div className=\"text-xs text-[var(--glass-text-tertiary)]\">\n                    {t(\"embedded.linesStats\", { total: totalLines, audio: linesWithAudio })}\n                </div>\n\n                {/* 重新分析按钮 */}\n                <button\n                    onClick={onAnalyze}\n                    disabled={analyzing}\n                    className=\"glass-btn-base glass-btn-primary flex items-center gap-2 px-4 py-2 font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n                    title={totalLines > 0 ? t(\"embedded.reanalyzeHint\") : t(\"embedded.analyzeHint\")}\n                >\n                    {analyzing ? (\n                        <TaskStatusInline state={voiceAnalyzingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                    ) : totalLines > 0 ? t(\"embedded.reanalyze\") : t(\"embedded.analyzeLines\")}\n                </button>\n\n                <button\n                    onClick={onAddLine}\n                    className=\"glass-btn-base glass-btn-secondary flex items-center gap-2 px-4 py-2 font-medium border border-[var(--glass-stroke-base)]\"\n                >\n                    {t(\"embedded.addLine\")}\n                </button>\n\n                {/* 下载按钮 */}\n                <button\n                    onClick={onDownloadAll}\n                    disabled={linesWithAudio === 0 || isDownloading}\n                    className=\"glass-btn-base glass-btn-tone-info flex items-center gap-2 px-4 py-2 font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n                    title={linesWithAudio === 0 ? t(\"toolbar.noDownload\") : t(\"toolbar.downloadCount\", { count: linesWithAudio })}\n                >\n                    {isDownloading ? (\n                        <TaskStatusInline state={voiceDownloadingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                    ) : (\n                        <>{t(\"embedded.downloadVoice\")}</>\n                    )}\n                </button>\n\n                {/* 生成全部按钮 */}\n                <button\n                    onClick={onGenerateAll}\n                    disabled={isBatchSubmitting || !allSpeakersHaveVoice || totalLines === 0}\n                    className=\"glass-btn-base glass-btn-tone-success flex items-center gap-2 px-4 py-2 font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n                    title={getGenerateButtonTitle()}\n                >\n                    {isBatchSubmitting ? (\n                        <>\n                            <TaskStatusInline state={voiceTaskRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                            <span className=\"text-xs text-white/90\">{t(\"embedded.generatingProgress\", { current: runningCount, total: totalLines - linesWithAudio })}</span>\n                        </>\n                    ) : (\n                        <>\n                            {t(\"embedded.generateAllVoice\")}\n                            {linesWithAudio > 0 && (\n                                <span className=\"text-xs opacity-75\">{t(\"embedded.pendingCount\", { count: totalLines - linesWithAudio })}</span>\n                            )}\n                        </>\n                    )}\n                </button>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice/EmotionSettingsPanel.tsx",
    "content": "'use client'\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\n\ninterface EmotionSettingsPanelProps {\n    lineId: string\n    emotionPrompt: string | null\n    emotionStrength: number\n    onSave: (lineId: string, emotionPrompt: string | null, emotionStrength: number) => void\n    onGenerate: (lineId: string) => void\n    isVoiceGenerationRunning: boolean\n}\n\nexport default function EmotionSettingsPanel({\n    lineId,\n    emotionPrompt,\n    emotionStrength,\n    onSave,\n    onGenerate,\n    isVoiceGenerationRunning\n}: EmotionSettingsPanelProps) {\n    const t = useTranslations('voice')\n    const voiceGenerationState = isVoiceGenerationRunning\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'audio',\n            hasOutput: false,\n        })\n        : null\n    const [prompt, setPrompt] = useState(emotionPrompt || '')\n    const [strength, setStrength] = useState(emotionStrength)\n\n    const handlePromptChange = (value: string) => {\n        setPrompt(value)\n    }\n\n    const handleStrengthChange = (value: number) => {\n        setStrength(value)\n    }\n\n    const handleGenerate = () => {\n        onSave(lineId, prompt.trim() || null, strength)\n        onGenerate(lineId)\n    }\n\n    return (\n        <div className=\"px-4 py-3 bg-[var(--glass-tone-info-bg)] space-y-3\">\n            {/* 情绪提示词 */}\n            <div>\n                <label className=\"block text-xs text-[var(--glass-tone-info-fg)] mb-1.5 font-medium\">\n                    {t(\"emotionPrompt\")} <span className=\"text-[var(--glass-text-tertiary)] font-normal\">{t(\"emotionPromptTip\")}</span>\n                </label>\n                <input\n                    type=\"text\"\n                    value={prompt}\n                    onChange={(e) => handlePromptChange(e.target.value)}\n                    placeholder={t(\"emotionPlaceholder\")}\n                    className=\"w-full px-3 py-2 text-sm border border-[var(--glass-stroke-focus)]/60 rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]/50 focus:border-[var(--glass-stroke-focus)] bg-[var(--glass-bg-surface)]\"\n                />\n            </div>\n\n            {/* 情绪强度滑块 */}\n            <div>\n                <label className=\"block text-xs text-[var(--glass-tone-info-fg)] mb-1.5 font-medium\">\n                    {t(\"emotionStrength\")}: <span className=\"font-bold\">{strength.toFixed(1)}</span>\n                </label>\n                <input\n                    type=\"range\"\n                    min=\"0\"\n                    max=\"1\"\n                    step=\"0.1\"\n                    value={strength}\n                    onChange={(e) => handleStrengthChange(parseFloat(e.target.value))}\n                    className=\"w-full h-2 bg-[var(--glass-tone-info-bg)] rounded-lg appearance-none cursor-pointer accent-[var(--glass-accent-from)]\"\n                />\n                <div className=\"flex justify-between text-[10px] text-[var(--glass-text-tertiary)] mt-1\">\n                    <span>{t(\"flat\")}</span>\n                    <span>{t(\"intense\")}</span>\n                </div>\n            </div>\n\n            {/* 生成语音按钮 */}\n            <button\n                onClick={handleGenerate}\n                disabled={isVoiceGenerationRunning}\n                className=\"w-full py-2 text-sm bg-[var(--glass-tone-success-fg)] text-white rounded-xl hover:bg-[var(--glass-tone-success-fg)] font-medium transition-all shadow-[var(--glass-shadow-sm)] disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n                {isVoiceGenerationRunning ? (\n                    <TaskStatusInline state={voiceGenerationState} className=\"justify-center text-white [&>span]:text-white [&_svg]:text-white\" />\n                ) : t(\"generateVoice\")}\n            </button>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice/EmptyVoiceState.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface EmptyVoiceStateProps {\n    onAnalyze: () => void\n    analyzing: boolean\n}\n\nexport default function EmptyVoiceState({\n    onAnalyze,\n    analyzing\n}: EmptyVoiceStateProps) {\n    const t = useTranslations('voice')\n    const analyzingState = analyzing\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'analyze',\n            resource: 'text',\n            hasOutput: false,\n        })\n        : null\n\n    return (\n        <div className=\"glass-surface-elevated p-10 text-center\">\n            <div className=\"mb-4 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]\">\n                <AppIcon name=\"micOutline\" className=\"h-7 w-7\" />\n            </div>\n            <h3 className=\"text-xl font-bold text-[var(--glass-text-secondary)] mb-2\">{t(\"empty.title\")}</h3>\n            <p className=\"text-[var(--glass-text-tertiary)] mb-6\">{t(\"empty.description\")}</p>\n            <button\n                onClick={onAnalyze}\n                disabled={analyzing}\n                className=\"glass-btn-base glass-btn-primary inline-flex items-center gap-2 px-6 py-3 font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n                {analyzing ? (\n                    <TaskStatusInline state={analyzingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                ) : (\n                    <>\n                        <AppIcon name=\"clipboardCheck\" className=\"w-5 h-5\" />\n                        {t(\"empty.analyzeButton\")}\n                    </>\n                )}\n            </button>\n            <p className=\"text-sm text-[var(--glass-text-tertiary)] mt-6\">\n                {t(\"empty.hint\")}\n            </p>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice/SpeakerVoiceBindingDialog.tsx",
    "content": "'use client'\n\nimport { useState, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport { useTranslations } from 'next-intl'\nimport VoicePickerDialog from '@/app/[locale]/workspace/asset-hub/components/VoicePickerDialog'\nimport VoiceCreationModal from '@/app/[locale]/workspace/asset-hub/components/VoiceCreationModal'\nimport { AppIcon } from '@/components/ui/icons'\nimport { SegmentedControl } from '@/components/ui/SegmentedControl'\nimport type { InlineSpeakerVoiceBinding } from '@/lib/novel-promotion/stages/voice-stage-runtime/types'\n\ntype BindingTab = 'select' | 'upload' | 'design'\n\ninterface SpeakerVoiceBindingDialogProps {\n    isOpen: boolean\n    speaker: string\n    projectId: string\n    episodeId: string\n    onClose: () => void\n    onBound: (speaker: string, binding: InlineSpeakerVoiceBinding) => void\n}\n\n/**\n * 内联音色绑定弹窗\n * 用于不在资产库中的角色/发言人在配音阶段直接绑定音色\n * 提供三种绑定方式：从音色库选择、上传音频、AI设计音色（Tab 切换）\n */\nexport default function SpeakerVoiceBindingDialog({\n    isOpen,\n    speaker,\n    onClose,\n    onBound,\n}: SpeakerVoiceBindingDialogProps) {\n    const t = useTranslations('voice.inlineBinding')\n    const [activeTab, setActiveTab] = useState<BindingTab>('select')\n    // 子弹窗打开标记\n    const [subDialogOpen, setSubDialogOpen] = useState(false)\n\n    const handleClose = useCallback(() => {\n        setActiveTab('select')\n        setSubDialogOpen(false)\n        onClose()\n    }, [onClose])\n\n    const confirmUploadVoice = useCallback(() => {\n        return window.confirm(t('uploadQwenHint'))\n    }, [t])\n\n    // 从音色库选择后的回调\n    const handleVoiceSelected = useCallback((voice: {\n        id: string\n        customVoiceUrl: string | null\n        voiceId: string | null\n        voiceType: string\n    }) => {\n        if (voice.voiceId) {\n            onBound(speaker, {\n                provider: 'bailian',\n                voiceType: voice.voiceType,\n                voiceId: voice.voiceId,\n                ...(voice.customVoiceUrl ? { previewAudioUrl: voice.customVoiceUrl } : {}),\n            })\n        } else if (voice.customVoiceUrl) {\n            onBound(speaker, {\n                provider: 'fal',\n                voiceType: voice.voiceType,\n                audioUrl: voice.customVoiceUrl,\n            })\n            alert(t('uploadQwenHint'))\n        }\n        setSubDialogOpen(false)\n        onClose()\n    }, [speaker, onBound, onClose, t])\n\n    // AI 设计音色或上传音频后的回调\n    const handleCreationSuccess = useCallback(() => {\n        // 创建成功后切换到选择模式，让用户从音色库选取刚创建的音色\n        setActiveTab('select')\n        setSubDialogOpen(true)\n    }, [])\n\n    const handleTabClick = useCallback((tab: BindingTab) => {\n        if (tab === 'upload' && !confirmUploadVoice()) {\n            return\n        }\n        setActiveTab(tab)\n        setSubDialogOpen(true)\n    }, [confirmUploadVoice])\n\n    if (!isOpen) return null\n    if (typeof document === 'undefined') return null\n\n    // 音色库选择 — 直接渲染 VoicePickerDialog\n    if (activeTab === 'select' && subDialogOpen) {\n        return (\n            <VoicePickerDialog\n                isOpen\n                onClose={handleClose}\n                onSelect={handleVoiceSelected}\n            />\n        )\n    }\n\n    // 上传/AI设计 — 渲染 VoiceCreationModal\n    if ((activeTab === 'upload' || activeTab === 'design') && subDialogOpen) {\n        return (\n            <VoiceCreationModal\n                isOpen\n                folderId={null}\n                initialVoiceName={speaker}\n                onClose={handleClose}\n                onSuccess={handleCreationSuccess}\n            />\n        )\n    }\n\n    // 主弹窗：Tab 切换\n    return createPortal(\n        <>\n            <div className=\"fixed inset-0 z-[9999] glass-overlay\" onClick={handleClose} />\n            <div\n                className=\"fixed z-[10000] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 glass-surface-modal w-full max-w-md overflow-hidden\"\n                onClick={(e) => e.stopPropagation()}\n            >\n                {/* 头部 */}\n                <div className=\"flex items-center justify-between px-5 py-4 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]\">\n                    <div className=\"flex items-center gap-2 min-w-0\">\n                        <AppIcon name=\"mic\" className=\"w-5 h-5 text-[var(--glass-tone-info-fg)] shrink-0\" />\n                        <h2 className=\"font-semibold text-[var(--glass-text-primary)] truncate\">\n                            {t('title', { speaker })}\n                        </h2>\n                    </div>\n                    <button onClick={handleClose} className=\"glass-btn-base glass-btn-soft p-1 text-[var(--glass-text-tertiary)] shrink-0\">\n                        <AppIcon name=\"close\" className=\"w-5 h-5\" />\n                    </button>\n                </div>\n\n                {/* 描述 */}\n                <div className=\"px-5 pt-4 pb-2\">\n                    <p className=\"text-sm text-[var(--glass-text-secondary)]\">\n                        {t('description')}\n                    </p>\n                </div>\n\n                <div className=\"px-5 py-3\">\n                    <SegmentedControl\n                        options={[\n                            { value: 'select' as const, label: t('selectFromLibrary') },\n                            { value: 'upload' as const, label: t('uploadAudio') },\n                            { value: 'design' as const, label: t('aiDesign') },\n                        ]}\n                        value={activeTab}\n                        onChange={(val) => handleTabClick(val as BindingTab)}\n                    />\n                </div>\n\n                {/* Tab 内容区 — 显示描述和进入按钮 */}\n                <div className=\"p-5\">\n                    <div className=\"text-center py-6\">\n                        <div className={`w-14 h-14 mx-auto rounded-full flex items-center justify-center mb-3 ${activeTab === 'select' ? 'bg-[var(--glass-tone-info-bg)]'\n                            : activeTab === 'upload' ? 'bg-[var(--glass-tone-success-bg)]'\n                                : 'bg-[var(--glass-accent-bg,var(--glass-tone-info-bg))]'\n                            }`}>\n                            <AppIcon\n                                name={activeTab === 'select' ? 'mic' : activeTab === 'upload' ? 'cloudUpload' : 'idea'}\n                                className={`w-6 h-6 ${activeTab === 'select' ? 'text-[var(--glass-tone-info-fg)]'\n                                    : activeTab === 'upload' ? 'text-[var(--glass-tone-success-fg)]'\n                                        : 'text-[var(--glass-accent-from,var(--glass-tone-info-fg))]'\n                                    }`}\n                            />\n                        </div>\n                        <p className=\"text-sm text-[var(--glass-text-secondary)] mb-4\">\n                            {activeTab === 'select' && t('selectFromLibraryDesc')}\n                            {activeTab === 'upload' && t('uploadAudioDesc')}\n                            {activeTab === 'design' && t('aiDesignDesc')}\n                        </p>\n                        <button\n                            onClick={() => {\n                                if (activeTab === 'upload' && !confirmUploadVoice()) return\n                                setSubDialogOpen(true)\n                            }}\n                            className=\"glass-btn-base glass-btn-primary px-8 py-2.5 rounded-lg text-sm font-medium\"\n                        >\n                            {activeTab === 'select' && t('selectFromLibrary')}\n                            {activeTab === 'upload' && t('uploadAudio')}\n                            {activeTab === 'design' && t('aiDesign')}\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </>,\n        document.body,\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice/SpeakerVoiceStatus.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\n\ninterface SpeakerVoiceStatusProps {\n    speakers: string[]\n    speakerStats: Record<string, number>\n    getSpeakerVoiceUrl: (speaker: string) => string | null\n    onOpenAssetLibrary: (speaker: string) => void\n    /** 内联绑定回调：当发言人不在资产库中时调用 */\n    onOpenInlineBinding?: (speaker: string) => void\n    /** 判断发言人是否有匹配的项目角色 */\n    hasSpeakerCharacter?: (speaker: string) => boolean\n    embedded?: boolean\n}\n\nexport default function SpeakerVoiceStatus({\n    speakers,\n    speakerStats,\n    getSpeakerVoiceUrl,\n    onOpenAssetLibrary,\n    onOpenInlineBinding,\n    hasSpeakerCharacter,\n    embedded = false\n}: SpeakerVoiceStatusProps) {\n    const t = useTranslations('voice')\n\n    if (speakers.length === 0) return null\n\n    /**\n     * 点击\"音色设置\"按钮的处理逻辑：\n     * - 有匹配的项目角色 → 跳转资产中心（现有行为）\n     * - 无匹配的项目角色 → 打开内联绑定弹窗\n     */\n    const handleVoiceSettings = (speaker: string) => {\n        const hasCharacter = hasSpeakerCharacter ? hasSpeakerCharacter(speaker) : true\n        if (hasCharacter || !onOpenInlineBinding) {\n            onOpenAssetLibrary(speaker)\n        } else {\n            onOpenInlineBinding(speaker)\n        }\n    }\n\n    // 嵌入模式：紧凑布局\n    if (embedded) {\n        return (\n            <div className=\"glass-surface px-4 py-3 mb-3 mx-4\">\n                <div className=\"flex items-center justify-between mb-2\">\n                    <h4 className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">{t(\"embedded.speakerVoiceStatus\")}</h4>\n                    <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{t(\"embedded.speakersCount\", { count: speakers.length })}</span>\n                </div>\n                <div className=\"flex flex-wrap gap-2\">\n                    {speakers.map(speaker => {\n                        const hasVoice = !!getSpeakerVoiceUrl(speaker)\n                        const count = speakerStats[speaker]\n                        const hasCharacter = hasSpeakerCharacter ? hasSpeakerCharacter(speaker) : true\n                        return (\n                            <div\n                                key={speaker}\n                                className=\"w-full sm:w-[280px] max-w-full flex items-center gap-1.5 rounded-xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] px-3 py-2\"\n                            >\n                                <div className=\"min-w-0\">\n                                    <div className=\"text-sm font-semibold text-[var(--glass-text-primary)] truncate\">{speaker}</div>\n                                    <div className=\"text-xs text-[var(--glass-text-tertiary)]\">{t(\"speakerVoice.linesCount\", { count })}</div>\n                                </div>\n                                <span className={`text-xs px-2 py-1 rounded-full ${hasVoice\n                                    ? 'bg-[var(--glass-tone-success-bg)] text-[var(--glass-tone-success-fg)]'\n                                    : 'bg-[var(--glass-tone-warning-bg)] text-[var(--glass-tone-warning-fg)]'\n                                    }`}>\n                                    {hasVoice ? t(\"speakerVoice.configuredStatus\") : t(\"speakerVoice.pendingStatus\")}\n                                </span>\n                                {/* 无匹配角色时显示内联标记 */}\n                                {!hasCharacter && !hasVoice && (\n                                    <span className=\"text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]\">\n                                        {t(\"speakerVoice.inlineLabel\")}\n                                    </span>\n                                )}\n                                <button\n                                    onClick={() => handleVoiceSettings(speaker)}\n                                    className=\"glass-btn-base glass-btn-secondary text-xs px-2.5 py-1.5 font-medium whitespace-nowrap shrink-0\"\n                                >\n                                    {t(\"speakerVoice.voiceSettings\")}\n                                </button>\n                            </div>\n                        )\n                    })}\n                </div>\n            </div>\n        )\n    }\n\n    // 标准模式：完整布局\n    return (\n        <div className=\"glass-surface p-5\">\n            <h3 className=\"text-lg font-bold text-[var(--glass-text-primary)] mb-4 flex items-center gap-2\">\n                <span className=\"w-1.5 h-5 bg-[var(--glass-accent-from)] rounded-full\" />\n                {t(\"speakerVoice.title\")}\n                <span className=\"text-sm font-normal text-[var(--glass-text-tertiary)] ml-2\">\n                    （{t(\"speakerVoice.hint\")}）\n                </span>\n            </h3>\n            <div className=\"flex flex-wrap gap-2\">\n                {speakers.map(speaker => {\n                    const voiceUrl = getSpeakerVoiceUrl(speaker)\n                    const hasVoice = !!voiceUrl\n                    const hasCharacter = hasSpeakerCharacter ? hasSpeakerCharacter(speaker) : true\n\n                    return (\n                        <div key={speaker} className=\"w-full sm:w-[280px] max-w-full flex items-center gap-1.5 px-3 py-2 rounded-xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]\">\n                            <div className=\"min-w-0\">\n                                <div className=\"font-semibold text-[var(--glass-text-primary)] truncate\" title={speaker}>{speaker}</div>\n                                <div className=\"text-xs text-[var(--glass-text-tertiary)]\">{t(\"speakerVoice.linesCount\", { count: speakerStats[speaker] })}</div>\n                            </div>\n                            <span className={`text-xs px-2 py-1 rounded-full ${hasVoice ? 'bg-[var(--glass-tone-success-bg)] text-[var(--glass-tone-success-fg)]' : 'bg-[var(--glass-tone-warning-bg)] text-[var(--glass-tone-warning-fg)]'}`}>\n                                {hasVoice ? t(\"speakerVoice.configuredStatus\") : t(\"speakerVoice.pendingStatus\")}\n                            </span>\n                            {/* 无匹配角色时显示内联标记 */}\n                            {!hasCharacter && !hasVoice && (\n                                <span className=\"text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]\">\n                                    {t(\"speakerVoice.inlineLabel\")}\n                                </span>\n                            )}\n                            <button\n                                onClick={() => handleVoiceSettings(speaker)}\n                                className=\"glass-btn-base glass-btn-secondary text-xs px-2.5 py-1.5\"\n                            >\n                                {t(\"speakerVoice.voiceSettings\")}\n                            </button>\n                        </div>\n                    )\n                })}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice/VoiceDesignDialog.tsx",
    "content": "'use client'\n\nimport VoiceDesignDialogBase, {\n  type VoiceDesignMutationPayload,\n  type VoiceDesignMutationResult,\n} from '@/components/voice/VoiceDesignDialogBase'\nimport { useDesignProjectVoice } from '@/lib/query/hooks'\n\ninterface VoiceDesignDialogProps {\n  isOpen: boolean\n  speaker: string\n  hasExistingVoice?: boolean\n  onClose: () => void\n  onSave: (voiceId: string, audioBase64: string) => void\n  projectId: string\n}\n\nexport default function VoiceDesignDialog({\n  isOpen,\n  speaker,\n  hasExistingVoice = false,\n  onClose,\n  onSave,\n  projectId,\n}: VoiceDesignDialogProps) {\n  const designVoiceMutation = useDesignProjectVoice(projectId)\n\n  const handleDesignVoice = async (\n    payload: VoiceDesignMutationPayload,\n  ): Promise<VoiceDesignMutationResult> => {\n    return await designVoiceMutation.mutateAsync(payload)\n  }\n\n  return (\n    <VoiceDesignDialogBase\n      isOpen={isOpen}\n      speaker={speaker}\n      hasExistingVoice={hasExistingVoice}\n      onClose={onClose}\n      onSave={onSave}\n      onDesignVoice={handleDesignVoice}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice/VoiceLineCard.tsx",
    "content": "'use client'\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport EmotionSettingsPanel from './EmotionSettingsPanel'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState, type TaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface VoiceLine {\n    id: string\n    lineIndex: number\n    speaker: string\n    content: string\n    emotionPrompt: string | null\n    emotionStrength: number | null\n    audioUrl: string | null\n    updatedAt: string | null\n    lineTaskRunning: boolean\n    matchedPanelId?: string | null\n    matchedStoryboardId?: string | null\n    matchedPanelIndex?: number | null\n}\n\ninterface VoiceLineCardProps {\n    line: VoiceLine\n    isVoiceTaskRunning: boolean\n    statusState?: TaskPresentationState | null\n    isPlaying: boolean\n    hasVoice: boolean\n    onTogglePlay: (lineId: string, audioUrl: string) => void\n    onDownload: (audioUrl: string) => void\n    onGenerate: (lineId: string) => void\n    onEdit: (line: VoiceLine) => void\n    onLocatePanel?: (line: VoiceLine) => void\n    onDelete: (lineId: string) => void\n    onDeleteAudio: (lineId: string) => void\n    onSaveEmotionSettings: (lineId: string, emotionPrompt: string | null, emotionStrength: number) => void\n}\n\nexport default function VoiceLineCard({\n    line,\n    isVoiceTaskRunning,\n    statusState,\n    isPlaying,\n    hasVoice,\n    onTogglePlay,\n    onDownload,\n    onGenerate,\n    onEdit,\n    onLocatePanel,\n    onDelete,\n    onDeleteAudio,\n    onSaveEmotionSettings\n}: VoiceLineCardProps) {\n    const t = useTranslations('voice')\n    const [isEmotionExpanded, setIsEmotionExpanded] = useState(false)\n    const hasPanelBinding = !!onLocatePanel && !!line.matchedStoryboardId && line.matchedPanelIndex !== null && line.matchedPanelIndex !== undefined\n    const locateTitle = t(\"lineCard.locateVideo\")\n    const inlineStatusState = isVoiceTaskRunning\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'audio',\n            hasOutput: !!line.audioUrl,\n        })\n        : statusState ?? null\n\n    return (\n        <div\n            className={`relative glass-surface-elevated overflow-hidden transition-all hover:shadow-[var(--glass-shadow-md)] hover:-translate-y-0.5 ${line.audioUrl ? 'ring-1 ring-[var(--glass-focus-ring)]/60' : hasVoice ? '' : 'ring-1 ring-[var(--glass-stroke-warning)]/60'\n                }`}\n        >\n            {/* 顶部：播放/生成区域 */}\n            <div className={`h-14 flex items-center justify-center gap-3 ${line.audioUrl\n                ? 'bg-[var(--glass-tone-success-bg)]/50'\n                : 'bg-[var(--glass-bg-muted)]/50'\n                }`}>\n                {line.audioUrl ? (\n                    <div className=\"flex items-center justify-center gap-3\">\n                        {/* 播放按钮 */}\n                        <button\n                            onClick={() => onTogglePlay(line.id, line.audioUrl!)}\n                            className=\"flex items-center justify-center w-9 h-9 bg-[var(--glass-tone-success-fg)] text-white rounded-xl hover:bg-[var(--glass-tone-success-fg)] shadow-[var(--glass-shadow-sm)] transition-all\"\n                            title={isPlaying ? t(\"lineCard.pause\") : t(\"lineCard.play\")}\n                        >\n                            {isPlaying ? (\n                                <AppIcon name=\"pauseSolid\" className=\"w-4 h-4\" />\n                            ) : (\n                                <AppIcon name=\"play\" className=\"w-4 h-4\" />\n                            )}\n                        </button>\n                        {/* 重新生成按钮 */}\n                        <button\n                            onClick={() => onGenerate(line.id)}\n                            disabled={!hasVoice || isVoiceTaskRunning}\n                            className=\"flex items-center justify-center w-8 h-8 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)] rounded-xl transition-all disabled:opacity-50\"\n                            title={t(\"common.regenerate\")}\n                        >\n                            {isVoiceTaskRunning ? (\n                                <TaskStatusInline state={inlineStatusState} className=\"[&_span]:sr-only [&_svg]:text-current\" />\n                            ) : (\n                                <AppIcon name=\"refresh\" className=\"w-3.5 h-3.5\" />\n                            )}\n                        </button>\n                        {/* 下载按钮 */}\n                        <button\n                            onClick={() => onDownload(line.audioUrl!)}\n                            className=\"flex items-center justify-center w-8 h-8 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)] rounded-xl transition-all\"\n                            title={t(\"common.download\")}\n                        >\n                            <AppIcon name=\"download\" className=\"w-4 h-4\" />\n                        </button>\n                    </div>\n                ) : isVoiceTaskRunning ? (\n                    /* 生成中状态：显示状态指示器 */\n                    <div className=\"flex items-center gap-2\">\n                        <div className=\"flex items-center gap-2 px-5 py-2 bg-[var(--glass-accent-from)] text-white rounded-xl text-sm font-medium shadow-[var(--glass-shadow-sm)]\">\n                            <TaskStatusInline state={inlineStatusState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                        </div>\n                    </div>\n                ) : (\n                    /* 生成按钮 */\n                    <button\n                        onClick={() => onGenerate(line.id)}\n                        disabled={!hasVoice}\n                        className=\"flex items-center gap-2 px-5 py-2 bg-[var(--glass-accent-from)] text-white rounded-xl text-sm font-medium hover:bg-[var(--glass-accent-to)] shadow-[var(--glass-shadow-sm)] transition-all disabled:opacity-50 disabled:cursor-not-allowed\"\n                    >\n                        <AppIcon name=\"mic\" className=\"w-4 h-4\" />\n                        {t(\"common.generate\")}\n                    </button>\n                )}\n            </div>\n\n            {/* 序号标签 */}\n            <div className=\"absolute top-2 left-2 bg-[var(--glass-overlay)] backdrop-blur-sm text-white px-2 py-0.5 rounded-lg text-xs font-medium\">\n                #{line.lineIndex}\n            </div>\n\n            {/* 状态标签+删除配音按钮 */}\n            {\n                line.audioUrl && (\n                    <div className=\"absolute top-2 right-2 flex items-center gap-1\">\n                        <div className=\"flex items-center justify-center bg-[var(--glass-tone-success-fg)] text-white px-2 py-0.5 rounded-lg text-xs font-medium shadow-[var(--glass-shadow-sm)]\">\n                            <AppIcon name=\"checkXs\" className=\"h-3 w-3\" />\n                        </div>\n                        <button\n                            onClick={() => onDeleteAudio(line.id)}\n                            className=\"flex items-center justify-center w-5 h-5 bg-[var(--glass-tone-warning-fg)] text-white rounded-md shadow-[var(--glass-shadow-sm)] hover:bg-[var(--glass-tone-warning-fg)] transition-colors\"\n                            title={t(\"lineCard.deleteAudio\")}\n                        >\n                            <AppIcon name=\"close\" className=\"w-3 h-3\" />\n                        </button>\n                    </div>\n                )\n            }\n\n            {/* 中间：台词内容 */}\n            <div className=\"px-4 py-3\">\n                <div className=\"group\">\n                    <p className=\"text-sm text-[var(--glass-text-secondary)] line-clamp-3 leading-relaxed\" title={line.content}>\n                        {line.content}\n                    </p>\n                    {/* 操作按钮组 */}\n                    <div className=\"mt-2 flex justify-end gap-0.5\">\n                        {hasPanelBinding && (\n                            <button\n                                onClick={() => onLocatePanel?.(line)}\n                                className=\"px-2 py-1 text-[11px] leading-none text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)] border border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)] rounded-md transition-colors\"\n                                title={locateTitle}\n                            >\n                                <span>{t(\"lineCard.locateVideo\")}</span>\n                            </button>\n                        )}\n                        <button\n                            onClick={() => onEdit(line)}\n                            className=\"p-1 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)] rounded transition-colors\"\n                            title={t(\"lineCard.editLine\")}\n                        >\n                            <AppIcon name=\"editSquare\" className=\"w-3.5 h-3.5\" />\n                        </button>\n                        <button\n                            onClick={() => onDelete(line.id)}\n                            className=\"p-1 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-bg)] rounded transition-colors\"\n                            title={t(\"lineCard.deleteLine\")}\n                        >\n                            <AppIcon name=\"trash\" className=\"w-3.5 h-3.5\" />\n                        </button>\n                    </div>\n                </div>\n            </div>\n\n            {/* 情绪设置面板 */}\n            {\n                hasVoice && (\n                    <>\n                        <button\n                            onClick={() => setIsEmotionExpanded(!isEmotionExpanded)}\n                            className=\"w-full px-4 py-2 text-xs text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)] flex items-center justify-center gap-1.5 font-medium transition-colors\"\n                        >\n                            <AppIcon name=\"chevronDown\" className={`w-3.5 h-3.5 transition-transform ${isEmotionExpanded ? 'rotate-180' : ''}`} />\n                            {line.emotionPrompt || (line.emotionStrength !== null && line.emotionStrength !== 0.4)\n                                ? t(\"lineCard.emotionConfigured\")\n                                : t(\"lineCard.emotionSettings\")}\n                        </button>\n\n                        {isEmotionExpanded && (\n                            <EmotionSettingsPanel\n                                lineId={line.id}\n                                emotionPrompt={line.emotionPrompt}\n                                emotionStrength={line.emotionStrength ?? 0.4}\n                                onSave={onSaveEmotionSettings}\n                                onGenerate={onGenerate}\n                                isVoiceGenerationRunning={isVoiceTaskRunning}\n                            />\n                        )}\n                    </>\n                )\n            }\n\n            {/* 底部：发言人 */}\n            <div className=\"px-4 py-2.5 bg-[var(--glass-bg-muted)]/50 border-t border-[var(--glass-stroke-base)]/60 flex items-center justify-between gap-2\">\n                <span className=\"inline-flex items-center px-2.5 py-1 bg-[var(--glass-tone-info-bg)]/80 text-[var(--glass-tone-info-fg)] text-xs rounded-lg truncate max-w-[160px] font-medium\" title={line.speaker}>\n                    {line.speaker}\n                </span>\n                {hasVoice ? (\n                    <span className=\"text-xs text-[var(--glass-tone-success-fg)] font-medium\">{t(\"lineCard.voiceConfigured\")}</span>\n                ) : (\n                    <span className=\"text-xs text-[var(--glass-tone-warning-fg)] font-medium\">{t(\"lineCard.needVoice\")}</span>\n                )}\n            </div>\n        </div >\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice/VoiceToolbar.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\n\ninterface VoiceToolbarProps {\n    onBack?: () => void\n    onAddLine: () => void\n    onAnalyze: () => void\n    onGenerateAll: () => void\n    onDownloadAll: () => void\n    analyzing: boolean\n    isBatchSubmitting: boolean\n    runningCount: number\n    isDownloading: boolean\n    allSpeakersHaveVoice: boolean\n    totalLines: number\n    linesWithVoice: number\n    linesWithAudio: number\n}\n\nexport default function VoiceToolbar({\n    onBack,\n    onAddLine,\n    onAnalyze,\n    onGenerateAll,\n    onDownloadAll,\n    analyzing,\n    isBatchSubmitting,\n    runningCount,\n    isDownloading,\n    allSpeakersHaveVoice,\n    totalLines,\n    linesWithVoice,\n    linesWithAudio\n}: VoiceToolbarProps) {\n    const t = useTranslations('voice')\n    const voiceTaskRunningState = isBatchSubmitting\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'audio',\n            hasOutput: linesWithAudio > 0,\n        })\n        : null\n    const voiceDownloadRunningState = isDownloading\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'process',\n            resource: 'audio',\n            hasOutput: linesWithAudio > 0,\n        })\n        : null\n\n    return (\n        <div className=\"glass-surface-elevated p-6\">\n            <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-4\">\n                    <button\n                        onClick={onBack}\n                        className=\"flex items-center gap-2 px-5 py-2.5 bg-[var(--glass-bg-surface)] text-[var(--glass-text-secondary)] font-medium rounded-xl border border-[var(--glass-stroke-base)] hover:bg-[var(--glass-bg-muted)] hover:text-[var(--glass-tone-info-fg)] transition-all\"\n                    >\n                        {t(\"toolbar.back\")}\n                    </button>\n                    <button\n                        onClick={onAnalyze}\n                        disabled={analyzing}\n                        className=\"glass-btn-base glass-btn-primary flex items-center gap-2 px-5 py-2.5 font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n                    >\n                        {analyzing ? t(\"assets.stage.analyzing\") : t(\"toolbar.analyzeLines\")}\n                    </button>\n                    <button\n                        onClick={onAddLine}\n                        className=\"glass-btn-base glass-btn-secondary flex items-center gap-2 px-5 py-2.5 font-medium border border-[var(--glass-stroke-base)]\"\n                    >\n                        {t(\"toolbar.addLine\")}\n                    </button>\n                    <button\n                        onClick={onGenerateAll}\n                        disabled={isBatchSubmitting || !allSpeakersHaveVoice || totalLines === 0}\n                        className=\"glass-btn-base glass-btn-tone-success flex items-center gap-2 px-5 py-2.5 font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n                        title={!allSpeakersHaveVoice ? t(\"toolbar.uploadReferenceHint\") : ''}\n                    >\n                        {isBatchSubmitting ? (\n                            <>\n                                <TaskStatusInline state={voiceTaskRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                                <span className=\"text-xs text-white/90\">({runningCount})</span>\n                            </>\n                        ) : t(\"toolbar.generateAll\")}\n                    </button>\n                    <button\n                        onClick={onDownloadAll}\n                        disabled={linesWithAudio === 0 || isDownloading}\n                        className=\"glass-btn-base glass-btn-tone-info flex items-center gap-2 px-5 py-2.5 font-medium disabled:opacity-50 disabled:cursor-not-allowed\"\n                        title={linesWithAudio === 0 ? t(\"toolbar.noDownload\") : t(\"toolbar.downloadCount\", { count: linesWithAudio })}\n                    >\n                        {isDownloading ? (\n                            <TaskStatusInline state={voiceDownloadRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                        ) : t(\"toolbar.downloadAll\")}\n                    </button>\n                </div>\n                <div className=\"text-sm text-[var(--glass-text-tertiary)]\">\n                    {t(\"toolbar.stats\", { total: totalLines, withVoice: linesWithVoice, withAudio: linesWithAudio })}\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice-stage/VoiceControlPanel.tsx",
    "content": "import type { ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport VoiceToolbar from '../voice/VoiceToolbar'\nimport EmbeddedVoiceToolbar from '../voice/EmbeddedVoiceToolbar'\nimport SpeakerVoiceStatus from '../voice/SpeakerVoiceStatus'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface BindablePanelOption {\n  id: string\n  storyboardId: string\n  panelIndex: number\n  label: string\n}\n\ninterface VoiceControlPanelProps {\n  children: ReactNode\n  embedded: boolean\n  onBack?: () => void\n  analyzing: boolean\n  isBatchSubmittingAll: boolean\n  isDownloading: boolean\n  runningLineCount: number\n  allSpeakersHaveVoice: boolean\n  totalLines: number\n  linesWithVoice: number\n  linesWithAudio: number\n  speakers: string[]\n  speakerStats: Record<string, number>\n  isLineEditorOpen: boolean\n  isSavingLineEditor: boolean\n  editingLineId: string | null\n  editingContent: string\n  editingSpeaker: string\n  editingMatchedPanelId: string\n  speakerOptions: string[]\n  bindablePanelOptions: BindablePanelOption[]\n  savingLineEditorState: TaskPresentationState | null\n  onAnalyze: () => Promise<void>\n  onGenerateAll: () => Promise<void>\n  onDownloadAll: () => Promise<void>\n  onStartAdd: () => void\n  onOpenAssetLibraryForSpeaker: (speaker: string) => void\n  onOpenInlineBinding?: (speaker: string) => void\n  hasSpeakerCharacter?: (speaker: string) => boolean\n  onCancelEdit: () => void\n  onSaveEdit: () => Promise<void>\n  onEditingContentChange: (value: string) => void\n  onEditingSpeakerChange: (value: string) => void\n  onEditingMatchedPanelIdChange: (value: string) => void\n  getSpeakerVoiceUrl: (speaker: string) => string | null\n}\n\nexport default function VoiceControlPanel({\n  children,\n  embedded,\n  onBack,\n  analyzing,\n  isBatchSubmittingAll,\n  isDownloading,\n  runningLineCount,\n  allSpeakersHaveVoice,\n  totalLines,\n  linesWithVoice,\n  linesWithAudio,\n  speakers,\n  speakerStats,\n  isLineEditorOpen,\n  isSavingLineEditor,\n  editingLineId,\n  editingContent,\n  editingSpeaker,\n  editingMatchedPanelId,\n  speakerOptions,\n  bindablePanelOptions,\n  savingLineEditorState,\n  onAnalyze,\n  onGenerateAll,\n  onDownloadAll,\n  onStartAdd,\n  onOpenAssetLibraryForSpeaker,\n  onOpenInlineBinding,\n  hasSpeakerCharacter,\n  onCancelEdit,\n  onSaveEdit,\n  onEditingContentChange,\n  onEditingSpeakerChange,\n  onEditingMatchedPanelIdChange,\n  getSpeakerVoiceUrl,\n}: VoiceControlPanelProps) {\n  const t = useTranslations('voice')\n\n  return (\n    <div className=\"space-y-6 pb-20\">\n      {!embedded ? (\n        <VoiceToolbar\n          onBack={onBack}\n          onAddLine={onStartAdd}\n          onAnalyze={onAnalyze}\n          onGenerateAll={onGenerateAll}\n          onDownloadAll={onDownloadAll}\n          analyzing={analyzing}\n          isBatchSubmitting={isBatchSubmittingAll}\n          runningCount={runningLineCount}\n          isDownloading={isDownloading}\n          allSpeakersHaveVoice={allSpeakersHaveVoice}\n          totalLines={totalLines}\n          linesWithVoice={linesWithVoice}\n          linesWithAudio={linesWithAudio}\n        />\n      ) : (\n        <EmbeddedVoiceToolbar\n          totalLines={totalLines}\n          linesWithAudio={linesWithAudio}\n          analyzing={analyzing}\n          isDownloading={isDownloading}\n          isBatchSubmitting={isBatchSubmittingAll}\n          runningCount={runningLineCount}\n          allSpeakersHaveVoice={allSpeakersHaveVoice}\n          onAddLine={onStartAdd}\n          onAnalyze={onAnalyze}\n          onDownloadAll={onDownloadAll}\n          onGenerateAll={onGenerateAll}\n        />\n      )}\n\n      {speakers.length > 0 && (\n        <SpeakerVoiceStatus\n          speakers={speakers}\n          speakerStats={speakerStats}\n          getSpeakerVoiceUrl={getSpeakerVoiceUrl}\n          onOpenAssetLibrary={onOpenAssetLibraryForSpeaker}\n          onOpenInlineBinding={onOpenInlineBinding}\n          hasSpeakerCharacter={hasSpeakerCharacter}\n          embedded={embedded}\n        />\n      )}\n\n      {children}\n\n      {isLineEditorOpen && (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-[var(--glass-overlay)] p-4\" onClick={onCancelEdit}>\n          <div className=\"w-full max-w-xl bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl border border-[var(--glass-stroke-base)] p-5\" onClick={(event) => event.stopPropagation()}>\n            <div className=\"flex items-center justify-between mb-4\">\n              <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                {editingLineId ? t('lineEditor.editTitle') : t('lineEditor.addTitle')}\n              </h3>\n              <button\n                onClick={onCancelEdit}\n                className=\"p-1 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors\"\n                title={t('common.cancel')}\n              >\n                <AppIcon name=\"close\" className=\"w-5 h-5\" />\n              </button>\n            </div>\n\n            <div className=\"space-y-4\">\n              <div>\n                <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-1.5\">{t('lineEditor.contentLabel')}</label>\n                <textarea\n                  value={editingContent}\n                  onChange={(event) => onEditingContentChange(event.target.value)}\n                  placeholder={t('lineEditor.contentPlaceholder')}\n                  rows={4}\n                  className=\"w-full rounded-xl border border-[var(--glass-stroke-strong)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] resize-y\"\n                />\n              </div>\n\n              <div>\n                <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-1.5\">{t('lineEditor.speakerLabel')}</label>\n                <select\n                  value={editingSpeaker}\n                  onChange={(event) => onEditingSpeakerChange(event.target.value)}\n                  className=\"w-full rounded-xl border border-[var(--glass-stroke-strong)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]\"\n                >\n                  <option value=\"\" disabled>{t('lineEditor.selectSpeaker')}</option>\n                  {speakerOptions.map((speaker) => (\n                    <option key={speaker} value={speaker}>\n                      {speaker}\n                    </option>\n                  ))}\n                </select>\n                {speakerOptions.length === 0 && (\n                  <p className=\"mt-1 text-xs text-[var(--glass-tone-warning-fg)]\">{t('lineEditor.noSpeakerOptions')}</p>\n                )}\n              </div>\n\n              <div>\n                <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-1.5\">{t('lineEditor.bindPanelLabel')}</label>\n                <select\n                  value={editingMatchedPanelId}\n                  onChange={(event) => onEditingMatchedPanelIdChange(event.target.value)}\n                  className=\"w-full rounded-xl border border-[var(--glass-stroke-strong)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]\"\n                >\n                  <option value=\"\">{t('lineEditor.unboundPanel')}</option>\n                  {bindablePanelOptions.map((panel) => (\n                    <option key={panel.id} value={panel.id}>\n                      {panel.label}\n                    </option>\n                  ))}\n                </select>\n              </div>\n            </div>\n\n            <div className=\"flex items-center justify-end gap-2 mt-6\">\n              <button\n                onClick={onCancelEdit}\n                disabled={isSavingLineEditor}\n                className=\"px-4 py-2 text-sm rounded-lg border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)] disabled:opacity-60\"\n              >\n                {t('common.cancel')}\n              </button>\n              <button\n                onClick={onSaveEdit}\n                disabled={isSavingLineEditor}\n                className=\"px-4 py-2 text-sm rounded-lg bg-[var(--glass-accent-from)] text-white hover:bg-[var(--glass-accent-to)] disabled:opacity-60 flex items-center gap-2\"\n              >\n                {isSavingLineEditor && (\n                  <TaskStatusInline state={savingLineEditorState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                )}\n                <span>{editingLineId ? t('lineEditor.saveEdit') : t('lineEditor.saveAdd')}</span>\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice-stage/VoiceLineList.tsx",
    "content": "import type { TaskPresentationState } from '@/lib/task/presentation'\nimport type { VoiceLine } from '@/lib/novel-promotion/stages/voice-stage-runtime/types'\nimport VoiceLineCard from '../voice/VoiceLineCard'\nimport EmptyVoiceState from '../voice/EmptyVoiceState'\n\ninterface VoiceLineListProps {\n  voiceLines: VoiceLine[]\n  runningLineIds: Set<string>\n  voiceStatusStateByLineId: Map<string, TaskPresentationState>\n  playingLineId: string | null\n  analyzing: boolean\n  getSpeakerVoiceUrl: (speaker: string) => string | null\n  onTogglePlayAudio: (lineId: string, audioUrl: string) => void\n  onDownloadSingle: (audioUrl: string) => void\n  onGenerateLine: (lineId: string) => Promise<void>\n  onStartEdit: (line: VoiceLine) => void\n  onLocatePanel: (line: VoiceLine) => void\n  onDeleteLine: (lineId: string) => Promise<void>\n  onDeleteAudio: (lineId: string) => Promise<void>\n  onSaveEmotionSettings: (lineId: string, emotionPrompt: string | null, emotionStrength: number) => Promise<void>\n  onAnalyze: () => Promise<void>\n}\n\nexport default function VoiceLineList({\n  voiceLines,\n  runningLineIds,\n  voiceStatusStateByLineId,\n  playingLineId,\n  analyzing,\n  getSpeakerVoiceUrl,\n  onTogglePlayAudio,\n  onDownloadSingle,\n  onGenerateLine,\n  onStartEdit,\n  onLocatePanel,\n  onDeleteLine,\n  onDeleteAudio,\n  onSaveEmotionSettings,\n  onAnalyze,\n}: VoiceLineListProps) {\n  if (voiceLines.length === 0) {\n    return <EmptyVoiceState onAnalyze={onAnalyze} analyzing={analyzing} />\n  }\n\n  return (\n    <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 px-2 pt-4\">\n      {voiceLines.map((line) => (\n        <VoiceLineCard\n          key={line.id}\n          line={line}\n          isVoiceTaskRunning={runningLineIds.has(line.id)}\n          statusState={voiceStatusStateByLineId.get(line.id) || null}\n          isPlaying={playingLineId === line.id}\n          hasVoice={!!getSpeakerVoiceUrl(line.speaker)}\n          onTogglePlay={onTogglePlayAudio}\n          onDownload={onDownloadSingle}\n          onGenerate={onGenerateLine}\n          onEdit={onStartEdit}\n          onLocatePanel={onLocatePanel}\n          onDelete={onDeleteLine}\n          onDeleteAudio={onDeleteAudio}\n          onSaveEmotionSettings={onSaveEmotionSettings}\n        />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice-stage/VoiceStageLayout.tsx",
    "content": "'use client'\n\nimport { useVoiceStageRuntime, type VoiceStageShellProps } from './hooks/useVoiceStageRuntime'\n\nexport type { VoiceStageShellProps }\n\nexport default function VoiceStageLayout(props: VoiceStageShellProps) {\n  return useVoiceStageRuntime(props)\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice-stage/VoiceStageShell.tsx",
    "content": "'use client'\n\nimport VoiceStageLayout, { type VoiceStageShellProps } from './VoiceStageLayout'\n\nexport type { VoiceStageShellProps }\n\nexport default function VoiceStageShell(props: VoiceStageShellProps) {\n  return <VoiceStageLayout {...props} />\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice-stage/hooks/useVoiceStageRuntime.tsx",
    "content": "'use client'\n\nexport {\n  useVoiceStageRuntime,\n  type VoiceStageShellProps,\n} from '@/lib/novel-promotion/stages/voice-stage-runtime-core'\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useNovelPromotionWorkspaceController.ts",
    "content": "'use client'\n\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { useEffect, useState } from 'react'\nimport { useSearchParams } from 'next/navigation'\nimport { useTranslations } from 'next-intl'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { useWorkspaceProvider } from '../WorkspaceProvider'\nimport { useRebuildConfirm } from './useRebuildConfirm'\nimport { useWorkspaceUserModels } from './useWorkspaceUserModels'\nimport { useWorkspaceExecution } from './useWorkspaceExecution'\nimport { useWorkspaceVideoActions } from './useWorkspaceVideoActions'\nimport { useWorkspaceAssetLibraryShell } from './useWorkspaceAssetLibraryShell'\nimport { useWorkspaceStageNavigation } from './useWorkspaceStageNavigation'\nimport { useWorkspaceProjectSnapshot } from './useWorkspaceProjectSnapshot'\nimport { useWorkspaceModalEscape } from './useWorkspaceModalEscape'\nimport { useWorkspaceStageRuntime } from './useWorkspaceStageRuntime'\nimport { useWorkspaceConfigActions } from './useWorkspaceConfigActions'\nimport { buildWorkspaceControllerViewModel } from './workspace-controller-view-model'\nimport type { NovelPromotionWorkspaceProps } from '../types'\nimport { useRouter } from '@/i18n/navigation'\n\nexport function useNovelPromotionWorkspaceController({\n  project,\n  projectId,\n  episodeId,\n  episode,\n  urlStage,\n  onStageChange,\n}: NovelPromotionWorkspaceProps) {\n  const t = useTranslations('novelPromotion')\n  const te = useTranslations('errors')\n  const tc = useTranslations('common')\n\n  const searchParams = useSearchParams()\n  const router = useRouter()\n  const { onRefresh } = useWorkspaceProvider()\n\n  const projectSnapshot = useWorkspaceProjectSnapshot({ project, episode, urlStage })\n  const { currentStage, episodeStoryboards, ...projectSection } = projectSnapshot\n\n  const assetsLoading = false\n  const assetsLoadingState = assetsLoading\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'process',\n      resource: 'image',\n      hasOutput: false,\n    })\n    : null\n\n  useEffect(() => {\n    _ulogInfo(\n      '[NovelPromotionWorkspace] project prop 更新, characters:',\n      project?.novelPromotionData?.characters?.length,\n    )\n  }, [project])\n\n  const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)\n  const [isWorldContextModalOpen, setIsWorldContextModalOpen] = useState(false)\n\n  const assetLibrary = useWorkspaceAssetLibraryShell({\n    currentStage,\n    searchParams,\n    router,\n    onRefresh,\n  })\n\n  useWorkspaceModalEscape({\n    isAssetLibraryOpen: assetLibrary.isAssetLibraryOpen,\n    closeAssetLibrary: assetLibrary.closeAssetLibrary,\n    isSettingsModalOpen,\n    setIsSettingsModalOpen,\n    isWorldContextModalOpen,\n    setIsWorldContextModalOpen,\n  })\n\n  const configActions = useWorkspaceConfigActions({\n    projectId,\n    episodeId,\n    onStageChange,\n  })\n\n  const rebuildState = useRebuildConfirm({\n    episodeId,\n    episodeStoryboards: episode?.storyboards,\n    getProjectStoryboardStats: configActions.getProjectStoryboardStats,\n    t,\n  })\n\n  const userModels = useWorkspaceUserModels()\n\n  const execution = useWorkspaceExecution({\n    projectId,\n    episodeId,\n    currentStage,\n    analysisModel: projectSnapshot.analysisModel,\n    novelText: projectSnapshot.novelText,\n    t,\n    onRefresh,\n    onUpdateConfig: configActions.handleUpdateConfig,\n    onStageChange: configActions.handleStageChange,\n    onOpenAssetLibrary: assetLibrary.openAssetLibrary,\n  })\n\n  const videoActions = useWorkspaceVideoActions({\n    projectId,\n    episodeId,\n    t,\n  })\n\n  const isStartingStoryToScript = rebuildState.pendingActionType === 'storyToScript'\n  const isStartingScriptToStoryboard = rebuildState.pendingActionType === 'scriptToStoryboard'\n\n  const isAnyOperationRunning =\n    isStartingStoryToScript ||\n    isStartingScriptToStoryboard ||\n    execution.isSubmittingTTS ||\n    execution.isAssetAnalysisRunning ||\n    execution.isConfirmingAssets ||\n    execution.isTransitioning ||\n    execution.storyToScriptStream.isRunning ||\n    execution.scriptToStoryboardStream.isRunning\n\n  const capsuleNavItems = useWorkspaceStageNavigation({\n    isAnyOperationRunning,\n    episode,\n    projectCharacterCount: projectSnapshot.projectCharacters.length,\n    episodeStoryboards,\n    t,\n  })\n\n  const stageRuntime = useWorkspaceStageRuntime({\n    assetsLoading,\n    isSubmittingTTS: execution.isSubmittingTTS,\n    isTransitioning: execution.isTransitioning,\n    isConfirmingAssets: execution.isConfirmingAssets,\n    isStartingStoryToScript,\n    isStartingScriptToStoryboard,\n    videoRatio: projectSnapshot.videoRatio,\n    artStyle: projectSnapshot.artStyle,\n    videoModel: projectSnapshot.videoModel,\n    capabilityOverrides: projectSnapshot.capabilityOverrides,\n    userVideoModels: userModels.userVideoModels || [],\n    handleUpdateEpisode: configActions.handleUpdateEpisode,\n    handleUpdateConfig: configActions.handleUpdateConfig,\n    runWithRebuildConfirm: rebuildState.runWithRebuildConfirm,\n    runStoryToScriptFlow: execution.runStoryToScriptFlow,\n    runScriptToStoryboardFlow: execution.runScriptToStoryboardFlow,\n    handleUpdateClip: videoActions.handleUpdateClip,\n    openAssetLibrary: assetLibrary.openAssetLibrary,\n    handleStageChange: configActions.handleStageChange,\n    handleGenerateVideo: videoActions.handleGenerateVideo,\n    handleGenerateAllVideos: videoActions.handleGenerateAllVideos,\n    handleUpdateVideoPrompt: videoActions.handleUpdateVideoPrompt,\n    handleUpdatePanelVideoModel: videoActions.handleUpdatePanelVideoModel,\n  })\n\n  const uiState = {\n    onRefresh,\n    assetsLoading,\n    assetsLoadingState,\n    isSettingsModalOpen,\n    setIsSettingsModalOpen,\n    isWorldContextModalOpen,\n    setIsWorldContextModalOpen,\n    isAssetLibraryOpen: assetLibrary.isAssetLibraryOpen,\n    assetLibraryFocusCharacterId: assetLibrary.assetLibraryFocusCharacterId,\n    assetLibraryFocusRequestId: assetLibrary.assetLibraryFocusRequestId,\n    triggerGlobalAnalyzeOnOpen: assetLibrary.triggerGlobalAnalyzeOnOpen,\n    setTriggerGlobalAnalyzeOnOpen: assetLibrary.setTriggerGlobalAnalyzeOnOpen,\n    openAssetLibrary: assetLibrary.openAssetLibrary,\n    closeAssetLibrary: assetLibrary.closeAssetLibrary,\n    userModelsForSettings: userModels.userModelsForSettings,\n    userVideoModels: userModels.userVideoModels || [],\n    userModelsLoaded: userModels.userModelsLoaded,\n  }\n\n  const stageNavState = {\n    currentStage,\n    capsuleNavItems,\n    handleStageChange: configActions.handleStageChange,\n  }\n\n  const executionState = {\n    isSubmittingTTS: execution.isSubmittingTTS,\n    isAssetAnalysisRunning: execution.isAssetAnalysisRunning,\n    isConfirmingAssets: execution.isConfirmingAssets,\n    isTransitioning: execution.isTransitioning,\n    isStartingStoryToScript,\n    isStartingScriptToStoryboard,\n    transitionProgress: execution.transitionProgress,\n    storyToScriptConsoleMinimized: execution.storyToScriptConsoleMinimized,\n    setStoryToScriptConsoleMinimized: execution.setStoryToScriptConsoleMinimized,\n    scriptToStoryboardConsoleMinimized: execution.scriptToStoryboardConsoleMinimized,\n    setScriptToStoryboardConsoleMinimized: execution.setScriptToStoryboardConsoleMinimized,\n    storyToScriptStream: execution.storyToScriptStream,\n    scriptToStoryboardStream: execution.scriptToStoryboardStream,\n    handleGenerateTTS: execution.handleGenerateTTS,\n    handleAnalyzeAssets: execution.handleAnalyzeAssets,\n    runStoryToScriptFlow: execution.runStoryToScriptFlow,\n    runScriptToStoryboardFlow: execution.runScriptToStoryboardFlow,\n    showCreatingToast: execution.showCreatingToast,\n  }\n\n  const videoState = {\n    handleGenerateVideo: videoActions.handleGenerateVideo,\n    handleGenerateAllVideos: videoActions.handleGenerateAllVideos,\n    handleUpdateVideoPrompt: videoActions.handleUpdateVideoPrompt,\n    handleUpdatePanelVideoModel: videoActions.handleUpdatePanelVideoModel,\n    handleUpdateClip: videoActions.handleUpdateClip,\n  }\n\n  const actionsState = {\n    handleUpdateConfig: configActions.handleUpdateConfig,\n    handleUpdateEpisode: configActions.handleUpdateEpisode,\n  }\n\n  return buildWorkspaceControllerViewModel({\n    t,\n    tc,\n    te,\n    projectSnapshot: projectSection,\n    uiState,\n    stageNavState,\n    rebuildState,\n    executionState,\n    videoState,\n    stageRuntime,\n    actionsState,\n  })\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useRebuildConfirm.ts",
    "content": "'use client'\n\nimport { useCallback, useMemo, useRef, useState } from 'react'\nimport { logWarn as _ulogWarn } from '@/lib/logging/core'\n\ntype RebuildActionType = 'storyToScript' | 'scriptToStoryboard'\n\ninterface RebuildConfirmContext {\n  actionType: RebuildActionType\n  storyboardCount: number\n  panelCount: number\n}\n\ninterface DownstreamCheckResult {\n  shouldConfirm: boolean\n  storyboardCount: number\n  panelCount: number\n}\n\ntype StoryboardStats = {\n  storyboardCount: number\n  panelCount: number\n}\n\nexport function hasDownstreamStoryboardData(stats: StoryboardStats): boolean {\n  return stats.storyboardCount > 0 || stats.panelCount > 0\n}\n\ninterface StoryboardLike {\n  panels?: unknown[] | null\n}\n\ninterface UseRebuildConfirmParams {\n  episodeId?: string\n  episodeStoryboards?: StoryboardLike[]\n  getProjectStoryboardStats: (episodeId: string) => Promise<StoryboardStats>\n  t: (key: string, values?: Record<string, string | number | Date>) => string\n}\n\nexport function useRebuildConfirm({\n  episodeId,\n  episodeStoryboards,\n  getProjectStoryboardStats,\n  t,\n}: UseRebuildConfirmParams) {\n  const [showRebuildConfirm, setShowRebuildConfirm] = useState(false)\n  const [rebuildConfirmContext, setRebuildConfirmContext] = useState<RebuildConfirmContext | null>(null)\n  const [pendingActionType, setPendingActionType] = useState<RebuildActionType | null>(null)\n  const pendingRebuildActionRef = useRef<(() => Promise<void>) | null>(null)\n\n  const getFallbackStoryboardStats = useCallback(() => {\n    const storyboards = Array.isArray(episodeStoryboards) ? episodeStoryboards : []\n    const storyboardCount = storyboards.length\n    const panelCount = storyboards.reduce((sum: number, storyboard) => {\n      const panels = Array.isArray(storyboard?.panels) ? storyboard.panels.length : 0\n      return sum + panels\n    }, 0)\n    return { storyboardCount, panelCount }\n  }, [episodeStoryboards])\n\n  const checkStoryboardDownstreamData = useCallback(async (): Promise<DownstreamCheckResult> => {\n    if (!episodeId) {\n      return { shouldConfirm: false, storyboardCount: 0, panelCount: 0 }\n    }\n\n    try {\n      const { storyboardCount, panelCount } = await getProjectStoryboardStats(episodeId)\n      return {\n        shouldConfirm: hasDownstreamStoryboardData({ storyboardCount, panelCount }),\n        storyboardCount,\n        panelCount,\n      }\n    } catch (error) {\n      _ulogWarn('[RebuildConfirm] Failed to check downstream storyboards, fallback to local cache', error)\n      const fallbackStats = getFallbackStoryboardStats()\n      return {\n        shouldConfirm: hasDownstreamStoryboardData(fallbackStats),\n        storyboardCount: fallbackStats.storyboardCount,\n        panelCount: fallbackStats.panelCount,\n      }\n    }\n  }, [episodeId, getFallbackStoryboardStats, getProjectStoryboardStats])\n\n  const runWithRebuildConfirm = useCallback(async (\n    actionType: RebuildActionType,\n    action: () => Promise<void>\n  ) => {\n    if (pendingActionType === actionType) return\n\n    setPendingActionType(actionType)\n    try {\n      const downstream = await checkStoryboardDownstreamData()\n      if (!downstream.shouldConfirm) {\n        try {\n          await action()\n        } finally {\n          setPendingActionType((current) => (current === actionType ? null : current))\n        }\n        return\n      }\n\n      pendingRebuildActionRef.current = async () => {\n        try {\n          await action()\n        } finally {\n          setPendingActionType((current) => (current === actionType ? null : current))\n        }\n      }\n      setRebuildConfirmContext({\n        actionType,\n        storyboardCount: downstream.storyboardCount,\n        panelCount: downstream.panelCount,\n      })\n      setShowRebuildConfirm(true)\n    } catch (error) {\n      setPendingActionType((current) => (current === actionType ? null : current))\n      throw error\n    }\n  }, [checkStoryboardDownstreamData, pendingActionType])\n\n  const handleCancelRebuildConfirm = useCallback(() => {\n    const currentActionType = rebuildConfirmContext?.actionType ?? pendingActionType\n    pendingRebuildActionRef.current = null\n    setShowRebuildConfirm(false)\n    setRebuildConfirmContext(null)\n    if (currentActionType) {\n      setPendingActionType((current) => (current === currentActionType ? null : current))\n    }\n  }, [pendingActionType, rebuildConfirmContext])\n\n  const handleAcceptRebuildConfirm = useCallback(() => {\n    const pendingAction = pendingRebuildActionRef.current\n    pendingRebuildActionRef.current = null\n    setShowRebuildConfirm(false)\n    setRebuildConfirmContext(null)\n    if (pendingAction) {\n      void pendingAction()\n      return\n    }\n    setPendingActionType(null)\n  }, [])\n\n  const rebuildConfirmTitle = useMemo(() => {\n    if (!rebuildConfirmContext) return ''\n    if (rebuildConfirmContext.actionType === 'storyToScript') {\n      return t('rebuildConfirm.storyToScript.title')\n    }\n    return t('rebuildConfirm.scriptToStoryboard.title')\n  }, [rebuildConfirmContext, t])\n\n  const rebuildConfirmMessage = useMemo(() => {\n    if (!rebuildConfirmContext) return ''\n    const values = {\n      storyboardCount: rebuildConfirmContext.storyboardCount,\n      panelCount: rebuildConfirmContext.panelCount,\n    }\n    if (rebuildConfirmContext.actionType === 'storyToScript') {\n      return t('rebuildConfirm.storyToScript.message', values)\n    }\n    return t('rebuildConfirm.scriptToStoryboard.message', values)\n  }, [rebuildConfirmContext, t])\n\n  return {\n    showRebuildConfirm,\n    rebuildConfirmTitle,\n    rebuildConfirmMessage,\n    pendingActionType,\n    runWithRebuildConfirm,\n    handleCancelRebuildConfirm,\n    handleAcceptRebuildConfirm,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceAssetLibraryShell.ts",
    "content": "'use client'\n\nimport { useState, useRef, useEffect, useCallback } from 'react'\n\ntype RefreshOptions = { scope?: string; mode?: string }\n\ninterface RouterLike {\n  replace: (href: string, options?: { scroll?: boolean }) => void\n}\n\ninterface SearchParamsLike {\n  get: (name: string) => string | null\n  toString: () => string\n}\n\ninterface UseWorkspaceAssetLibraryShellParams {\n  currentStage: string\n  searchParams: SearchParamsLike | null\n  router: RouterLike\n  onRefresh: (options?: RefreshOptions) => Promise<void>\n}\n\nexport function useWorkspaceAssetLibraryShell({\n  currentStage,\n  searchParams,\n  router,\n  onRefresh,\n}: UseWorkspaceAssetLibraryShellParams) {\n  const [isAssetLibraryOpen, setIsAssetLibraryOpen] = useState(false)\n  const [assetLibraryFocusCharacterId, setAssetLibraryFocusCharacterId] = useState<string | null>(null)\n  const [assetLibraryFocusRequestId, setAssetLibraryFocusRequestId] = useState(0)\n  const [triggerGlobalAnalyzeOnOpen, setTriggerGlobalAnalyzeOnOpen] = useState(false)\n  const hasTriggeredGlobalAnalyze = useRef(false)\n\n  const openAssetLibrary = useCallback((focusCharacterId?: string | null, refreshAssets = true) => {\n    setAssetLibraryFocusCharacterId(focusCharacterId || null)\n    setAssetLibraryFocusRequestId(prev => prev + 1)\n    setIsAssetLibraryOpen(true)\n\n    if (refreshAssets) {\n      window.setTimeout(() => {\n        onRefresh({ scope: 'assets' })\n      }, 0)\n    }\n  }, [onRefresh])\n\n  const closeAssetLibrary = useCallback(() => {\n    setIsAssetLibraryOpen(false)\n    setAssetLibraryFocusCharacterId(null)\n  }, [])\n\n  useEffect(() => {\n    if (!searchParams) return\n\n    const shouldTriggerGlobalAnalyze = searchParams.get('globalAnalyze') === '1'\n    const shouldOpenAssetLibrary = searchParams.get('assetLibrary') === '1'\n    const focusCharacterId = searchParams.get('focusCharacter')\n\n    if (!shouldTriggerGlobalAnalyze && !shouldOpenAssetLibrary) {\n      return\n    }\n\n    const newParams = new URLSearchParams(searchParams.toString())\n    if (shouldTriggerGlobalAnalyze) newParams.delete('globalAnalyze')\n    if (shouldOpenAssetLibrary) newParams.delete('assetLibrary')\n    router.replace(`?${newParams.toString()}`, { scroll: false })\n\n    openAssetLibrary(focusCharacterId)\n\n    if (shouldTriggerGlobalAnalyze && !hasTriggeredGlobalAnalyze.current) {\n      hasTriggeredGlobalAnalyze.current = true\n      setTriggerGlobalAnalyzeOnOpen(true)\n    }\n  }, [openAssetLibrary, router, searchParams])\n\n  useEffect(() => {\n    const needsAssets =\n      currentStage === 'script' ||\n      currentStage === 'assets' ||\n      currentStage === 'storyboard' ||\n      currentStage === 'videos'\n\n    if (needsAssets) {\n      onRefresh({ scope: 'assets' })\n    }\n  }, [currentStage, onRefresh])\n\n  return {\n    isAssetLibraryOpen,\n    assetLibraryFocusCharacterId,\n    assetLibraryFocusRequestId,\n    triggerGlobalAnalyzeOnOpen,\n    setTriggerGlobalAnalyzeOnOpen,\n    openAssetLibrary,\n    closeAssetLibrary,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceConfigActions.ts",
    "content": "'use client'\n\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useCallback } from 'react'\nimport {\n  useGetProjectStoryboardStats,\n  useUpdateProjectConfig,\n  useUpdateProjectEpisodeField,\n} from '@/lib/query/hooks'\n\ninterface UseWorkspaceConfigActionsParams {\n  projectId: string\n  episodeId?: string\n  onStageChange?: (stage: string) => void\n}\n\nexport function useWorkspaceConfigActions({\n  projectId,\n  episodeId,\n  onStageChange,\n}: UseWorkspaceConfigActionsParams) {\n  const updateProjectConfigMutation = useUpdateProjectConfig(projectId)\n  const updateProjectEpisodeMutation = useUpdateProjectEpisodeField(projectId)\n  const getProjectStoryboardStatsMutation = useGetProjectStoryboardStats(projectId)\n\n  const handleStageChange = useCallback((stage: string) => {\n    onStageChange?.(stage)\n  }, [onStageChange])\n\n  const handleUpdateConfig = useCallback(async (key: string, value: unknown) => {\n    try {\n      await updateProjectConfigMutation.mutateAsync({ key, value })\n    } catch (error: unknown) {\n      _ulogError('Update config error:', error)\n    }\n  }, [updateProjectConfigMutation])\n\n  const handleUpdateEpisode = useCallback(async (key: string, value: unknown) => {\n    if (!episodeId) {\n      _ulogError('No episode selected')\n      return\n    }\n\n    try {\n      await updateProjectEpisodeMutation.mutateAsync({ episodeId, key, value })\n    } catch (error: unknown) {\n      _ulogError('Update episode error:', error)\n    }\n  }, [episodeId, updateProjectEpisodeMutation])\n\n  const getProjectStoryboardStats = useCallback(async (targetEpisodeId: string) => {\n    return getProjectStoryboardStatsMutation.mutateAsync({ episodeId: targetEpisodeId })\n  }, [getProjectStoryboardStatsMutation])\n\n  return {\n    handleStageChange,\n    handleUpdateConfig,\n    handleUpdateEpisode,\n    getProjectStoryboardStats,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceEpisodeStageData.ts",
    "content": "'use client'\n\nimport { useEpisodeData } from '@/lib/query/hooks'\nimport type { NovelPromotionClip, NovelPromotionStoryboard } from '@/types/project'\nimport { useWorkspaceProvider } from '../WorkspaceProvider'\n\ninterface EpisodeStagePayload {\n  name?: string\n  novelText?: string | null\n  clips?: NovelPromotionClip[]\n  storyboards?: NovelPromotionStoryboard[]\n}\n\nexport function useWorkspaceEpisodeStageData() {\n  const { projectId, episodeId } = useWorkspaceProvider()\n  const { data: episodeData } = useEpisodeData(projectId, episodeId || null)\n  const payload = episodeData as EpisodeStagePayload | null\n\n  return {\n    episodeName: payload?.name,\n    novelText: payload?.novelText || '',\n    clips: payload?.clips || [],\n    storyboards: payload?.storyboards || [],\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceExecution.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport {\n  useAnalyzeProjectAssets,\n  useScriptToStoryboardRunStream,\n  useStoryToScriptRunStream,\n} from '@/lib/query/hooks'\n\ninterface UseWorkspaceExecutionParams {\n  projectId: string\n  episodeId?: string\n  currentStage: string\n  analysisModel?: string | null\n  novelText: string\n  t: (key: string) => string\n  onRefresh: (options?: { scope?: string; mode?: string }) => Promise<void>\n  onUpdateConfig: (key: string, value: unknown) => Promise<void>\n  onStageChange: (stage: string) => void\n  onOpenAssetLibrary: (focusCharacterId?: string | null, refreshAssets?: boolean) => void\n}\n\nfunction isAbortError(err: unknown): boolean {\n  if (!(err instanceof Error)) return false\n  return err.name === 'AbortError' || err.message === 'Failed to fetch'\n}\n\nfunction getErrorMessage(err: unknown): string {\n  if (err instanceof Error) return err.message\n  return String(err)\n}\n\nfunction isRunStreamTimeoutMessage(message: string): boolean {\n  return /(?:run|task)\\s+stream\\s+timeout/i.test(message.trim())\n}\n\nfunction readSessionBoolean(key: string): boolean {\n  if (typeof window === 'undefined') return false\n  try {\n    return window.sessionStorage.getItem(key) === '1'\n  } catch {\n    return false\n  }\n}\n\nfunction writeSessionBoolean(key: string, value: boolean) {\n  if (typeof window === 'undefined') return\n  try {\n    if (value) {\n      window.sessionStorage.setItem(key, '1')\n      return\n    }\n    window.sessionStorage.removeItem(key)\n  } catch {\n    // ignore session storage failures\n  }\n}\n\nexport function useWorkspaceExecution({\n  projectId,\n  episodeId,\n  currentStage,\n  analysisModel,\n  novelText,\n  t,\n  onRefresh,\n  onUpdateConfig,\n  onStageChange,\n  onOpenAssetLibrary,\n}: UseWorkspaceExecutionParams) {\n  const analyzeProjectAssetsMutation = useAnalyzeProjectAssets(projectId)\n  const storageScope = `${projectId}:${episodeId || 'global'}`\n  const storyToScriptMinimizedStorageKey = `novel-promotion:story-to-script:minimized:${storageScope}`\n  const scriptToStoryboardMinimizedStorageKey = `novel-promotion:script-to-storyboard:minimized:${storageScope}`\n\n  const [isSubmittingTTS] = useState(false)\n  const [isAssetAnalysisRunning, setIsAssetAnalysisRunning] = useState(false)\n  const [isConfirmingAssets, setIsConfirmingAssets] = useState(false)\n  const [isTransitioning, setIsTransitioning] = useState(false)\n  const [transitionProgress, setTransitionProgress] = useState({ message: '', step: '' })\n  const [storyToScriptConsoleMinimized, setStoryToScriptConsoleMinimized] = useState(\n    () => readSessionBoolean(storyToScriptMinimizedStorageKey),\n  )\n  const [scriptToStoryboardConsoleMinimized, setScriptToStoryboardConsoleMinimized] = useState(\n    () => readSessionBoolean(scriptToStoryboardMinimizedStorageKey),\n  )\n\n  const storyToScriptStream = useStoryToScriptRunStream({ projectId, episodeId })\n  const scriptToStoryboardStream = useScriptToStoryboardRunStream({ projectId, episodeId })\n  const handledStoryToScriptRunIdsRef = useRef<Set<string>>(new Set())\n  const handledScriptToStoryboardRunIdsRef = useRef<Set<string>>(new Set())\n  const storyToScriptWasActiveRef = useRef(false)\n  const scriptToStoryboardWasActiveRef = useRef(false)\n\n  const finalizeStoryToScriptSuccess = useCallback(async (runId: string) => {\n    const normalizedRunId = runId.trim()\n    if (!normalizedRunId) return\n    if (handledStoryToScriptRunIdsRef.current.has(normalizedRunId)) return\n    handledStoryToScriptRunIdsRef.current.add(normalizedRunId)\n\n    try {\n      await onRefresh()\n    } catch (refreshError) {\n      _ulogInfo('[WorkspaceExecution] refresh after story-to-script completed failed', {\n        runId: normalizedRunId,\n        message: getErrorMessage(refreshError),\n      })\n    }\n\n    setStoryToScriptConsoleMinimized(true)\n    onStageChange('script')\n    onOpenAssetLibrary()\n    storyToScriptStream.reset()\n  }, [onOpenAssetLibrary, onRefresh, onStageChange, storyToScriptStream])\n\n  const finalizeScriptToStoryboardSuccess = useCallback(async (runId: string) => {\n    const normalizedRunId = runId.trim()\n    if (!normalizedRunId) return\n    if (handledScriptToStoryboardRunIdsRef.current.has(normalizedRunId)) return\n    handledScriptToStoryboardRunIdsRef.current.add(normalizedRunId)\n\n    try {\n      await onRefresh()\n    } catch (refreshError) {\n      _ulogInfo('[WorkspaceExecution] refresh after script-to-storyboard completed failed', {\n        runId: normalizedRunId,\n        message: getErrorMessage(refreshError),\n      })\n    }\n\n    setScriptToStoryboardConsoleMinimized(true)\n    onStageChange('storyboard')\n    scriptToStoryboardStream.reset()\n  }, [onRefresh, onStageChange, scriptToStoryboardStream])\n\n  useEffect(() => {\n    setStoryToScriptConsoleMinimized(readSessionBoolean(storyToScriptMinimizedStorageKey))\n  }, [storyToScriptMinimizedStorageKey])\n\n  useEffect(() => {\n    setScriptToStoryboardConsoleMinimized(readSessionBoolean(scriptToStoryboardMinimizedStorageKey))\n  }, [scriptToStoryboardMinimizedStorageKey])\n\n  useEffect(() => {\n    writeSessionBoolean(storyToScriptMinimizedStorageKey, storyToScriptConsoleMinimized)\n  }, [storyToScriptConsoleMinimized, storyToScriptMinimizedStorageKey])\n\n  useEffect(() => {\n    writeSessionBoolean(scriptToStoryboardMinimizedStorageKey, scriptToStoryboardConsoleMinimized)\n  }, [scriptToStoryboardConsoleMinimized, scriptToStoryboardMinimizedStorageKey])\n\n  const handleGenerateTTS = useCallback(async () => {\n    _ulogInfo('[NovelPromotionWorkspace] TTS is disabled, skip generate request')\n  }, [])\n\n  const handleAnalyzeAssets = useCallback(async () => {\n    if (!episodeId) return\n    if (isAssetAnalysisRunning) {\n      _ulogInfo('[WorkspaceExecution] asset analysis already running, skip duplicate trigger')\n      return\n    }\n\n    try {\n      setIsAssetAnalysisRunning(true)\n      await analyzeProjectAssetsMutation.mutateAsync({ episodeId })\n      await onRefresh({ scope: 'assets' })\n    } catch (err: unknown) {\n      if (isAbortError(err)) {\n        _ulogInfo(t('execution.requestAborted'))\n        return\n      }\n      alert(`${t('execution.analysisFailed')}: ${getErrorMessage(err)}`)\n    } finally {\n      setIsAssetAnalysisRunning(false)\n    }\n  }, [analyzeProjectAssetsMutation, episodeId, isAssetAnalysisRunning, onRefresh, t])\n\n  const runStoryToScriptFlow = useCallback(async () => {\n    if (!episodeId) {\n      alert(t('execution.selectEpisode'))\n      return\n    }\n\n    const storyContent = (novelText || '').trim()\n    if (!storyContent) {\n      alert(`${t('execution.prepareFailed')}: ${t('execution.fillContentFirst')}`)\n      return\n    }\n\n    try {\n      setIsTransitioning(true)\n      setStoryToScriptConsoleMinimized(false)\n\n      await onUpdateConfig('workflowMode', 'agent')\n      setTransitionProgress({ message: t('execution.storyToScriptRunning'), step: 'streaming' })\n      const runResult = await storyToScriptStream.run({\n        episodeId,\n        content: storyContent,\n        model: analysisModel || undefined,\n        temperature: 0.7,\n        reasoning: true,\n      })\n      if (runResult.status !== 'completed') {\n        throw new Error(runResult.errorMessage || t('execution.storyToScriptFailed'))\n      }\n      await finalizeStoryToScriptSuccess(runResult.runId || '')\n    } catch (err: unknown) {\n      if (isAbortError(err) || (err instanceof Error && err.message === 'aborted')) {\n        _ulogInfo(t('execution.requestAborted'))\n        return\n      }\n      const rawMessage = getErrorMessage(err)\n      const friendlyMessage = isRunStreamTimeoutMessage(rawMessage)\n        ? t('execution.taskStreamTimeout')\n        : rawMessage\n      alert(`${t('execution.prepareFailed')}: ${friendlyMessage}`)\n    } finally {\n      setIsTransitioning(false)\n      setTransitionProgress({ message: '', step: '' })\n    }\n  }, [analysisModel, episodeId, finalizeStoryToScriptSuccess, novelText, onUpdateConfig, storyToScriptStream, t])\n\n  const runScriptToStoryboardFlow = useCallback(async () => {\n    if (!episodeId) {\n      alert(t('execution.selectEpisode'))\n      return\n    }\n\n    try {\n      setScriptToStoryboardConsoleMinimized(false)\n      setIsConfirmingAssets(true)\n      setTransitionProgress({ message: t('execution.scriptToStoryboardRunning'), step: 'streaming' })\n      const runResult = await scriptToStoryboardStream.run({\n        episodeId,\n        model: analysisModel || undefined,\n        temperature: 0.7,\n        reasoning: true,\n      })\n      if (runResult.status !== 'completed') {\n        throw new Error(runResult.errorMessage || t('execution.scriptToStoryboardFailed'))\n      }\n      await finalizeScriptToStoryboardSuccess(runResult.runId || '')\n    } catch (err: unknown) {\n      if (isAbortError(err)) {\n        _ulogInfo(t('execution.requestAborted'))\n        return\n      }\n      const rawMessage = getErrorMessage(err)\n      alert(`${t('execution.generationFailed')}: ${isRunStreamTimeoutMessage(rawMessage) ? t('execution.taskStreamTimeout') : rawMessage}`)\n    } finally {\n      setIsConfirmingAssets(false)\n      setTransitionProgress({ message: '', step: '' })\n    }\n  }, [analysisModel, episodeId, finalizeScriptToStoryboardSuccess, scriptToStoryboardStream, t])\n\n  useEffect(() => {\n    const active = (\n      storyToScriptStream.isRunning ||\n      storyToScriptStream.isRecoveredRunning ||\n      storyToScriptStream.status === 'running'\n    )\n    if (active) {\n      storyToScriptWasActiveRef.current = true\n      return\n    }\n    if (storyToScriptStream.status === 'completed' && storyToScriptWasActiveRef.current) {\n      storyToScriptWasActiveRef.current = false\n      if (storyToScriptStream.runId) {\n        void finalizeStoryToScriptSuccess(storyToScriptStream.runId)\n      }\n      return\n    }\n    if (storyToScriptStream.status === 'completed' && currentStage === 'config' && storyToScriptStream.runId) {\n      void finalizeStoryToScriptSuccess(storyToScriptStream.runId)\n      return\n    }\n    if (storyToScriptStream.status === 'failed' || storyToScriptStream.status === 'idle') {\n      storyToScriptWasActiveRef.current = false\n    }\n  }, [\n    currentStage,\n    finalizeStoryToScriptSuccess,\n    storyToScriptStream.isRecoveredRunning,\n    storyToScriptStream.isRunning,\n    storyToScriptStream.runId,\n    storyToScriptStream.status,\n  ])\n\n  useEffect(() => {\n    const active = (\n      scriptToStoryboardStream.isRunning ||\n      scriptToStoryboardStream.isRecoveredRunning ||\n      scriptToStoryboardStream.status === 'running'\n    )\n    if (active) {\n      scriptToStoryboardWasActiveRef.current = true\n      return\n    }\n    if (scriptToStoryboardStream.status === 'completed' && scriptToStoryboardWasActiveRef.current) {\n      scriptToStoryboardWasActiveRef.current = false\n      if (scriptToStoryboardStream.runId) {\n        void finalizeScriptToStoryboardSuccess(scriptToStoryboardStream.runId)\n      }\n      return\n    }\n    if (scriptToStoryboardStream.status === 'completed' && currentStage === 'script' && scriptToStoryboardStream.runId) {\n      void finalizeScriptToStoryboardSuccess(scriptToStoryboardStream.runId)\n      return\n    }\n    if (scriptToStoryboardStream.status === 'failed' || scriptToStoryboardStream.status === 'idle') {\n      scriptToStoryboardWasActiveRef.current = false\n    }\n  }, [\n    currentStage,\n    finalizeScriptToStoryboardSuccess,\n    scriptToStoryboardStream.isRecoveredRunning,\n    scriptToStoryboardStream.isRunning,\n    scriptToStoryboardStream.runId,\n    scriptToStoryboardStream.status,\n  ])\n\n  const showCreatingToast = useMemo(() => (\n    storyToScriptStream.isRunning ||\n    storyToScriptStream.isRecoveredRunning ||\n    scriptToStoryboardStream.isRunning ||\n    scriptToStoryboardStream.isRecoveredRunning ||\n    isTransitioning ||\n    isConfirmingAssets\n  ), [\n    isConfirmingAssets,\n    isTransitioning,\n    scriptToStoryboardStream.isRecoveredRunning,\n    scriptToStoryboardStream.isRunning,\n    storyToScriptStream.isRecoveredRunning,\n    storyToScriptStream.isRunning,\n  ])\n\n  return {\n    isSubmittingTTS,\n    isAssetAnalysisRunning,\n    isConfirmingAssets,\n    isTransitioning,\n    transitionProgress,\n    storyToScriptConsoleMinimized,\n    setStoryToScriptConsoleMinimized,\n    scriptToStoryboardConsoleMinimized,\n    setScriptToStoryboardConsoleMinimized,\n    storyToScriptStream,\n    scriptToStoryboardStream,\n    handleGenerateTTS,\n    handleAnalyzeAssets,\n    runStoryToScriptFlow,\n    runScriptToStoryboardFlow,\n    showCreatingToast,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceModalEscape.ts",
    "content": "'use client'\n\nimport { useEffect } from 'react'\n\ninterface UseWorkspaceModalEscapeParams {\n  isAssetLibraryOpen: boolean\n  closeAssetLibrary: () => void\n  isSettingsModalOpen: boolean\n  setIsSettingsModalOpen: (open: boolean) => void\n  isWorldContextModalOpen: boolean\n  setIsWorldContextModalOpen: (open: boolean) => void\n}\n\nexport function useWorkspaceModalEscape({\n  isAssetLibraryOpen,\n  closeAssetLibrary,\n  isSettingsModalOpen,\n  setIsSettingsModalOpen,\n  isWorldContextModalOpen,\n  setIsWorldContextModalOpen,\n}: UseWorkspaceModalEscapeParams) {\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== 'Escape') return\n      if (isAssetLibraryOpen) closeAssetLibrary()\n      if (isSettingsModalOpen) setIsSettingsModalOpen(false)\n      if (isWorldContextModalOpen) setIsWorldContextModalOpen(false)\n    }\n\n    document.addEventListener('keydown', handleKeyDown)\n    return () => document.removeEventListener('keydown', handleKeyDown)\n  }, [\n    closeAssetLibrary,\n    isAssetLibraryOpen,\n    isSettingsModalOpen,\n    isWorldContextModalOpen,\n    setIsSettingsModalOpen,\n    setIsWorldContextModalOpen,\n  ])\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceProjectSnapshot.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport type { NovelPromotionWorkspaceProps } from '../types'\nimport type { CapabilitySelections } from '@/lib/model-config-contract'\n\nfunction parseCapabilitySelections(raw: unknown): CapabilitySelections {\n  if (!raw) return {}\n  if (typeof raw === 'object' && !Array.isArray(raw)) {\n    return raw as CapabilitySelections\n  }\n  if (typeof raw !== 'string') return {}\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}\n    return parsed as CapabilitySelections\n  } catch {\n    return {}\n  }\n}\n\nexport function useWorkspaceProjectSnapshot({\n  project,\n  episode,\n  urlStage,\n}: Pick<NovelPromotionWorkspaceProps, 'project' | 'episode' | 'urlStage'>) {\n  return useMemo(() => {\n    const projectData = project.novelPromotionData\n    const capabilityOverrides = parseCapabilitySelections(projectData?.capabilityOverrides)\n    return {\n      projectData,\n      projectCharacters: projectData?.characters || [],\n      projectLocations: projectData?.locations || [],\n      episodeStoryboards: episode?.storyboards || [],\n      currentStage: urlStage === 'editor' ? 'videos' : (urlStage || 'config'),\n      globalAssetText: projectData?.globalAssetText || '',\n      novelText: episode?.novelText || '',\n      analysisModel: projectData?.analysisModel,\n      characterModel: projectData?.characterModel,\n      locationModel: projectData?.locationModel,\n      storyboardModel: projectData?.storyboardModel,\n      editModel: projectData?.editModel,\n      videoModel: projectData?.videoModel,\n      audioModel: projectData?.audioModel,\n      videoRatio: projectData?.videoRatio,\n      capabilityOverrides,\n      ttsRate: projectData?.ttsRate,\n      artStyle: projectData?.artStyle,\n    }\n  }, [episode?.novelText, episode?.storyboards, project.novelPromotionData, urlStage])\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceStageNavigation.ts",
    "content": "'use client'\n\nimport type { NovelPromotionPanel } from '@/types/project'\n\ninterface EpisodeLike {\n  novelText?: string | null\n  voiceLines?: unknown[] | null\n}\n\ninterface StoryboardLike {\n  panels?: NovelPromotionPanel[] | null\n}\n\ninterface CapsuleNavItem {\n  id: string\n  icon: string\n  label: string\n  status: 'empty' | 'active' | 'processing' | 'ready'\n  disabled?: boolean\n  disabledLabel?: string\n}\n\ninterface UseWorkspaceStageNavigationParams {\n  isAnyOperationRunning: boolean\n  episode?: EpisodeLike | null\n  projectCharacterCount: number\n  episodeStoryboards: StoryboardLike[]\n  t: (key: string) => string\n}\n\nexport function useWorkspaceStageNavigation({\n  isAnyOperationRunning,\n  episode,\n  projectCharacterCount,\n  episodeStoryboards,\n  t,\n}: UseWorkspaceStageNavigationParams): CapsuleNavItem[] {\n  const getStageStatus = (stageId: string): 'empty' | 'active' | 'processing' | 'ready' => {\n    if (isAnyOperationRunning) return 'processing'\n\n    switch (stageId) {\n      case 'config':\n        return episode?.novelText ? 'ready' : 'active'\n      case 'assets':\n        return projectCharacterCount > 0 ? 'ready' : 'empty'\n      case 'storyboard':\n        return episodeStoryboards.some((sb) => sb.panels?.length) ? 'ready' : 'empty'\n      case 'videos':\n      case 'editor':\n        return episodeStoryboards.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' : 'empty'\n      case 'voice':\n        return (episode?.voiceLines?.length || 0) > 0 ? 'ready' : 'empty'\n      default:\n        return 'empty'\n    }\n  }\n\n  return [\n    { id: 'config', icon: 'S', label: t('stages.story'), status: getStageStatus('config') },\n    { id: 'script', icon: 'A', label: t('stages.script'), status: getStageStatus('assets') },\n    { id: 'storyboard', icon: 'B', label: t('stages.storyboard'), status: getStageStatus('storyboard') },\n    { id: 'videos', icon: 'V', label: t('stages.video'), status: getStageStatus('videos') },\n    {\n      id: 'editor',\n      icon: 'E',\n      label: t('stages.editor'),\n      status: 'empty',\n      disabled: true,\n      disabledLabel: t('stages.editorComingSoon'),\n    },\n  ]\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceStageRuntime.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport type { WorkspaceStageRuntimeValue } from '../WorkspaceStageRuntimeContext'\nimport type { CapabilitySelections, ModelCapabilities } from '@/lib/model-config-contract'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\nimport type { BatchVideoGenerationParams, VideoGenerationOptions } from '../components/video'\n\ninterface UseWorkspaceStageRuntimeParams {\n  assetsLoading: boolean\n  isSubmittingTTS: boolean\n  isTransitioning: boolean\n  isConfirmingAssets: boolean\n  isStartingStoryToScript: boolean\n  isStartingScriptToStoryboard: boolean\n  videoRatio: string | undefined\n  artStyle: string | undefined\n  videoModel: string | undefined\n  capabilityOverrides: CapabilitySelections\n  userVideoModels: Array<{\n    value: string\n    label: string\n    provider?: string\n    providerName?: string\n    capabilities?: ModelCapabilities\n    videoPricingTiers?: VideoPricingTier[]\n  }> | undefined\n  handleUpdateEpisode: (key: string, value: unknown) => Promise<void>\n  handleUpdateConfig: (key: string, value: unknown) => Promise<void>\n  runWithRebuildConfirm: (action: 'storyToScript' | 'scriptToStoryboard', operation: () => Promise<void>) => Promise<void>\n  runStoryToScriptFlow: () => Promise<void>\n  runScriptToStoryboardFlow: () => Promise<void>\n  handleUpdateClip: (clipId: string, updates: Record<string, unknown>) => Promise<void>\n  openAssetLibrary: (characterId?: string | null, refreshAssets?: boolean) => void\n  handleStageChange: (stage: string) => void\n  handleGenerateVideo: (\n    storyboardId: string,\n    panelIndex: number,\n    videoModel?: string,\n    firstLastFrame?: {\n      lastFrameStoryboardId: string\n      lastFramePanelIndex: number\n      flModel: string\n      customPrompt?: string\n    },\n    generationOptions?: VideoGenerationOptions,\n    panelId?: string,\n  ) => Promise<void>\n  handleGenerateAllVideos: (options?: BatchVideoGenerationParams) => Promise<void>\n  handleUpdateVideoPrompt: (\n    storyboardId: string,\n    panelIndex: number,\n    value: string,\n    field?: 'videoPrompt' | 'firstLastFramePrompt',\n  ) => Promise<void>\n  handleUpdatePanelVideoModel: (storyboardId: string, panelIndex: number, model: string) => Promise<void>\n}\n\nexport function useWorkspaceStageRuntime({\n  assetsLoading,\n  isSubmittingTTS,\n  isTransitioning,\n  isConfirmingAssets,\n  isStartingStoryToScript,\n  isStartingScriptToStoryboard,\n  videoRatio,\n  artStyle,\n  videoModel,\n  capabilityOverrides,\n  userVideoModels,\n  handleUpdateEpisode,\n  handleUpdateConfig,\n  runWithRebuildConfirm,\n  runStoryToScriptFlow,\n  runScriptToStoryboardFlow,\n  handleUpdateClip,\n  openAssetLibrary,\n  handleStageChange,\n  handleGenerateVideo,\n  handleGenerateAllVideos,\n  handleUpdateVideoPrompt,\n  handleUpdatePanelVideoModel,\n}: UseWorkspaceStageRuntimeParams) {\n  const resolvedUserVideoModels = useMemo(\n    () => userVideoModels || [],\n    [userVideoModels],\n  )\n\n  return useMemo<WorkspaceStageRuntimeValue>(() => ({\n    assetsLoading,\n    isSubmittingTTS,\n    isTransitioning,\n    isConfirmingAssets,\n    isStartingStoryToScript,\n    isStartingScriptToStoryboard,\n    videoRatio,\n    artStyle,\n    videoModel,\n    capabilityOverrides,\n    userVideoModels: resolvedUserVideoModels,\n    onNovelTextChange: (value) => handleUpdateEpisode('novelText', value),\n    onVideoRatioChange: (value) => handleUpdateConfig('videoRatio', value),\n    onArtStyleChange: (value) => handleUpdateConfig('artStyle', value),\n    onRunStoryToScript: () => runWithRebuildConfirm('storyToScript', runStoryToScriptFlow),\n    onClipUpdate: (clipId, data) => {\n      if (!data || typeof data !== 'object' || Array.isArray(data)) {\n        throw new Error('onClipUpdate requires a plain object payload')\n      }\n      return handleUpdateClip(clipId, data as Record<string, unknown>)\n    },\n    onOpenAssetLibrary: () => openAssetLibrary(),\n    onRunScriptToStoryboard: () => runWithRebuildConfirm('scriptToStoryboard', runScriptToStoryboardFlow),\n    onStageChange: handleStageChange,\n    onGenerateVideo: handleGenerateVideo,\n    onGenerateAllVideos: handleGenerateAllVideos,\n    onUpdateVideoPrompt: handleUpdateVideoPrompt,\n    onUpdatePanelVideoModel: handleUpdatePanelVideoModel,\n    onOpenAssetLibraryForCharacter: (characterId, refreshAssets) => openAssetLibrary(characterId, refreshAssets),\n  }), [\n    artStyle,\n    assetsLoading,\n    handleGenerateAllVideos,\n    handleGenerateVideo,\n    handleStageChange,\n    handleUpdateClip,\n    handleUpdateConfig,\n    handleUpdateEpisode,\n    handleUpdatePanelVideoModel,\n    handleUpdateVideoPrompt,\n    isConfirmingAssets,\n    isStartingScriptToStoryboard,\n    isStartingStoryToScript,\n    isSubmittingTTS,\n    isTransitioning,\n    openAssetLibrary,\n    runScriptToStoryboardFlow,\n    runStoryToScriptFlow,\n    runWithRebuildConfirm,\n    resolvedUserVideoModels,\n    capabilityOverrides,\n    videoModel,\n    videoRatio,\n  ])\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceUserModels.ts",
    "content": "'use client'\n\nimport { useEffect, useMemo } from 'react'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useUserModels } from '@/lib/query/hooks'\nimport type { ModelCapabilities } from '@/lib/model-config-contract'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\n\nexport interface UserModelOption {\n  value: string\n  label: string\n  provider?: string\n  providerName?: string\n  capabilities?: ModelCapabilities\n  videoPricingTiers?: VideoPricingTier[]\n}\n\nexport interface UserModelsPayload {\n  llm: UserModelOption[]\n  image: UserModelOption[]\n  video: UserModelOption[]\n  audio: UserModelOption[]\n  lipsync: UserModelOption[]\n}\n\nexport function useWorkspaceUserModels() {\n  const userModelsQuery = useUserModels()\n  const userModelsForSettings = (userModelsQuery.data || null) as UserModelsPayload | null\n  const userVideoModels = useMemo<UserModelOption[]>(() => {\n    if (!userModelsForSettings || !Array.isArray(userModelsForSettings.video)) return []\n    return userModelsForSettings.video\n  }, [userModelsForSettings])\n  const userModelsLoaded = userModelsQuery.isFetched\n\n  useEffect(() => {\n    if (userModelsQuery.error) {\n      _ulogError('Failed to fetch user models:', userModelsQuery.error)\n    }\n  }, [userModelsQuery.error])\n\n  return {\n    userModelsForSettings,\n    userVideoModels,\n    userModelsLoaded,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceVideoActions.ts",
    "content": "'use client'\n\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { useGenerateVideo, useBatchGenerateVideos } from '@/lib/query/hooks/useStoryboards'\nimport { useUpdateProjectPanelVideoPrompt, useUpdateProjectClip, useUpdateProjectConfig } from '@/lib/query/hooks'\nimport type { BatchVideoGenerationParams, VideoGenerationOptions } from '../components/video'\n\ninterface UseWorkspaceVideoActionsParams {\n  projectId: string\n  episodeId?: string\n  t: (key: string) => string\n}\n\nfunction isAbortError(err: unknown): boolean {\n  if (!(err instanceof Error)) return false\n  return err.name === 'AbortError' || err.message === 'Failed to fetch'\n}\n\nfunction getErrorMessage(err: unknown): string {\n  if (err instanceof Error) return err.message\n  return String(err)\n}\n\nfunction assertClipUpdateData(data: unknown): asserts data is Record<string, unknown> {\n  if (!data || typeof data !== 'object' || Array.isArray(data)) {\n    throw new TypeError('Clip update payload must be an object')\n  }\n}\n\nexport function useWorkspaceVideoActions({\n  projectId,\n  episodeId,\n  t,\n}: UseWorkspaceVideoActionsParams) {\n  const generateVideoMutation = useGenerateVideo(projectId, episodeId || null)\n  const batchGenerateVideosMutation = useBatchGenerateVideos(projectId, episodeId || null)\n  const updateProjectPanelVideoPromptMutation = useUpdateProjectPanelVideoPrompt(projectId)\n  const updateProjectClipMutation = useUpdateProjectClip(projectId)\n  const updateProjectConfigMutation = useUpdateProjectConfig(projectId)\n\n  const handleGenerateVideo = async (\n    storyboardId: string,\n    panelIndex: number,\n    videoModel?: string,\n    firstLastFrame?: {\n      lastFrameStoryboardId: string\n      lastFramePanelIndex: number\n      flModel: string\n      customPrompt?: string\n    },\n    generationOptions?: VideoGenerationOptions,\n    panelId?: string,\n  ) => {\n    const normalizedVideoModel = typeof videoModel === 'string' ? videoModel.trim() : ''\n    if (!normalizedVideoModel) {\n      alert('Video model is required')\n      return\n    }\n    try {\n      await generateVideoMutation.mutateAsync({\n        storyboardId,\n        panelIndex,\n        panelId,\n        videoModel: normalizedVideoModel,\n        firstLastFrame,\n        generationOptions,\n      })\n    } catch (err: unknown) {\n      if (isAbortError(err)) {\n        _ulogInfo(t('execution.requestAborted'))\n        return\n      }\n      alert(`${t('execution.generationFailed')}: ${getErrorMessage(err)}`)\n      throw err\n    }\n  }\n\n  const handleGenerateAllVideos = async (options?: BatchVideoGenerationParams) => {\n    if (!episodeId) {\n      alert(t('execution.selectEpisode'))\n      return\n    }\n    const normalizedVideoModel = typeof options?.videoModel === 'string' ? options.videoModel.trim() : ''\n    if (!normalizedVideoModel) {\n      alert('Video model is required')\n      return\n    }\n\n    try {\n      await batchGenerateVideosMutation.mutateAsync({\n        ...options,\n        videoModel: normalizedVideoModel,\n      })\n    } catch (err: unknown) {\n      if (isAbortError(err)) {\n        _ulogInfo(t('execution.requestAborted'))\n        return\n      }\n      alert(`${t('execution.batchVideoFailed')}: ${getErrorMessage(err)}`)\n      throw err\n    }\n  }\n\n  const handleUpdateVideoPrompt = async (\n    storyboardId: string,\n    panelIndex: number,\n    value: string,\n    field: 'videoPrompt' | 'firstLastFramePrompt' = 'videoPrompt',\n  ) => {\n    await updateProjectPanelVideoPromptMutation.mutateAsync({ storyboardId, panelIndex, value, field })\n  }\n\n  const handleUpdatePanelVideoModel = async (_storyboardId: string, _panelIndex: number, model: string) => {\n    const normalizedModel = model.trim()\n    if (!normalizedModel) return\n    try {\n      await updateProjectConfigMutation.mutateAsync({\n        key: 'videoModel',\n        value: normalizedModel,\n      })\n    } catch (err: unknown) {\n      _ulogError(`${t('execution.updateFailed')}:`, err)\n    }\n  }\n\n  const handleUpdateClip = async (clipId: string, data: unknown) => {\n    if (!episodeId) {\n      _ulogError('No episode selected for clip update')\n      return\n    }\n    try {\n      assertClipUpdateData(data)\n      await updateProjectClipMutation.mutateAsync({ clipId, data, episodeId })\n    } catch (err: unknown) {\n      _ulogError(`${t('execution.updateFailed')}:`, err)\n      alert(`${t('execution.saveFailed')}: ${getErrorMessage(err)}`)\n    }\n  }\n\n  return {\n    handleGenerateVideo,\n    handleGenerateAllVideos,\n    handleUpdateVideoPrompt,\n    handleUpdatePanelVideoModel,\n    handleUpdateClip,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/workspace-controller-view-model.ts",
    "content": "'use client'\n\nimport type { UserModelsPayload } from './useWorkspaceUserModels'\nimport type { WorkspaceStageRuntimeValue } from '../WorkspaceStageRuntimeContext'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport type { BatchVideoGenerationParams, VideoGenerationOptions } from '../components/video'\nimport type { CapabilitySelections } from '@/lib/model-config-contract'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\nimport type {\n  useScriptToStoryboardRunStream,\n  useStoryToScriptRunStream,\n} from '@/lib/query/hooks'\n\ntype StoryToScriptStreamState = ReturnType<typeof useStoryToScriptRunStream>\ntype ScriptToStoryboardStreamState = ReturnType<typeof useScriptToStoryboardRunStream>\n\ninterface ProjectSnapshotInput {\n  projectData: unknown\n  projectCharacters: unknown[]\n  projectLocations: unknown[]\n  globalAssetText: string\n  novelText: string\n  analysisModel: string | undefined\n  characterModel: string | undefined\n  locationModel: string | undefined\n  storyboardModel: string | undefined\n  editModel: string | undefined\n  videoModel: string | undefined\n  audioModel: string | undefined\n  videoRatio: string | undefined\n  capabilityOverrides: CapabilitySelections\n  ttsRate: string | number | undefined\n  artStyle: string | undefined\n}\n\ninterface BuildWorkspaceControllerViewModelParams {\n  t: (key: string, values?: Record<string, string | number | Date>) => string\n  tc: (key: string, values?: Record<string, string | number | Date>) => string\n  te: (key: string, values?: Record<string, string | number | Date>) => string\n  projectSnapshot: ProjectSnapshotInput\n  uiState: {\n    onRefresh: (options?: { mode?: 'full' | 'light' | 'assets' }) => Promise<void>\n    assetsLoading: boolean\n    assetsLoadingState: TaskPresentationState | null\n    isSettingsModalOpen: boolean\n    setIsSettingsModalOpen: (open: boolean) => void\n    isWorldContextModalOpen: boolean\n    setIsWorldContextModalOpen: (open: boolean) => void\n    isAssetLibraryOpen: boolean\n    assetLibraryFocusCharacterId: string | null\n    assetLibraryFocusRequestId: number\n    triggerGlobalAnalyzeOnOpen: boolean\n    setTriggerGlobalAnalyzeOnOpen: (value: boolean) => void\n    openAssetLibrary: (characterId?: string | null, refreshAssets?: boolean) => void\n    closeAssetLibrary: () => void\n    userModelsForSettings: UserModelsPayload | null\n    userVideoModels: Array<{\n      value: string\n      label: string\n      capabilities?: UserModelsPayload['video'][number]['capabilities']\n      videoPricingTiers?: VideoPricingTier[]\n    }>\n    userModelsLoaded: boolean\n  }\n  stageNavState: {\n    currentStage: string\n    capsuleNavItems: Array<{\n      id: string\n      label: string\n      icon: string\n      status: 'empty' | 'active' | 'processing' | 'ready'\n      disabled?: boolean\n      disabledLabel?: string\n    }>\n    handleStageChange: (stage: string) => void\n  }\n  rebuildState: {\n    showRebuildConfirm: boolean\n    rebuildConfirmTitle: string\n    rebuildConfirmMessage: string\n    pendingActionType: 'storyToScript' | 'scriptToStoryboard' | null\n    runWithRebuildConfirm: (action: 'storyToScript' | 'scriptToStoryboard', operation: () => Promise<void>) => Promise<void>\n    handleCancelRebuildConfirm: () => void\n    handleAcceptRebuildConfirm: () => void\n  }\n  executionState: {\n    isSubmittingTTS: boolean\n    isAssetAnalysisRunning: boolean\n    isConfirmingAssets: boolean\n    isTransitioning: boolean\n    isStartingStoryToScript: boolean\n    isStartingScriptToStoryboard: boolean\n    transitionProgress: { step?: string; total?: number; current?: number }\n    storyToScriptConsoleMinimized: boolean\n    setStoryToScriptConsoleMinimized: (minimized: boolean) => void\n    scriptToStoryboardConsoleMinimized: boolean\n    setScriptToStoryboardConsoleMinimized: (minimized: boolean) => void\n    storyToScriptStream: StoryToScriptStreamState\n    scriptToStoryboardStream: ScriptToStoryboardStreamState\n    handleGenerateTTS: () => Promise<void>\n    handleAnalyzeAssets: () => Promise<void>\n    runStoryToScriptFlow: () => Promise<void>\n    runScriptToStoryboardFlow: () => Promise<void>\n    showCreatingToast: boolean\n  }\n  videoState: {\n    handleGenerateVideo: (\n      storyboardId: string,\n      panelIndex: number,\n      videoModel?: string,\n      firstLastFrame?: {\n        lastFrameStoryboardId: string\n        lastFramePanelIndex: number\n        flModel: string\n        customPrompt?: string\n      },\n      generationOptions?: VideoGenerationOptions,\n      panelId?: string,\n    ) => Promise<void>\n    handleGenerateAllVideos: (options?: BatchVideoGenerationParams) => Promise<void>\n    handleUpdateVideoPrompt: (\n      storyboardId: string,\n      panelIndex: number,\n      value: string,\n      field?: 'videoPrompt' | 'firstLastFramePrompt',\n    ) => Promise<void>\n    handleUpdatePanelVideoModel: (storyboardId: string, panelIndex: number, model: string) => Promise<void>\n    handleUpdateClip: (clipId: string, updates: Record<string, unknown>) => Promise<void>\n  }\n  stageRuntime: WorkspaceStageRuntimeValue\n  actionsState: {\n    handleUpdateConfig: (key: string, value: unknown) => Promise<void>\n    handleUpdateEpisode: (key: string, value: unknown) => Promise<void>\n  }\n}\n\nexport function buildWorkspaceControllerViewModel({\n  t,\n  tc,\n  te,\n  projectSnapshot,\n  uiState,\n  stageNavState,\n  rebuildState,\n  executionState,\n  videoState,\n  stageRuntime,\n  actionsState,\n}: BuildWorkspaceControllerViewModelParams) {\n  return {\n    i18n: { t, tc, te },\n    project: projectSnapshot,\n    ui: uiState,\n    stageNav: stageNavState,\n    rebuild: rebuildState,\n    execution: executionState,\n    video: videoState,\n    runtime: { stageRuntime },\n    actions: actionsState,\n  }\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/modes/novel-promotion/types.ts",
    "content": "import type { Project } from '@/types/project'\nimport type {\n  NovelPromotionClip,\n  NovelPromotionShot,\n  NovelPromotionStoryboard,\n} from '@/types/project'\n\nexport interface Episode {\n  id: string\n  episodeNumber: number\n  name: string\n  description?: string | null\n  novelText?: string | null\n  audioUrl?: string | null\n  srtContent?: string | null\n  clips?: NovelPromotionClip[]\n  storyboards?: NovelPromotionStoryboard[]\n  shots?: NovelPromotionShot[]\n  voiceLines?: unknown[]\n  createdAt: string\n}\n\nexport interface NovelPromotionWorkspaceProps {\n  project: Project\n  projectId: string\n  episodeId?: string\n  episode?: Episode | null\n  viewMode?: 'global-assets' | 'episode'\n  urlStage?: string | null\n  onStageChange?: (stage: string) => void\n  episodes?: Episode[]\n  onEpisodeSelect?: (episodeId: string) => void\n  onEpisodeCreate?: () => void\n  onEpisodeRename?: (episodeId: string, newName: string) => void\n  onEpisodeDelete?: (episodeId: string) => void\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/[projectId]/page.tsx",
    "content": "'use client'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { apiFetch } from '@/lib/api-fetch'\n\nimport { useEffect, useState, useCallback, useMemo } from 'react'\nimport { useParams, useSearchParams } from 'next/navigation'\nimport { useTranslations } from 'next-intl'\nimport { useQueryClient } from '@tanstack/react-query'\nimport Navbar from '@/components/Navbar'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { useProjectData, useEpisodeData, useUserModels } from '@/lib/query/hooks'\nimport { queryKeys } from '@/lib/query/keys'\nimport NovelPromotionWorkspace from './modes/novel-promotion/NovelPromotionWorkspace'\nimport SmartImportWizard, { SplitEpisode } from './modes/novel-promotion/components/SmartImportWizard'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { resolveSelectedEpisodeId } from './episode-selection'\nimport { ModelCapabilityDropdown } from '@/components/ui/config-modals/ModelCapabilityDropdown'\nimport { AppIcon } from '@/components/ui/icons'\nimport { readConfiguredAnalysisModel, shouldGuideToModelSetup } from '@/lib/workspace/model-setup'\nimport { useRouter } from '@/i18n/navigation'\n\n// 有效的stage值\nconst VALID_STAGES = ['config', 'script', 'assets', 'text-storyboard', 'storyboard', 'videos', 'voice', 'editor'] as const\ntype Stage = typeof VALID_STAGES[number]\n\ninterface Episode {\n  id: string\n  episodeNumber: number\n  name: string\n  description?: string | null\n  novelText?: string | null\n  audioUrl?: string | null\n  srtContent?: string | null\n  createdAt: string\n}\n\ntype NovelPromotionData = {\n  episodes?: Episode[]\n  importStatus?: string\n}\n\n/**\n * 项目详情页 - 带侧边栏的剧集管理\n */\nexport default function ProjectDetailPage() {\n  const params = useParams<{ projectId?: string }>()\n  const router = useRouter()\n  const searchParams = useSearchParams()\n  if (!params?.projectId) {\n    throw new Error('ProjectDetailPage requires projectId route param')\n  }\n  if (!searchParams) {\n    throw new Error('ProjectDetailPage requires searchParams')\n  }\n  const projectId = params.projectId\n  const t = useTranslations('workspaceDetail')\n  const tc = useTranslations('common')\n\n  // 从URL读取参数\n  const urlStage = searchParams.get('stage') as Stage | null\n  const urlEpisodeId = searchParams.get('episode') ?? null\n  const currentUrlStage = urlStage && VALID_STAGES.includes(urlStage) ? urlStage : null\n\n  // 🔥 React Query 数据获取\n  const queryClient = useQueryClient()\n  const { data: project, isLoading: loading, error: projectError } = useProjectData(projectId)\n  const error = projectError?.message || null\n\n  // 视图状态（仅 UI）\n  const [isGlobalAssetsView, setIsGlobalAssetsView] = useState(false)\n  const [isCheckingModelSetup, setIsCheckingModelSetup] = useState(true)\n  const [needsModelSetup, setNeedsModelSetup] = useState(false)\n  const [analysisModelDraft, setAnalysisModelDraft] = useState('')\n  const [isModelSetupModalOpen, setIsModelSetupModalOpen] = useState(false)\n  const [modelSetupSaving, setModelSetupSaving] = useState(false)\n\n  const userModelsQuery = useUserModels()\n  const llmModelOptions = userModelsQuery.data?.llm || []\n\n  // 更新URL参数（stage 和/或 episode）\n  const updateUrlParams = useCallback((updates: { stage?: string; episode?: string | null }) => {\n    const params = new URLSearchParams(searchParams.toString())\n    if (updates.stage !== undefined) {\n      params.set('stage', updates.stage)\n    }\n    if (updates.episode !== undefined) {\n      if (updates.episode) {\n        params.set('episode', updates.episode)\n      } else {\n        params.delete('episode')\n      }\n    }\n    const query = Object.fromEntries(params.entries())\n    router.replace(\n      {\n        pathname: `/workspace/${projectId}`,\n        query,\n      },\n      { scroll: false },\n    )\n  }, [router, projectId, searchParams])\n\n  // 更新URL中的stage参数（保持向后兼容）\n  const updateUrlStage = useCallback((stage: string) => {\n    updateUrlParams({ stage })\n  }, [updateUrlParams])\n\n  // Stage 状态完全由 URL 控制，不再从数据库同步\n  // 如果 URL 没有 stage 参数，默认使用 'config'\n  // 🚧 剪辑阶段 (editor) 暂时禁用，自动重定向到成片阶段 (videos)\n  const effectiveStage = currentUrlStage === 'editor' ? 'videos' : (currentUrlStage || 'config')\n\n  // 获取剧集列表\n  const novelPromotionData = project?.novelPromotionData as NovelPromotionData | undefined\n  const episodes = useMemo<Episode[]>(() => {\n    const getNum = (name: string) => { const m = name.match(/\\d+/); return m ? parseInt(m[0], 10) : Infinity }\n    return [...(novelPromotionData?.episodes ?? [])].sort((a, b) => {\n      const diff = getNum(a.name) - getNum(b.name)\n      return diff !== 0 ? diff : a.name.localeCompare(b.name, 'zh')\n    })\n  }, [novelPromotionData?.episodes])\n\n  // 剧集导航状态单源：URL（无本地副本）\n  const selectedEpisodeId = useMemo(\n    () => resolveSelectedEpisodeId(episodes, urlEpisodeId),\n    [episodes, urlEpisodeId],\n  )\n\n  // 🔥 使用 React Query 获取剧集数据\n  const { data: currentEpisode } = useEpisodeData(\n    projectId,\n    !isGlobalAssetsView ? selectedEpisodeId : null\n  )\n\n  // 获取导入状态\n  const importStatus = novelPromotionData?.importStatus\n\n  // 检测是否需要显示导入向导：无剧集或导入中\n  const isZeroState = episodes.length === 0\n  const shouldShowImportWizard = isZeroState || importStatus === 'pending'\n  const shouldGateImportWizardByModel = shouldShowImportWizard && !isGlobalAssetsView\n\n  useEffect(() => {\n    if (!shouldGateImportWizardByModel) return\n\n    let canceled = false\n    const checkDefaultModelSetup = async () => {\n      setIsCheckingModelSetup(true)\n      try {\n        const response = await apiFetch('/api/user-preference')\n        if (!response.ok) {\n          _ulogError('[ProjectDetail] 获取用户默认模型失败:', { status: response.status })\n          if (!canceled) {\n            setNeedsModelSetup(true)\n            setAnalysisModelDraft('')\n          }\n          return\n        }\n\n        const payload: unknown = await response.json()\n        const configuredModel = readConfiguredAnalysisModel(payload)\n        if (!canceled) {\n          setAnalysisModelDraft(configuredModel || '')\n          setNeedsModelSetup(shouldGuideToModelSetup(payload))\n        }\n      } catch (err) {\n        _ulogError('[ProjectDetail] 检查默认模型失败:', err)\n        if (!canceled) {\n          setNeedsModelSetup(true)\n          setAnalysisModelDraft('')\n        }\n      } finally {\n        if (!canceled) {\n          setIsCheckingModelSetup(false)\n        }\n      }\n    }\n\n    void checkDefaultModelSetup()\n    return () => {\n      canceled = true\n    }\n  }, [shouldGateImportWizardByModel])\n\n  // 初始化 URL：无效/缺失 episode 时，统一回写默认 episode\n  useEffect(() => {\n    if (!project || isGlobalAssetsView || episodes.length === 0) return\n    if (urlEpisodeId && episodes.some((episode) => episode.id === urlEpisodeId)) return\n    if (selectedEpisodeId) {\n      updateUrlParams({ episode: selectedEpisodeId })\n    }\n  }, [episodes, isGlobalAssetsView, project, selectedEpisodeId, updateUrlParams, urlEpisodeId])\n\n  // 创建剧集\n  const handleCreateEpisode = async (name: string, description?: string) => {\n    const res = await apiFetch(`/api/novel-promotion/${projectId}/episodes`, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ name, description })\n    })\n\n    if (!res.ok) {\n      const data = await res.json()\n      throw new Error(data.error || t('createFailed'))\n    }\n\n    const data = await res.json()\n    // 🔥 刷新项目数据获取新的剧集列表\n    queryClient.invalidateQueries({ queryKey: queryKeys.projectData(projectId) })\n    // 自动切换到新创建的剧集\n    setIsGlobalAssetsView(false)\n    // 同步到URL\n    updateUrlParams({ episode: data.episode.id })\n  }\n\n  // 智能导入 - 完成后刷新数据（数据已由 SmartImportWizard 保存）\n  const handleSmartImportComplete = async (splitEpisodes: SplitEpisode[], triggerGlobalAnalysis?: boolean) => {\n    _ulogInfo('[Page] handleSmartImportComplete 被调用，triggerGlobalAnalysis:', triggerGlobalAnalysis)\n\n    try {\n      // 🔥 刷新项目数据\n      queryClient.invalidateQueries({ queryKey: queryKeys.projectData(projectId) })\n\n      // 刷新后重新获取最新的剧集列表\n      const res = await apiFetch(`/api/projects/${projectId}/data`)\n      const data = await res.json()\n      // API 返回结构是 { project: { novelPromotionData: { episodes: [...] } } }\n      const newEpisodes = data?.project?.novelPromotionData?.episodes || []\n      _ulogInfo('[Page] 获取到新剧集:', newEpisodes.length, '个')\n\n      // 如果有剧集，进入第一个\n      if (newEpisodes.length > 0) {\n        // 如果需要触发全局分析，切换到 assets 阶段并带上参数\n        if (triggerGlobalAnalysis) {\n          _ulogInfo('[Page] 触发全局分析，跳转到 assets 阶段，带 globalAnalyze=1 参数')\n          // 使用相对路径更新，保留 locale\n          const params = new URLSearchParams()\n          params.set('stage', 'assets')\n          params.set('episode', newEpisodes[0].id)\n          params.set('globalAnalyze', '1')\n          const newUrl = `?${params.toString()}`\n          _ulogInfo('[Page] 跳转到:', newUrl)\n          router.replace(newUrl, { scroll: false })\n        } else {\n          _ulogInfo('[Page] 不触发全局分析，只更新 episode 参数')\n          updateUrlParams({ episode: newEpisodes[0].id })\n        }\n      }\n    } catch (err: unknown) {\n      _ulogError('刷新失败:', err)\n    }\n  }\n\n  // 重命名剧集\n  const handleRenameEpisode = async (episodeId: string, newName: string) => {\n    const res = await apiFetch(`/api/novel-promotion/${projectId}/episodes/${episodeId}`, {\n      method: 'PATCH',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ name: newName })\n    })\n\n    if (!res.ok) {\n      throw new Error(t('renameFailed'))\n    }\n\n    // 🔥 刷新项目数据\n    queryClient.invalidateQueries({ queryKey: queryKeys.projectData(projectId) })\n    // 剧集详情也刷新\n    if (selectedEpisodeId) {\n      queryClient.invalidateQueries({ queryKey: queryKeys.episodeData(projectId, selectedEpisodeId) })\n    }\n  }\n\n  // 删除剧集\n  const handleDeleteEpisode = async (episodeId: string) => {\n    const res = await apiFetch(`/api/novel-promotion/${projectId}/episodes/${episodeId}`, {\n      method: 'DELETE',\n    })\n    if (!res.ok) {\n      throw new Error(t('deleteFailed'))\n    }\n    // 刷新项目数据\n    queryClient.invalidateQueries({ queryKey: queryKeys.projectData(projectId) })\n    // 如果删除的是当前正在查看的剧集，切换到其他剧集\n    if (episodeId === selectedEpisodeId) {\n      const remaining = episodes.filter(ep => ep.id !== episodeId)\n      if (remaining.length > 0) {\n        updateUrlParams({ episode: remaining[0].id })\n      } else {\n        updateUrlParams({ episode: null })\n      }\n    }\n  }\n\n  // 选择剧集\n  const handleEpisodeSelect = (episodeId: string) => {\n    setIsGlobalAssetsView(false)\n    // 同步到URL\n    updateUrlParams({ episode: episodeId })\n  }\n\n  const handleSaveDefaultAnalysisModel = async () => {\n    const modelKey = analysisModelDraft.trim()\n    if (!modelKey) {\n      alert(t('modelSetup.selectModelFirst'))\n      return\n    }\n\n    setModelSetupSaving(true)\n    try {\n      const response = await apiFetch('/api/user-preference', {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ analysisModel: modelKey }),\n      })\n\n      if (!response.ok) {\n        throw new Error(`HTTP ${response.status}`)\n      }\n\n      setNeedsModelSetup(false)\n      setIsModelSetupModalOpen(false)\n    } catch (err) {\n      _ulogError('[ProjectDetail] 保存默认分析模型失败:', err)\n      alert(t('modelSetup.saveFailed'))\n    } finally {\n      setModelSetupSaving(false)\n    }\n  }\n\n  // Loading状态：等待项目数据和剧集数据都准备好\n  // 条件：正在加载 或 (有剧集但episode数据未准备好)\n  // 排除：如果要显示导入向导，则不需要等待剧集数据\n  const isInitializing = loading ||\n    (!shouldShowImportWizard && !isGlobalAssetsView && episodes.length > 0 && (!selectedEpisodeId || !currentEpisode)) ||\n    (project && !project.novelPromotionData)\n  const initLoadingState = resolveTaskPresentationState({\n    phase: 'processing',\n    intent: 'generate',\n    resource: 'text',\n    hasOutput: false,\n  })\n\n  if (isInitializing) {\n    return (\n      <div className=\"glass-page min-h-screen\">\n        <Navbar />\n        <main className=\"flex items-center justify-center h-[calc(100vh-64px)]\">\n          <div className=\"text-[var(--glass-text-secondary)]\">{tc('loading')}</div>\n        </main>\n      </div>\n    )\n  }\n\n  // Error状态\n  if (error || !project) {\n    return (\n      <div className=\"glass-page min-h-screen\">\n        <Navbar />\n        <main className=\"container mx-auto px-4 py-8\">\n          <div className=\"glass-surface p-6 text-center\">\n            <p className=\"text-[var(--glass-tone-danger-fg)] mb-4\">{error || t('projectNotFound')}</p>\n            <button\n              onClick={() => router.push({ pathname: '/workspace' })}\n              className=\"glass-btn-base glass-btn-primary px-6 py-2\"\n            >\n              {t('backToWorkspace')}\n            </button>\n          </div>\n        </main>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"glass-page min-h-screen flex flex-col\">\n      <Navbar />\n\n      {/* V3 UI: 浮动导航替代了旧的 Sidebar */}\n\n      {/* 主内容区 - 占满全部宽度 */}\n      <main className=\"flex-1 overflow-y-auto\">\n        <div className=\"container mx-auto px-4 py-8\">\n          {isGlobalAssetsView && project.novelPromotionData ? (\n            // 全局资产视图（确保数据准备好）\n            <div>\n              <h1 className=\"text-2xl font-bold text-[var(--glass-text-primary)] mb-6\">{t('globalAssets')}</h1>\n              <NovelPromotionWorkspace\n                project={project}\n                projectId={projectId}\n                viewMode=\"global-assets\"\n                urlStage={effectiveStage}\n                onStageChange={updateUrlStage}\n              />\n            </div>\n          ) : shouldShowImportWizard && !isGlobalAssetsView ? (\n            isCheckingModelSetup ? (\n              <div className=\"glass-surface p-8 text-center\">\n                <div className=\"mx-auto mb-4 w-12 h-12 rounded-full flex items-center justify-center bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)]\">\n                  <TaskStatusInline state={initLoadingState} className=\"[&>span]:sr-only\" />\n                </div>\n                <h2 className=\"text-xl font-semibold text-[var(--glass-text-secondary)] mb-2\">{tc('loading')}</h2>\n              </div>\n            ) : needsModelSetup ? (\n              <div className=\"glass-surface p-8 max-w-2xl mx-auto\">\n                <div className=\"flex items-start gap-4\">\n                  <div className=\"w-10 h-10 rounded-lg bg-[var(--glass-tone-warning-bg)] text-[var(--glass-tone-warning-fg)] flex items-center justify-center shrink-0\">\n                    <AppIcon name=\"alert\" className=\"w-5 h-5\" />\n                  </div>\n                  <div className=\"flex-1\">\n                    <h2 className=\"text-xl font-semibold text-[var(--glass-text-primary)] mb-2\">\n                      {t('modelSetup.title')}\n                    </h2>\n                    <p className=\"text-[var(--glass-text-secondary)] mb-5\">\n                      {t('modelSetup.description')}\n                    </p>\n                    <div className=\"flex flex-wrap gap-3\">\n                      <button\n                        onClick={() => setIsModelSetupModalOpen(true)}\n                        className=\"glass-btn-base glass-btn-primary px-4 py-2\"\n                      >\n                        {t('modelSetup.configureNow')}\n                      </button>\n                      <button\n                        onClick={() => router.push({ pathname: '/profile' })}\n                        className=\"glass-btn-base glass-btn-secondary px-4 py-2\"\n                      >\n                        {t('modelSetup.goProfile')}\n                      </button>\n                    </div>\n                  </div>\n                </div>\n\n                {isModelSetupModalOpen && (\n                  <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 backdrop-blur-sm\">\n                    <div className=\"glass-surface-modal p-6 w-full max-w-xl mx-4\">\n                      <h3 className=\"text-xl font-bold text-[var(--glass-text-primary)] mb-2\">\n                        {t('modelSetup.modalTitle')}\n                      </h3>\n                      <p className=\"text-sm text-[var(--glass-text-secondary)] mb-5\">\n                        {t('modelSetup.modalDescription')}\n                      </p>\n\n                      <div className=\"mb-6\">\n                        <label className=\"glass-field-label block mb-2\">{t('modelSetup.selectModelLabel')}</label>\n                        {userModelsQuery.isLoading ? (\n                          <div className=\"text-sm text-[var(--glass-text-tertiary)]\">{tc('loading')}</div>\n                        ) : llmModelOptions.length === 0 ? (\n                          <div className=\"text-sm text-[var(--glass-tone-warning-fg)]\">\n                            {t('modelSetup.noModelOptions')}\n                          </div>\n                        ) : (\n                          <ModelCapabilityDropdown\n                            models={llmModelOptions}\n                            value={analysisModelDraft || undefined}\n                            onModelChange={setAnalysisModelDraft}\n                            capabilityFields={[]}\n                            capabilityOverrides={{}}\n                            onCapabilityChange={(field, rawValue, sample) => {\n                              void field\n                              void rawValue\n                              void sample\n                            }}\n                            placeholder={t('modelSetup.selectModelPlaceholder')}\n                          />\n                        )}\n                      </div>\n\n                      <div className=\"flex justify-end gap-3\">\n                        <button\n                          type=\"button\"\n                          onClick={() => setIsModelSetupModalOpen(false)}\n                          className=\"glass-btn-base glass-btn-secondary px-4 py-2\"\n                          disabled={modelSetupSaving}\n                        >\n                          {tc('cancel')}\n                        </button>\n                        <button\n                          type=\"button\"\n                          onClick={() => { void handleSaveDefaultAnalysisModel() }}\n                          className=\"glass-btn-base glass-btn-primary px-4 py-2 disabled:opacity-50\"\n                          disabled={modelSetupSaving || llmModelOptions.length === 0 || !analysisModelDraft.trim()}\n                        >\n                          {modelSetupSaving ? tc('loading') : tc('save')}\n                        </button>\n                      </div>\n                    </div>\n                  </div>\n                )}\n              </div>\n            ) : (\n              // 零状态或导入中：显示智能导入向导\n              <SmartImportWizard\n                projectId={projectId}\n                onManualCreate={() => handleCreateEpisode(`${t('episode')} 1`)}\n                onImportComplete={handleSmartImportComplete}\n                importStatus={importStatus}\n              />\n            )\n          ) : selectedEpisodeId && currentEpisode ? (\n            // 剧集工作区（确保所有数据都准备好）\n            <NovelPromotionWorkspace\n              project={project}\n              projectId={projectId}\n              episodeId={selectedEpisodeId}\n              episode={currentEpisode}\n              viewMode=\"episode\"\n              urlStage={effectiveStage}\n              onStageChange={updateUrlStage}\n              episodes={episodes}\n              onEpisodeSelect={handleEpisodeSelect}\n              onEpisodeCreate={() => handleCreateEpisode(`${t('episode')} ${episodes.length + 1}`)}\n              onEpisodeRename={handleRenameEpisode}\n              onEpisodeDelete={handleDeleteEpisode}\n            />\n          ) : (\n            // 加载中\n            <div className=\"glass-surface p-8 text-center\">\n              <div className=\"mx-auto mb-4 w-12 h-12 rounded-full flex items-center justify-center bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)]\">\n                <TaskStatusInline state={initLoadingState} className=\"[&>span]:sr-only\" />\n              </div>\n              <h2 className=\"text-xl font-semibold text-[var(--glass-text-secondary)] mb-2\">{tc('loading')}</h2>\n            </div>\n          )}\n        </div>\n      </main>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/AddLocationModal.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { ART_STYLES } from '@/lib/constants'\nimport { useAiDesignLocation, useCreateAssetHubLocation } from '@/lib/query/hooks'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface AddLocationModalProps {\n    folderId: string | null\n    onClose: () => void\n    onSuccess: () => void\n}\n\n// 内联 SVG 图标\nconst XMarkIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"close\" className={className} />\n)\n\nconst SparklesIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"sparklesAlt\" className={className} />\n)\n\nexport function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationModalProps) {\n    const t = useTranslations('assetHub')\n\n    // 表单字段\n    const [name, setName] = useState('')\n    const [summary, setSummary] = useState('')\n    const [aiInstruction, setAiInstruction] = useState('')\n    const [artStyle, setArtStyle] = useState('american-comic')\n\n    const aiDesignMutation = useAiDesignLocation()\n    const createLocationMutation = useCreateAssetHubLocation()\n    const { count: locationGenerationCount } = useImageGenerationCount('location')\n    const isSubmitting = createLocationMutation.isPending\n    const isAiDesigning = aiDesignMutation.isPending\n    const aiDesigningState = isAiDesigning\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n    const submittingState = isSubmitting\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n\n    // AI 设计描述\n    const handleAiDesign = async () => {\n        if (!aiInstruction.trim()) return\n\n        try {\n            const data = await aiDesignMutation.mutateAsync(aiInstruction.trim())\n            setSummary(data.prompt || '')\n            setAiInstruction('')\n        } catch (error) {\n            _ulogError('AI设计失败:', error)\n        }\n    }\n\n    // 提交\n    const handleSubmit = async () => {\n        if (!name.trim() || !summary.trim()) return\n\n        try {\n            await createLocationMutation.mutateAsync({\n                name: name.trim(),\n                summary: summary.trim(),\n                folderId,\n                artStyle,\n                count: locationGenerationCount,\n            })\n            onSuccess()\n        } catch (error) {\n            _ulogError('创建场景失败:', error)\n        }\n    }\n\n    return (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4\">\n            <div className=\"glass-surface-modal max-w-lg w-full max-h-[85vh] overflow-y-auto\">\n                <div className=\"p-6\">\n                    {/* 标题 */}\n                    <div className=\"flex items-center justify-between mb-6\">\n                        <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                            {t('modal.newLocation')}\n                        </h3>\n                        <button\n                            onClick={onClose}\n                            className=\"glass-btn-base glass-btn-soft h-8 w-8 rounded-full flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]\"\n                        >\n                            <XMarkIcon className=\"w-5 h-5\" />\n                        </button>\n                    </div>\n\n                    <div className=\"space-y-5\">\n                        {/* AI 设计区域 */}\n                        <div className=\"glass-surface-soft border border-[var(--glass-stroke-base)] rounded-xl p-4 space-y-3\">\n                            <div className=\"flex items-center gap-2 text-sm font-semibold text-[var(--glass-text-primary)]\">\n                                <SparklesIcon className=\"w-4 h-4\" />\n                                <span>{t('modal.aiDesign')}</span>\n                            </div>\n                            <div className=\"flex gap-2\">\n                                <input\n                                    type=\"text\"\n                                    value={aiInstruction}\n                                    onChange={(e) => setAiInstruction(e.target.value)}\n                                    placeholder={t('modal.aiDesignLocationPlaceholder')}\n                                    className=\"glass-input-base flex-1 px-3 py-2 text-sm\"\n                                    disabled={isAiDesigning}\n                                    onKeyDown={(e) => {\n                                        if (e.key === 'Enter' && !e.shiftKey) {\n                                            e.preventDefault()\n                                            handleAiDesign()\n                                        }\n                                    }}\n                                />\n                                <button\n                                    onClick={handleAiDesign}\n                                    disabled={isAiDesigning || !aiInstruction.trim()}\n                                    className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg text-sm\"\n                                >\n                                    {isAiDesigning ? (\n                                        <TaskStatusInline state={aiDesigningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                                    ) : (\n                                        <>\n                                            <SparklesIcon className=\"w-4 h-4\" />\n                                            <span>{t('modal.generate')}</span>\n                                        </>\n                                    )}\n                                </button>\n                            </div>\n                            <p className=\"glass-field-hint\">\n                                {t('modal.aiDesignLocationTip')}\n                            </p>\n                        </div>\n\n                        {/* 场景名称 */}\n                        <div className=\"space-y-2\">\n                            <label className=\"glass-field-label block\">\n                                {t('modal.locationNameLabel')}\n                            </label>\n                            <input\n                                type=\"text\"\n                                value={name}\n                                onChange={(e) => setName(e.target.value)}\n                                placeholder={t('modal.locationNamePlaceholder')}\n                                className=\"glass-input-base w-full px-3 py-2 text-sm\"\n                            />\n                        </div>\n\n                        {/* 风格选择 */}\n                        <div className=\"space-y-2\">\n                            <label className=\"glass-field-label block\">\n                                画面风格\n                            </label>\n                            <div className=\"grid grid-cols-2 gap-2\">\n                                {ART_STYLES.map((style) => (\n                                    <button\n                                        key={style.value}\n                                        type=\"button\"\n                                        onClick={() => setArtStyle(style.value)}\n                                        className={`glass-btn-base px-3 py-2 rounded-lg text-sm border flex items-center justify-start transition-all ${artStyle === style.value\n                                            ? 'glass-btn-tone-info border-[var(--glass-stroke-focus)]'\n                                            : 'glass-btn-soft border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] hover:border-[var(--glass-stroke-strong)]'\n                                            }`}\n                                    >\n                                        <span>{style.label}</span>\n                                    </button>\n                                ))}\n                            </div>\n                        </div>\n\n                        {/* 场景描述 */}\n                        <div className=\"space-y-2\">\n                            <label className=\"glass-field-label block\">\n                                {t('modal.locationSummaryLabel')}\n                            </label>\n                            <textarea\n                                value={summary}\n                                onChange={(e) => setSummary(e.target.value)}\n                                placeholder={t('modal.locationSummaryPlaceholder')}\n                                className=\"glass-textarea-base w-full h-40 px-3 py-2 text-sm resize-none\"\n                            />\n                        </div>\n                    </div>\n\n                    {/* 按钮区 */}\n                    <div className=\"flex gap-3 justify-end mt-6 pt-4 border-t border-[var(--glass-stroke-base)]\">\n                        <button\n                            onClick={onClose}\n                            className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm\"\n                            disabled={isSubmitting}\n                        >\n                            {t('common.cancel')}\n                        </button>\n                        <button\n                            onClick={handleSubmit}\n                            disabled={isSubmitting || !name.trim() || !summary.trim()}\n                            className=\"glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm\"\n                        >\n                            {isSubmitting ? (\n                                <TaskStatusInline state={submittingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                            ) : (\n                                <span>{t('modal.addLocation')}</span>\n                            )}\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { useState } from 'react'\nimport { CharacterCard } from './CharacterCard'\nimport { LocationCard } from './LocationCard'\nimport { VoiceCard } from './VoiceCard'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\nimport { SegmentedControl } from '@/components/ui/SegmentedControl'\n\n\n\ninterface Character {\n    id: string\n    name: string\n    folderId: string | null\n    customVoiceUrl: string | null\n    appearances: Array<{\n        id: string\n        appearanceIndex: number\n        changeReason: string\n        description: string | null\n        imageUrl: string | null\n        imageUrls: string[]\n        selectedIndex: number | null\n        effectiveSelectedIndex?: number | null\n        previousImageUrl: string | null\n        previousImageUrls: string[]\n        imageTaskRunning: boolean\n    }>\n}\n\ninterface Location {\n    id: string\n    name: string\n    summary: string | null\n    folderId: string | null\n    images: Array<{\n        id: string\n        imageIndex: number\n        description: string | null\n        imageUrl: string | null\n        previousImageUrl: string | null\n        isSelected: boolean\n        imageTaskRunning: boolean\n    }>\n}\n\ninterface Voice {\n    id: string\n    name: string\n    description: string | null\n    voiceId: string | null\n    voiceType: string\n    customVoiceUrl: string | null\n    voicePrompt: string | null\n    gender: string | null\n    language: string\n    folderId: string | null\n}\n\ninterface AssetGridProps {\n    characters: Character[]\n    locations: Location[]\n    voices: Voice[]\n    loading: boolean\n    onAddCharacter: () => void\n    onAddLocation: () => void\n    onAddVoice: () => void\n    onDownloadAll?: () => void\n    isDownloading?: boolean\n    selectedFolderId: string | null\n    onImageClick?: (url: string) => void\n    onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void\n    onVoiceDesign?: (characterId: string, characterName: string) => void\n    onCharacterEdit?: (character: unknown, appearance: unknown) => void\n    onLocationEdit?: (location: unknown, imageIndex: number) => void\n    onVoiceSelect?: (characterId: string) => void\n}\n\n// 内联 SVG 图标\nconst PlusIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"plus\" className={className} />\n)\n\nexport function AssetGrid({\n    characters,\n    locations,\n    voices,\n    loading,\n    onAddCharacter,\n    onAddLocation,\n    onAddVoice,\n    onDownloadAll,\n    isDownloading,\n    selectedFolderId: _selectedFolderId,\n    onImageClick,\n    onImageEdit,\n    onVoiceDesign,\n    onCharacterEdit,\n    onLocationEdit,\n    onVoiceSelect\n}: AssetGridProps) {\n    const t = useTranslations('assetHub')\n    const loadingState = loading\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n    void _selectedFolderId\n\n    const [filter, setFilter] = useState<'all' | 'character' | 'location' | 'voice'>('all')\n    const [sectionPage, setSectionPage] = useState<{ character: number; location: number; voice: number }>({\n        character: 1,\n        location: 1,\n        voice: 1,\n    })\n\n    const pageSize = 40\n    const paginate = <T,>(rows: T[], page: number) => {\n        const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))\n        const safePage = Math.min(Math.max(page, 1), totalPages)\n        const start = (safePage - 1) * pageSize\n        return {\n            items: rows.slice(start, start + pageSize),\n            page: safePage,\n            totalPages,\n        }\n    }\n\n    const setPage = (type: 'character' | 'location' | 'voice', page: number) => {\n        setSectionPage((prev) => ({ ...prev, [type]: page }))\n    }\n\n    const charactersPage = paginate(characters, sectionPage.character)\n    const locationsPage = paginate(locations, sectionPage.location)\n    const voicesPage = paginate(voices, sectionPage.voice)\n\n    const renderPagination = (type: 'character' | 'location' | 'voice', page: number, totalPages: number) => {\n        if (totalPages <= 1) return null\n        return (\n            <div className=\"mt-4 flex items-center justify-end gap-2\">\n                <button\n                    onClick={() => setPage(type, page - 1)}\n                    disabled={page <= 1}\n                    className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed\"\n                >\n                    {t('pagination.previous')}\n                </button>\n                <span className=\"text-xs text-[var(--glass-text-tertiary)]\">\n                    {page} / {totalPages}\n                </span>\n                <button\n                    onClick={() => setPage(type, page + 1)}\n                    disabled={page >= totalPages}\n                    className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed\"\n                >\n                    {t('pagination.next')}\n                </button>\n            </div>\n        )\n    }\n\n    if (loading) {\n        return (\n            <div className=\"flex-1 flex items-center justify-center py-20\">\n                <TaskStatusInline state={loadingState} />\n            </div>\n        )\n    }\n\n    const isEmpty = characters.length === 0 && locations.length === 0 && voices.length === 0\n\n    const tabs = [\n        { id: 'all', label: t('allAssets') },\n        { id: 'character', label: t('characters') },\n        { id: 'location', label: t('locations') },\n        { id: 'voice', label: t('voices') },\n    ]\n\n    return (\n        <div className=\"flex-1 min-w-0\">\n            {/* Header: 筛选 Tab + 操作按钮 */}\n            <div className=\"flex items-center justify-between mb-6\">\n                {/* 左侧筛选 */}\n                {(() => {\n                    return (\n                        <SegmentedControl\n                            options={tabs.map(tab => ({ value: tab.id, label: tab.label }))}\n                            value={filter}\n                            onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'voice')}\n                        />\n                    )\n                })()}\n\n                {/* 右侧操作按钮 */}\n                <div className=\"flex items-center gap-3\">\n                    {onDownloadAll && (\n                        <button\n                            onClick={onDownloadAll}\n                            disabled={isDownloading || isEmpty}\n                            title={t('downloadAllTitle')}\n                            className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed\"\n                        >\n                            <AppIcon name={isDownloading ? 'refresh' : 'download'} className={`w-4 h-4 ${isDownloading ? 'animate-spin' : ''}`} />\n                            <span>{isDownloading ? t('downloading') : t('downloadAll')}</span>\n                        </button>\n                    )}\n                    <button\n                        onClick={onAddCharacter}\n                        className=\"glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm\"\n                    >\n                        <PlusIcon className=\"w-4 h-4\" />\n                        <span>{t('addCharacter')}</span>\n                    </button>\n                    <button\n                        onClick={onAddLocation}\n                        className=\"glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm\"\n                    >\n                        <PlusIcon className=\"w-4 h-4\" />\n                        <span>{t('addLocation')}</span>\n                    </button>\n                    <button\n                        onClick={onAddVoice}\n                        className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg text-sm\"\n                    >\n                        <PlusIcon className=\"w-4 h-4\" />\n                        <span>{t('addVoice')}</span>\n                    </button>\n                </div>\n            </div>\n\n            {isEmpty ? (\n                /* 空状态 */\n                <div className=\"glass-surface rounded-xl p-12 text-center\">\n                    <div className=\"w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--glass-bg-muted)] flex items-center justify-center\">\n                        <PlusIcon className=\"w-8 h-8 text-[var(--glass-text-tertiary)]\" />\n                    </div>\n                    <p className=\"text-[var(--glass-text-secondary)] mb-2\">{t('emptyState')}</p>\n                    <p className=\"text-sm text-[var(--glass-text-tertiary)]\">{t('emptyStateHint')}</p>\n                </div>\n            ) : (\n                <div className=\"space-y-8\">\n                    {/* 角色区块 */}\n                    {(filter === 'all' || filter === 'character') && characters.length > 0 && (\n                        <section>\n                            <h2 className=\"text-sm font-semibold text-[var(--glass-text-primary)] mb-3 flex items-center gap-2\">\n                                {t('characters')}\n                                <span className=\"glass-chip glass-chip-neutral px-2 py-0.5\">{characters.length}</span>\n                            </h2>\n                            <div className=\"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4\">\n                                {charactersPage.items.map((character) => (\n                                    <CharacterCard\n                                        key={character.id}\n                                        character={character}\n                                        onImageClick={onImageClick}\n                                        onImageEdit={onImageEdit}\n                                        onVoiceDesign={onVoiceDesign}\n                                        onEdit={onCharacterEdit}\n                                        onVoiceSelect={onVoiceSelect}\n                                    />\n                                ))}\n                            </div>\n                            {renderPagination('character', charactersPage.page, charactersPage.totalPages)}\n                        </section>\n                    )}\n\n                    {/* 场景区块 */}\n                    {(filter === 'all' || filter === 'location') && locations.length > 0 && (\n                        <section>\n                            <h2 className=\"text-sm font-semibold text-[var(--glass-text-primary)] mb-3 flex items-center gap-2\">\n                                {t('locations')}\n                                <span className=\"glass-chip glass-chip-neutral px-2 py-0.5\">{locations.length}</span>\n                            </h2>\n                            <div className=\"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4\">\n                                {locationsPage.items.map((location) => (\n                                    <LocationCard\n                                        key={location.id}\n                                        location={location}\n                                        onImageClick={onImageClick}\n                                        onImageEdit={onImageEdit}\n                                        onEdit={onLocationEdit}\n                                    />\n                                ))}\n                            </div>\n                            {renderPagination('location', locationsPage.page, locationsPage.totalPages)}\n                        </section>\n                    )}\n\n                    {/* 音色区块 */}\n                    {(filter === 'all' || filter === 'voice') && voices.length > 0 && (\n                        <section>\n                            <h2 className=\"text-sm font-semibold text-[var(--glass-text-primary)] mb-3 flex items-center gap-2\">\n                                {t('voices')}\n                                <span className=\"glass-chip glass-chip-info px-2 py-0.5\">{voices.length}</span>\n                            </h2>\n                            <div className=\"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4\">\n                                {voicesPage.items.map((voice) => (\n                                    <VoiceCard\n                                        key={voice.id}\n                                        voice={voice}\n                                    />\n                                ))}\n                            </div>\n                            {renderPagination('voice', voicesPage.page, voicesPage.totalPages)}\n                        </section>\n                    )}\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/CharacterCard.tsx",
    "content": "'use client'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { resolveErrorDisplay } from '@/lib/errors/display'\n\nimport { useRef, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport {\n    useGenerateCharacterImage,\n    useSelectCharacterImage,\n    useUndoCharacterImage,\n    useUploadCharacterImage,\n    useDeleteCharacter,\n    useDeleteCharacterAppearance,\n    useUploadCharacterVoice\n} from '@/lib/query/mutations'\nimport VoiceSettings from './VoiceSettings'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport TaskStatusOverlay from '@/components/task/TaskStatusOverlay'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\nimport { getImageGenerationCountOptions } from '@/lib/image-generation/count'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface Appearance {\n    id: string\n    appearanceIndex: number\n    changeReason: string\n    artStyle?: string | null\n    description: string | null\n    imageUrl: string | null\n    imageUrls: string[]\n    selectedIndex: number | null\n    previousImageUrl: string | null\n    previousImageUrls: string[]\n    imageTaskRunning: boolean\n    lastError?: { code: string; message: string } | null\n}\n\ninterface Character {\n    id: string\n    name: string\n    folderId: string | null\n    customVoiceUrl: string | null\n    appearances: Appearance[]\n}\n\ninterface CharacterCardProps {\n    character: Character\n    onImageClick?: (url: string) => void\n    onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void\n    onVoiceDesign?: (characterId: string, characterName: string) => void\n    onEdit?: (character: Character, appearance: Appearance) => void\n    onVoiceSelect?: (characterId: string) => void\n}\n\nexport function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDesign, onEdit, onVoiceSelect }: CharacterCardProps) {\n    // 🔥 使用 mutation hooks\n    const generateImage = useGenerateCharacterImage()\n    const selectImage = useSelectCharacterImage()\n    const undoImage = useUndoCharacterImage()\n    const uploadImage = useUploadCharacterImage()\n    const deleteCharacter = useDeleteCharacter()\n    const deleteAppearance = useDeleteCharacterAppearance()\n    const uploadVoice = useUploadCharacterVoice()\n\n    const t = useTranslations('assetHub')\n    const tAssets = useTranslations('assets')\n    const { count: generationCount, setCount: setGenerationCount } = useImageGenerationCount('character')\n    const fileInputRef = useRef<HTMLInputElement>(null)\n    const voiceInputRef = useRef<HTMLInputElement>(null)\n\n    const [activeAppearance, setActiveAppearance] = useState(0)\n    const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\n    const [showDeleteMenu, setShowDeleteMenu] = useState(false)\n    const latestSelectRequestRef = useRef(0)\n\n    // 计算属性\n    const appearance = character.appearances[activeAppearance] || character.appearances[0]\n    const isPrimaryAppearance = appearance?.appearanceIndex === PRIMARY_APPEARANCE_INDEX\n    const appearanceCount = character.appearances.length\n\n    // URL 验证函数\n    const isValidUrl = (url: string | null | undefined): boolean => {\n        if (!url || url.trim() === '') return false\n        if (url.startsWith('/')) return true\n        if (url.startsWith('data:') || url.startsWith('blob:')) return true\n        try { new URL(url); return true } catch { return false }\n    }\n\n    const imageUrls = appearance?.imageUrls || []\n    const hasMultipleImages = imageUrls.filter(u => isValidUrl(u)).length > 1\n    const effectiveSelectedIndex: number | null = appearance?.selectedIndex ?? null\n    const currentImageUrl = appearance?.imageUrl || (effectiveSelectedIndex !== null ? imageUrls[effectiveSelectedIndex] : null) || imageUrls.find(u => u) || null\n    const hasPreviousVersion = !!(appearance?.previousImageUrl || (appearance?.previousImageUrls && appearance.previousImageUrls.length > 0))\n\n    const displayImageUrl = isValidUrl(currentImageUrl) ? currentImageUrl : null\n    const serverTaskRunning = !!appearance?.imageTaskRunning\n    const transientSubmitting = generateImage.isPending\n    const isAppearanceTaskRunning = serverTaskRunning || transientSubmitting\n    const taskErrorDisplay = !isAppearanceTaskRunning && appearance?.lastError\n        ? resolveErrorDisplay(appearance.lastError)\n        : null\n    const displayTaskPresentation = isAppearanceTaskRunning\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: displayImageUrl ? 'process' : 'generate',\n            resource: 'image',\n            hasOutput: !!displayImageUrl,\n        })\n        : null\n    const selectImageRunningState = selectImage.isPending\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'process',\n            resource: 'image',\n            hasOutput: !!displayImageUrl,\n        })\n        : null\n\n    // 生成图片\n    const handleGenerate = (count = generationCount) => {\n        generateImage.mutate(\n            {\n                characterId: character.id,\n                appearanceIndex: appearance.appearanceIndex,\n                artStyle: appearance.artStyle || undefined,\n                count,\n            },\n            { onError: (error) => alert(error.message || t('generateFailed')) }\n        )\n    }\n\n    // 选择图片（依赖 query 缓存乐观更新）\n    const handleSelectImage = (imageIndex: number | null) => {\n        if (imageIndex === effectiveSelectedIndex) return\n        const requestId = latestSelectRequestRef.current + 1\n        latestSelectRequestRef.current = requestId\n        selectImage.mutate({\n            characterId: character.id,\n            appearanceIndex: appearance.appearanceIndex,\n            imageIndex,\n            confirm: false\n        }, {\n            onError: (error) => {\n                if (latestSelectRequestRef.current !== requestId) return\n                alert(error.message || t('selectFailed'))\n            }\n        })\n    }\n\n    // 确认选择\n    const handleConfirmSelection = () => {\n        const requestId = latestSelectRequestRef.current + 1\n        latestSelectRequestRef.current = requestId\n        selectImage.mutate({\n            characterId: character.id,\n            appearanceIndex: appearance.appearanceIndex,\n            imageIndex: effectiveSelectedIndex,\n            confirm: true\n        }, {\n            onError: (error) => {\n                if (latestSelectRequestRef.current !== requestId) return\n                alert(error.message || t('selectFailed'))\n            }\n        })\n    }\n\n    // 撤回\n    const handleUndo = () => {\n        undoImage.mutate({ characterId: character.id, appearanceIndex: appearance.appearanceIndex })\n    }\n\n    // 上传图片\n    const handleUpload = () => {\n        const file = fileInputRef.current?.files?.[0]\n        if (!file) return\n\n        uploadImage.mutate(\n            {\n                file,\n                characterId: character.id,\n                appearanceIndex: appearance.appearanceIndex,\n                labelText: `${character.name} - ${appearance.changeReason}`,\n                imageIndex: effectiveSelectedIndex ?? undefined\n            },\n            {\n                onError: (error) => alert(error.message || t('uploadFailed')),\n                onSettled: () => {\n                    if (fileInputRef.current) fileInputRef.current.value = ''\n                }\n            }\n        )\n    }\n\n    // 删除角色\n    const handleDelete = () => {\n        deleteCharacter.mutate(character.id, {\n            onSettled: () => setShowDeleteConfirm(false)\n        })\n    }\n\n    // 删除子形象\n    const handleDeleteAppearance = () => {\n        deleteAppearance.mutate(\n            { characterId: character.id, appearanceIndex: appearance.appearanceIndex },\n            {\n                onSuccess: () => setActiveAppearance(0),\n                onSettled: () => setShowDeleteMenu(false)\n            }\n        )\n    }\n\n    // 上传音色\n    const handleUploadVoice = () => {\n        const file = voiceInputRef.current?.files?.[0]\n        if (!file) return\n\n        uploadVoice.mutate(\n            { file, characterId: character.id },\n            {\n                onSettled: () => {\n                    if (voiceInputRef.current) voiceInputRef.current.value = ''\n                }\n            }\n        )\n    }\n\n    // 多图选择模式\n    if (hasMultipleImages) {\n        return (\n            <div className=\"col-span-3 glass-surface p-4 relative\">\n                {/* 隐藏输入 */}\n                <input ref={fileInputRef} type=\"file\" accept=\"image/*\" onChange={handleUpload} className=\"hidden\" />\n                <input ref={voiceInputRef} type=\"file\" accept=\"audio/*\" onChange={handleUploadVoice} className=\"hidden\" />\n\n                {/* 顶部：名字 + 操作 */}\n                <div className=\"flex items-center justify-between mb-4\">\n                    <div className=\"flex items-center gap-2\">\n                        <span className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">{character.name}</span>\n                        <span className=\"glass-chip glass-chip-neutral px-2 py-0.5 text-xs\">{appearance.changeReason}</span>\n                        {isPrimaryAppearance ? (\n                            <span className=\"glass-chip glass-chip-success px-2 py-0.5 text-xs\">{tAssets('character.primary')}</span>\n                        ) : (\n                            <span className=\"glass-chip glass-chip-info px-2 py-0.5 text-xs\">{tAssets('character.secondary')}</span>\n                        )}\n                    </div>\n                    <div className=\"flex items-center gap-1\">\n                        <ImageGenerationInlineCountButton\n                            prefix={isAppearanceTaskRunning ? (\n                                <TaskStatusInline state={displayTaskPresentation} className=\"[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]\" />\n                            ) : (\n                                <AppIcon name=\"refresh\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n                            )}\n                            suffix={null}\n                            value={generationCount}\n                            options={getImageGenerationCountOptions('character')}\n                            onValueChange={setGenerationCount}\n                            onClick={() => {\n                                _ulogInfo('[CharacterCard] 多图模式 - 重新生成按钮点击, characterId:', character.id, 'appearanceCount:', appearanceCount)\n                                handleGenerate(generationCount)\n                            }}\n                            disabled={isAppearanceTaskRunning}\n                            ariaLabel={tAssets('image.selectCount')}\n                            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\"\n                            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\"\n                        />\n                        {hasPreviousVersion && (\n                            <button onClick={handleUndo} className=\"glass-btn-base glass-btn-soft h-6 w-6 rounded-md\" title={tAssets('image.undo')}>\n                                <AppIcon name=\"sparkles\" className=\"w-4 h-4 text-[var(--glass-tone-warning-fg)]\" />\n                            </button>\n                        )}\n                        <button onClick={(e) => {\n                            e.stopPropagation()\n                            _ulogInfo('[CharacterCard] 多图模式 - 删除按钮点击, characterId:', character.id, 'appearanceCount:', appearanceCount, 'showDeleteMenu:', showDeleteMenu)\n                            if (appearanceCount <= 1) {\n                                setShowDeleteConfirm(true)\n                                return\n                            }\n                            setShowDeleteMenu(!showDeleteMenu)\n                        }} className=\"glass-btn-base glass-btn-soft h-6 w-6 rounded-md\">\n                            <AppIcon name=\"trash\" className=\"w-4 h-4 text-[var(--glass-tone-danger-fg)]\" />\n                        </button>\n                    </div>\n                </div>\n\n                {/* 任务失败错误提示 */}\n                {taskErrorDisplay && !isAppearanceTaskRunning && (\n                    <div className=\"flex items-center gap-2 mb-3 p-2 rounded-lg bg-[var(--glass-danger-ring)] text-[var(--glass-tone-danger-fg)]\">\n                        <AppIcon name=\"alert\" className=\"w-4 h-4 shrink-0\" />\n                        <span className=\"text-xs line-clamp-2\">{taskErrorDisplay.message}</span>\n                    </div>\n                )}\n\n                {/* 图片列表 */}\n                <div className=\"grid grid-cols-3 gap-3\">\n                    {imageUrls.map((url, index) => {\n                        if (!isValidUrl(url)) return null\n                        const validUrl = url as string\n                        const isSelected = effectiveSelectedIndex === index\n                        return (\n                            <div key={index} className=\"relative group/thumb\">\n                                <div\n                                    onClick={() => onImageClick?.(validUrl)}\n                                    className={`rounded-lg overflow-hidden border-2 cursor-zoom-in transition-all ${isSelected ? 'border-[var(--glass-stroke-success)] ring-2 ring-[var(--glass-success-ring)]' : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'}`}\n                                >\n                                    <MediaImageWithLoading\n                                        src={validUrl}\n                                        alt={`${character.name} ${index + 1}`}\n                                        containerClassName=\"w-full min-h-[96px]\"\n                                        className=\"w-full h-auto object-contain\"\n                                    />\n                                    <div className={`absolute bottom-2 left-2 text-xs px-2 py-0.5 rounded ${isSelected ? 'glass-chip glass-chip-success' : 'glass-chip glass-chip-neutral'}`}>\n                                        {tAssets('image.optionNumber', { number: index + 1 })}\n                                    </div>\n                                </div>\n                                <button\n                                    onClick={(e) => { e.stopPropagation(); handleSelectImage(isSelected ? null : index) }}\n                                    className={`absolute top-2 right-2 glass-btn-base w-7 h-7 rounded-full flex items-center justify-center ${isSelected ? 'glass-btn-tone-success' : 'glass-btn-secondary'}`}\n                                >\n                                    <AppIcon name=\"check\" className=\"w-4 h-4\" />\n                                </button>\n                            </div>\n                        )\n                    })}\n                </div>\n\n                {/* 确认按钮 */}\n                {effectiveSelectedIndex !== null && (\n                    <div className=\"mt-4 flex justify-end\">\n                        <button onClick={handleConfirmSelection} disabled={selectImage.isPending} className=\"glass-btn-base glass-btn-tone-success px-4 py-2 rounded-lg flex items-center gap-2 text-sm\">\n                            {selectImage.isPending ? (\n                                <TaskStatusInline state={selectImageRunningState} className=\"text-white [&>span]:sr-only [&_svg]:text-white\" />\n                            ) : (\n                                <AppIcon name=\"check\" className=\"w-4 h-4\" />\n                            )}\n                            {tAssets('image.confirmOption', { number: effectiveSelectedIndex + 1 })}\n                        </button>\n                    </div>\n                )}\n\n                {/* 音色设置 */}\n                <VoiceSettings\n                    characterId={character.id}\n                    characterName={character.name}\n                    customVoiceUrl={character.customVoiceUrl}\n                    onVoiceDesign={onVoiceDesign}\n                    onVoiceSelect={onVoiceSelect}\n                    compact={true}\n                />\n\n                {/* 删除菜单 */}\n                {showDeleteMenu && appearanceCount > 1 && (\n                    <>\n                        <div className=\"fixed inset-0 z-10\" onClick={() => setShowDeleteMenu(false)} />\n                        <div className=\"absolute right-4 top-12 z-20 glass-surface-modal py-1 min-w-[120px]\">\n                            <button onClick={handleDeleteAppearance} className=\"glass-btn-base glass-btn-soft w-full justify-start rounded-none px-3 py-1.5 text-left text-xs\">{tAssets('image.deleteThis')}</button>\n                            <button onClick={() => { setShowDeleteMenu(false); setShowDeleteConfirm(true) }} className=\"glass-btn-base glass-btn-soft w-full justify-start rounded-none px-3 py-1.5 text-left text-xs text-[var(--glass-tone-danger-fg)]\">{tAssets('character.deleteWhole')}</button>\n                        </div>\n                    </>\n                )}\n\n                {/* 删除确认对话框 - 多图模式也需要 */}\n                {showDeleteConfirm && (\n                    <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50\">\n                        <div className=\"glass-surface-modal p-4 m-4 max-w-sm\">\n                            <p className=\"mb-4 text-sm text-[var(--glass-text-primary)]\">{t('confirmDeleteCharacter')}</p>\n                            <div className=\"flex gap-2 justify-end\">\n                                <button onClick={() => setShowDeleteConfirm(false)} className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm\">{t('cancel')}</button>\n                                <button onClick={handleDelete} className=\"glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm\">{t('delete')}</button>\n                            </div>\n                        </div>\n                    </div>\n                )}\n            </div>\n        )\n    }\n\n    // 单图模式\n    return (\n        <div className=\"glass-surface overflow-hidden relative group\">\n            <input ref={fileInputRef} type=\"file\" accept=\"image/*\" onChange={handleUpload} className=\"hidden\" />\n            <input ref={voiceInputRef} type=\"file\" accept=\"audio/*\" onChange={handleUploadVoice} className=\"hidden\" />\n\n            {/* 图片区域 */}\n            <div className=\"relative bg-[var(--glass-bg-muted)] min-h-[100px]\">\n                {displayImageUrl ? (\n                    <>\n                        <MediaImageWithLoading\n                            src={displayImageUrl}\n                            alt={character.name}\n                            containerClassName=\"w-full min-h-[120px]\"\n                            className=\"w-full h-auto object-contain cursor-zoom-in\"\n                            onClick={() => onImageClick?.(displayImageUrl)}\n                        />\n                        {/* 操作按钮 - 非生成时显示 */}\n                        {!isAppearanceTaskRunning && (\n                            <div className=\"absolute top-2 left-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity\">\n                                <button onClick={() => fileInputRef.current?.click()} disabled={uploadImage.isPending} className=\"glass-btn-base glass-btn-secondary h-7 w-7 rounded-full\">\n                                    <AppIcon name=\"upload\" className=\"w-4 h-4 text-[var(--glass-tone-success-fg)]\" />\n                                </button>\n                                <button onClick={() => onImageEdit?.('character', character.id, character.name, effectiveSelectedIndex ?? 0, appearance.appearanceIndex)} className=\"glass-btn-base glass-btn-tone-info h-7 w-7 rounded-full\">\n                                    <AppIcon name=\"edit\" className=\"w-4 h-4\" />\n                                </button>\n                        <button onClick={() => handleGenerate()} className=\"glass-btn-base glass-btn-secondary h-7 w-7 rounded-full\">\n                                    <AppIcon name=\"refresh\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n                                </button>\n                                {hasPreviousVersion && (\n                                    <button onClick={handleUndo} className=\"glass-btn-base glass-btn-secondary h-7 w-7 rounded-full\">\n                                        <AppIcon name=\"sparkles\" className=\"w-4 h-4 text-[var(--glass-tone-warning-fg)]\" />\n                                    </button>\n                                )}\n                            </div>\n                        )}\n                    </>\n                ) : (\n                    <div className=\"flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]\">\n                        <AppIcon name=\"image\" className=\"w-12 h-12 mb-3\" />\n                        <ImageGenerationInlineCountButton\n                            prefix={<span>{tAssets('image.generateCountPrefix')}</span>}\n                            suffix={<span>{tAssets('image.generateCountSuffix')}</span>}\n                            value={generationCount}\n                            options={getImageGenerationCountOptions('character')}\n                            onValueChange={setGenerationCount}\n                            onClick={() => handleGenerate(generationCount)}\n                            ariaLabel={tAssets('image.selectCount')}\n                            className=\"glass-btn-base glass-btn-primary flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg\"\n                            selectClassName=\"appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-current outline-none cursor-pointer leading-none transition-colors\"\n                        />\n                    </div>\n                )}\n                {isAppearanceTaskRunning && (\n                    <TaskStatusOverlay state={displayTaskPresentation} />\n                )}\n                {taskErrorDisplay && !isAppearanceTaskRunning && (\n                    <div className=\"absolute inset-0 flex flex-col items-center justify-center bg-[var(--glass-danger-ring)] text-[var(--glass-tone-danger-fg)] p-3 gap-1\">\n                        <AppIcon name=\"alert\" className=\"w-6 h-6\" />\n                        <span className=\"text-xs text-center font-medium line-clamp-3\">{taskErrorDisplay.message}</span>\n                    </div>\n                )}\n            </div>\n\n            {/* 信息区域 */}\n            <div className=\"p-3\">\n                <div className=\"flex items-center justify-between\">\n                    <h3 className=\"font-medium text-[var(--glass-text-primary)] text-sm truncate\">{character.name}</h3>\n                    <div className=\"flex items-center gap-1\">\n                        {/* 编辑按钮 */}\n                        <button\n                            onClick={() => onEdit?.(character, appearance)}\n                            className=\"glass-btn-base glass-btn-soft h-6 w-6 rounded-md opacity-0 group-hover:opacity-100\"\n                            title={tAssets('video.panelCard.editPrompt')}\n                        >\n                            <AppIcon name=\"edit\" className=\"w-4 h-4 text-[var(--glass-text-secondary)]\" />\n                        </button>\n                        {/* 删除按钮 */}\n                        <button onClick={() => appearanceCount <= 1 ? setShowDeleteConfirm(true) : setShowDeleteMenu(!showDeleteMenu)} className=\"glass-btn-base glass-btn-soft h-6 w-6 rounded-md text-[var(--glass-tone-danger-fg)] opacity-0 group-hover:opacity-100\">\n                            <AppIcon name=\"trash\" className=\"w-4 h-4\" />\n                        </button>\n                    </div>\n                </div>\n\n                {/* 形象切换 */}\n                {appearanceCount > 1 && (\n                    <div className=\"flex gap-1 mt-2 overflow-x-auto\">\n                        {character.appearances.map((app, index) => (\n                            <button key={app.id} onClick={() => setActiveAppearance(index)} className={`glass-btn-base px-2 py-0.5 text-xs rounded-full whitespace-nowrap ${index === activeAppearance ? 'glass-btn-primary' : 'glass-btn-soft text-[var(--glass-text-secondary)]'}`}>\n                                {app.changeReason || `形象 ${app.appearanceIndex}`}\n                            </button>\n                        ))}\n                    </div>\n                )}\n\n                {appearance?.description && <p className=\"mt-2 text-xs text-[var(--glass-text-secondary)] line-clamp-2\">{appearance.description}</p>}\n\n                {/* 音色设置 */}\n                <VoiceSettings\n                    characterId={character.id}\n                    characterName={character.name}\n                    customVoiceUrl={character.customVoiceUrl}\n                    onVoiceDesign={onVoiceDesign}\n                    onVoiceSelect={onVoiceSelect}\n                    compact={true}\n                />\n            </div>\n\n            {/* 删除确认 */}\n            {showDeleteConfirm && (\n                <div className=\"absolute inset-0 glass-overlay flex items-center justify-center z-20\">\n                    <div className=\"glass-surface-modal p-4 m-4\">\n                        <p className=\"mb-4 text-sm text-[var(--glass-text-primary)]\">{t('confirmDeleteCharacter')}</p>\n                        <div className=\"flex gap-2 justify-end\">\n                            <button onClick={() => setShowDeleteConfirm(false)} className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm\">{t('cancel')}</button>\n                            <button onClick={handleDelete} className=\"glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm\">{t('delete')}</button>\n                        </div>\n                    </div>\n                </div>\n            )}\n\n            {/* 删除菜单 */}\n            {showDeleteMenu && appearanceCount > 1 && (\n                <>\n                    <div className=\"fixed inset-0 z-10\" onClick={() => setShowDeleteMenu(false)} />\n                    <div className=\"absolute right-3 top-auto bottom-16 z-20 glass-surface-modal py-1 min-w-[120px]\">\n                        <button onClick={handleDeleteAppearance} className=\"glass-btn-base glass-btn-soft w-full justify-start rounded-none px-3 py-1.5 text-left text-xs\">{tAssets('image.deleteThis')}</button>\n                        <button onClick={() => { setShowDeleteMenu(false); setShowDeleteConfirm(true) }} className=\"glass-btn-base glass-btn-soft w-full justify-start rounded-none px-3 py-1.5 text-left text-xs text-[var(--glass-tone-danger-fg)]\">{tAssets('character.deleteWhole')}</button>\n                    </div>\n                </>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/CharacterEditModal.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\n\n/**\n * 资产中心 - 角色形象编辑弹窗\n * 与项目级资产库的 CharacterEditModal 保持一致\n */\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport { shouldShowError } from '@/lib/error-utils'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport {\n    useRefreshGlobalAssets,\n    useUpdateCharacterName,\n    useAiModifyCharacterDescription,\n    useUpdateCharacterAppearanceDescription,\n} from '@/lib/query/hooks'\n\ninterface CharacterEditModalProps {\n    characterId: string\n    characterName: string\n    appearanceIndex: number\n    changeReason: string\n    description: string\n    onClose: () => void\n    onSave: () => void  // 触发生成图片\n}\n\nexport function CharacterEditModal({\n    characterId,\n    characterName,\n    appearanceIndex,\n    changeReason,\n    description,\n    onClose,\n    onSave\n}: CharacterEditModalProps) {\n    // 🔥 使用 React Query\n    const onRefresh = useRefreshGlobalAssets()\n    const updateName = useUpdateCharacterName()\n    const modifyDescription = useAiModifyCharacterDescription()\n    const updateAppearanceDescription = useUpdateCharacterAppearanceDescription()\n    const t = useTranslations('assets')\n\n    const [editingName, setEditingName] = useState(characterName)\n    const [editingDescription, setEditingDescription] = useState(description)\n    const [aiModifyInstruction, setAiModifyInstruction] = useState('')\n    const [isAiModifying, setIsAiModifying] = useState(false)\n    const [isSaving, setIsSaving] = useState(false)\n    const aiModifyingState = isAiModifying\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'modify',\n            resource: 'image',\n            hasOutput: true,\n        })\n        : null\n    const savingState = isSaving\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'modify',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n\n    // AI 修改描述\n    const handleAiModify = async () => {\n        if (!aiModifyInstruction.trim()) return\n\n        try {\n            setIsAiModifying(true)\n            const data = await modifyDescription.mutateAsync({\n                characterId,\n                appearanceIndex,\n                currentDescription: editingDescription,\n                modifyInstruction: aiModifyInstruction,\n            })\n            setEditingDescription(data.modifiedDescription ?? '')\n            setAiModifyInstruction('')\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                const message = error instanceof Error ? error.message : String(error)\n                alert(t('modal.modifyFailed') + ': ' + message)\n            }\n        } finally {\n            setIsAiModifying(false)\n        }\n    }\n\n    // 保存名字\n    const handleSaveName = () => {\n        if (!editingName.trim() || editingName === characterName) return\n\n        updateName.mutate(\n            { characterId, name: editingName.trim() },\n            {\n                onError: (error) => {\n                    if (shouldShowError(error)) {\n                        alert(t('modal.saveName') + t('errors.failed'))\n                    }\n                }\n            }\n        )\n    }\n\n    // 仅保存（不生成图片）\n    const handleSaveOnly = async () => {\n        try {\n            setIsSaving(true)\n\n            // 如果名字变了，先保存名字\n            if (editingName.trim() !== characterName) {\n                await updateName.mutateAsync({ characterId, name: editingName.trim() })\n            }\n\n            // 保存描述\n            await updateAppearanceDescription.mutateAsync({\n                characterId,\n                appearanceIndex,\n                description: editingDescription,\n            })\n\n            onRefresh()\n            onClose()\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                alert(t('errors.saveFailed'))\n            }\n        } finally {\n            setIsSaving(false)\n        }\n    }\n\n    // 保存并生成图片\n    const handleSaveAndGenerate = async () => {\n        const descToSave = editingDescription\n        const nameToSave = editingName.trim()\n\n        // 立即关闭弹窗\n        onClose()\n\n            // 后台执行保存和生成\n            ; (async () => {\n                try {\n                    // 如果名字变了，先保存名字\n                    if (nameToSave !== characterName) {\n                        await updateName.mutateAsync({ characterId, name: nameToSave })\n                    }\n\n                    // 保存描述\n                    await updateAppearanceDescription.mutateAsync({\n                        characterId,\n                        appearanceIndex,\n                        description: descToSave,\n                    })\n\n                    // 触发生成\n                    onSave()\n                    onRefresh()\n                } catch (error: unknown) {\n                    _ulogError('保存并生成失败:', error)\n                    if (shouldShowError(error)) {\n                        alert(t('errors.saveFailed'))\n                    }\n                }\n            })()\n    }\n\n    return (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4\">\n            <div className=\"glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-y-auto\">\n                <div className=\"p-6 space-y-4\">\n                    {/* 标题 */}\n                    <div className=\"flex items-center justify-between\">\n                        <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                            {t('modal.editCharacter')} - {characterName}\n                        </h3>\n                        <button onClick={onClose} className=\"glass-btn-base glass-btn-soft h-8 w-8 rounded-full text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]\">\n                            <AppIcon name=\"close\" className=\"w-6 h-6\" />\n                        </button>\n                    </div>\n\n                    {/* 角色名字编辑 */}\n                    <div className=\"space-y-2\">\n                        <label className=\"glass-field-label block\">\n                            {t('character.name')}\n                        </label>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={editingName}\n                                onChange={(e) => setEditingName(e.target.value)}\n                                className=\"glass-input-base flex-1 px-3 py-2\"\n                                placeholder={t('modal.namePlaceholder')}\n                            />\n                            {editingName !== characterName && (\n                                <button\n                                    onClick={handleSaveName}\n                                    disabled={updateName.isPending || !editingName.trim()}\n                                    className=\"glass-btn-base glass-btn-tone-success px-3 py-2 rounded-lg text-sm whitespace-nowrap\"\n                                >\n                                    {updateName.isPending ? t('smartImport.preview.saving') : t('modal.saveName')}\n                                </button>\n                            )}\n                        </div>\n                    </div>\n\n                    {/* 形象标识 */}\n                    <div className=\"text-sm text-[var(--glass-text-secondary)]\">\n                        {t('character.appearance')}: <span className=\"font-medium text-[var(--glass-text-primary)]\">{changeReason}</span>\n                    </div>\n\n                    {/* AI 修改区域 */}\n                    <div className=\"space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]\">\n                        <label className=\"glass-field-label block flex items-center gap-2\">\n                            <AppIcon name=\"bolt\" className=\"w-4 h-4\" />\n                            {t('modal.smartModify')}\n                        </label>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={aiModifyInstruction}\n                                onChange={(e) => setAiModifyInstruction(e.target.value)}\n                                placeholder={t('modal.modifyPlaceholderCharacter')}\n                                className=\"glass-input-base flex-1 px-3 py-2\"\n                                disabled={isAiModifying}\n                                onKeyDown={(e) => {\n                                    if (e.key === 'Enter' && !e.shiftKey) {\n                                        e.preventDefault()\n                                        handleAiModify()\n                                    }\n                                }}\n                            />\n                            <button\n                                onClick={handleAiModify}\n                                disabled={isAiModifying || !aiModifyInstruction.trim()}\n                                className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap\"\n                            >\n                                {isAiModifying ? (\n                                    <TaskStatusInline state={aiModifyingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                                ) : (\n                                    <>\n                                        <AppIcon name=\"bolt\" className=\"w-4 h-4\" />\n                                        {t('modal.smartModify')}\n                                    </>\n                                )}\n                            </button>\n                        </div>\n                        <p className=\"glass-field-hint\">\n                            {t('modal.aiTipSub')}\n                        </p>\n                    </div>\n\n                    {/* 描述编辑 */}\n                    <div className=\"space-y-2\">\n                        <label className=\"glass-field-label block\">\n                            {t('modal.appearancePrompt')}\n                        </label>\n                        <textarea\n                            value={editingDescription}\n                            onChange={(e) => setEditingDescription(e.target.value)}\n                            className=\"glass-textarea-base w-full h-64 px-3 py-2 resize-none\"\n                            placeholder={t('modal.descPlaceholder')}\n                            disabled={isAiModifying}\n                        />\n                    </div>\n\n                    {/* 操作按钮 */}\n                    <div className=\"flex gap-3 justify-end\">\n                        <button\n                            onClick={onClose}\n                            className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg\"\n                            disabled={isSaving}\n                        >\n                            {t('common.cancel')}\n                        </button>\n                        <button\n                            onClick={handleSaveOnly}\n                            disabled={isSaving || !editingDescription.trim()}\n                            className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                        >\n                            {isSaving ? (\n                                <TaskStatusInline state={savingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                            ) : (\n                                t('modal.saveOnly')\n                            )}\n                        </button>\n                        <button\n                            onClick={handleSaveAndGenerate}\n                            disabled={isSaving || !editingDescription.trim()}\n                            className=\"glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                        >\n                            {t('modal.saveAndGenerate')}\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    )\n}\n\nexport default CharacterEditModal\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/FolderModal.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface Folder {\n    id: string\n    name: string\n}\n\ninterface FolderModalProps {\n    folder: Folder | null\n    onClose: () => void\n    onSave: (name: string) => void\n}\n\n// 内联 SVG 图标\nconst XMarkIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"close\" className={className} />\n)\n\nexport function FolderModal({ folder, onClose, onSave }: FolderModalProps) {\n    const t = useTranslations('assetHub')\n    const [name, setName] = useState(folder?.name || '')\n\n    const handleSubmit = (e: React.FormEvent) => {\n        e.preventDefault()\n        if (name.trim()) {\n            onSave(name.trim())\n        }\n    }\n\n    return (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4\">\n            <div className=\"glass-surface-modal max-w-sm w-full\">\n                <div className=\"p-5\">\n                    {/* 标题 */}\n                    <div className=\"flex items-center justify-between mb-5\">\n                        <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                            {folder ? t('editFolder') : t('newFolder')}\n                        </h3>\n                        <button\n                            onClick={onClose}\n                            className=\"glass-btn-base glass-btn-soft h-8 w-8 rounded-full flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]\"\n                        >\n                            <XMarkIcon className=\"w-5 h-5\" />\n                        </button>\n                    </div>\n\n                    <form onSubmit={handleSubmit}>\n                        <div className=\"mb-5\">\n                            <label className=\"block text-sm font-medium text-[var(--glass-text-secondary)] mb-2\">\n                                {t('folderName')}\n                            </label>\n                            <input\n                                type=\"text\"\n                                value={name}\n                                onChange={(e) => setName(e.target.value)}\n                                placeholder={t('folderNamePlaceholder')}\n                                className=\"glass-input-base w-full px-3 py-2 text-sm\"\n                                autoFocus\n                            />\n                        </div>\n\n                        <div className=\"flex gap-3 justify-end\">\n                            <button\n                                type=\"button\"\n                                onClick={onClose}\n                                className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm\"\n                            >\n                                {t('cancel')}\n                            </button>\n                            <button\n                                type=\"submit\"\n                                disabled={!name.trim()}\n                                className=\"glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed\"\n                            >\n                                {folder ? t('save') : t('create')}\n                            </button>\n                        </div>\n                    </form>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/FolderSidebar.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface Folder {\n    id: string\n    name: string\n}\n\ninterface FolderSidebarProps {\n    folders: Folder[]\n    selectedFolderId: string | null\n    onSelectFolder: (folderId: string | null) => void\n    onCreateFolder: () => void\n    onEditFolder: (folder: Folder) => void\n    onDeleteFolder: (folderId: string) => void\n}\n\n// 内联 SVG 图标\nconst FolderIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"folder\" className={className} />\n)\n\nconst PlusIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"plus\" className={className} />\n)\n\nconst PencilIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"edit\" className={className} />\n)\n\nconst TrashIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"trash\" className={className} />\n)\n\nexport function FolderSidebar({\n    folders,\n    selectedFolderId,\n    onSelectFolder,\n    onCreateFolder,\n    onEditFolder,\n    onDeleteFolder\n}: FolderSidebarProps) {\n    const t = useTranslations('assetHub')\n\n    return (\n        <div className=\"w-56 flex-shrink-0\">\n            <div className=\"glass-surface p-4\">\n                <div className=\"flex items-center justify-between mb-3\">\n                    <h3 className=\"text-sm font-medium text-[var(--glass-text-secondary)]\">{t('folders')}</h3>\n                    <button\n                        onClick={onCreateFolder}\n                        className=\"glass-btn-base glass-btn-primary h-6 w-6 rounded-full flex items-center justify-center\"\n                        title={t('newFolder')}\n                    >\n                        <PlusIcon className=\"w-4 h-4\" />\n                    </button>\n                </div>\n\n                <div className=\"space-y-1\">\n                    {/* 所有资产 */}\n                    <button\n                        onClick={() => onSelectFolder(null)}\n                        className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors ${selectedFolderId === null\n                                ? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'\n                                : 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'\n                            }`}\n                    >\n                        <FolderIcon className=\"w-4 h-4\" />\n                        <span className=\"truncate\">{t('allAssets')}</span>\n                    </button>\n\n                    {/* 文件夹列表 */}\n                    {folders.map((folder) => (\n                        <div\n                            key={folder.id}\n                            className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${selectedFolderId === folder.id\n                                    ? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'\n                                    : 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'\n                                }`}\n                        >\n                            <button\n                                onClick={() => onSelectFolder(folder.id)}\n                                className=\"flex-1 flex items-center gap-2 text-left text-sm min-w-0\"\n                            >\n                                <FolderIcon className=\"w-4 h-4 flex-shrink-0\" />\n                                <span className=\"truncate\">{folder.name}</span>\n                            </button>\n\n                            {/* 操作按钮 */}\n                            <div className=\"hidden group-hover:flex items-center gap-0.5\">\n                                <button\n                                    onClick={(e) => {\n                                        e.stopPropagation()\n                                        onEditFolder(folder)\n                                    }}\n                                    className=\"glass-btn-base glass-btn-soft h-5 w-5 rounded flex items-center justify-center\"\n                                    title={t('editFolder')}\n                                >\n                                    <PencilIcon className=\"w-3 h-3\" />\n                                </button>\n                                <button\n                                    onClick={(e) => {\n                                        e.stopPropagation()\n                                        onDeleteFolder(folder.id)\n                                    }}\n                                    className=\"glass-btn-base glass-btn-tone-danger h-5 w-5 rounded flex items-center justify-center\"\n                                    title={t('deleteFolder')}\n                                >\n                                    <TrashIcon className=\"w-3 h-3\" />\n                                </button>\n                            </div>\n                        </div>\n                    ))}\n\n                    {folders.length === 0 && (\n                        <div className=\"text-xs text-[var(--glass-text-tertiary)] text-center py-4\">\n                            {t('noFolders')}\n                        </div>\n                    )}\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/LocationCard.tsx",
    "content": "'use client'\nimport { resolveErrorDisplay } from '@/lib/errors/display'\n\nimport { useRef, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport {\n  useGenerateLocationImage,\n  useSelectLocationImage,\n  useUndoLocationImage,\n  useUploadLocationImage,\n  useDeleteLocation\n} from '@/lib/query/mutations'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport TaskStatusOverlay from '@/components/task/TaskStatusOverlay'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\nimport ImageGenerationSlotOverlay from '@/components/image-generation/ImageGenerationSlotOverlay'\nimport { getImageGenerationCountOptions } from '@/lib/image-generation/count'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\nimport {\n  countGeneratedImageSlots,\n  resolveGroupedImageSlotPhase,\n  resolveDisplayImageSlots,\n} from '@/lib/image-generation/slot-state'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface LocationImage {\n  id: string\n  imageIndex: number\n  description: string | null\n  imageUrl: string | null\n  previousImageUrl: string | null\n  isSelected: boolean\n  imageTaskRunning: boolean\n  imageErrorMessage?: string | null\n  lastError?: { code: string; message: string } | null\n}\n\ninterface Location {\n  id: string\n  name: string\n  summary: string | null\n  artStyle?: string | null\n  folderId: string | null\n  images: LocationImage[]\n}\n\ninterface LocationCardProps {\n  location: Location\n  onImageClick?: (url: string) => void\n  onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number) => void\n  onEdit?: (location: Location, imageIndex: number) => void\n}\n\nexport function LocationCard({ location, onImageClick, onImageEdit, onEdit }: LocationCardProps) {\n  // 🔥 使用 mutation hooks\n  const generateImage = useGenerateLocationImage()\n  const selectImage = useSelectLocationImage()\n  const undoImage = useUndoLocationImage()\n  const uploadImage = useUploadLocationImage()\n  const deleteLocation = useDeleteLocation()\n\n  const t = useTranslations('assetHub')\n  const tAssets = useTranslations('assets')\n  const { count: generationCount, setCount: setGenerationCount } = useImageGenerationCount('location')\n  const fileInputRef = useRef<HTMLInputElement>(null)\n\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\n  const latestSelectRequestRef = useRef(0)\n\n  // 解析图片\n  const orderedImages = [...(location.images || [])].sort((left, right) => left.imageIndex - right.imageIndex)\n  const imagesWithUrl = orderedImages.filter((img) => img.imageUrl)\n  const generatedImageCount = countGeneratedImageSlots(orderedImages)\n  const selectedImage = orderedImages.find((img) => img.isSelected)\n  const serverSelectedIndex = selectedImage?.imageIndex ?? null\n  const effectiveSelectedIndex = serverSelectedIndex\n  const currentImageUrl = selectedImage?.imageUrl || imagesWithUrl[0]?.imageUrl || null\n  const currentImageIndex = effectiveSelectedIndex ?? imagesWithUrl[0]?.imageIndex ?? 0\n  const hasPreviousVersion = location.images?.some(img => img.previousImageUrl) || false\n\n  const isValidUrl = (url: string | null | undefined): boolean => {\n    if (!url || url.trim() === '') return false\n    if (url.startsWith('/')) return true\n    if (url.startsWith('data:') || url.startsWith('blob:')) return true\n    try { new URL(url); return true } catch { return false }\n  }\n  const displayImageUrl = isValidUrl(currentImageUrl) ? currentImageUrl : null\n  const serverTaskRunning = (location.images || []).some((image) => image.imageTaskRunning)\n  const transientSubmitting = generateImage.isPending\n  const isTaskRunning = serverTaskRunning || transientSubmitting\n  const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {\n    hasRunningTask: isTaskRunning,\n    requestedCount: generationCount,\n  })\n  const displaySlotCount = displaySelectionImages.length\n  const hasMultipleImages = generatedImageCount > 1\n  const displayTaskPresentation = isTaskRunning\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: displayImageUrl ? 'process' : 'generate',\n      resource: 'image',\n      hasOutput: !!displayImageUrl,\n    })\n    : null\n  // 取第一个有错误的 image 的 lastError\n  const firstImageError = !isTaskRunning\n    ? (location.images || []).find(img => img.lastError)?.lastError || null\n    : null\n  const taskErrorDisplay = firstImageError ? resolveErrorDisplay(firstImageError) : null\n  const selectImageRunningState = selectImage.isPending\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'process',\n      resource: 'image',\n      hasOutput: !!displayImageUrl,\n    })\n    : null\n\n  // 生成图片\n  const handleGenerate = (count = generationCount) => {\n    generateImage.mutate({\n      locationId: location.id,\n      artStyle: location.artStyle || undefined,\n      count,\n    }, {\n      onError: (error) => alert(error.message || t('generateFailed'))\n    })\n  }\n\n  // 选择图片（依赖 query 缓存乐观更新）\n  const handleSelectImage = (imageIndex: number | null) => {\n    if (imageIndex === effectiveSelectedIndex) return\n    const requestId = latestSelectRequestRef.current + 1\n    latestSelectRequestRef.current = requestId\n    selectImage.mutate({\n      locationId: location.id,\n      imageIndex,\n      confirm: false\n    }, {\n      onError: (error) => {\n        if (latestSelectRequestRef.current !== requestId) return\n        alert(error.message || t('selectFailed'))\n      }\n    })\n  }\n\n  // 确认选择\n  const handleConfirmSelection = () => {\n    if (effectiveSelectedIndex === null) return\n    const requestId = latestSelectRequestRef.current + 1\n    latestSelectRequestRef.current = requestId\n    selectImage.mutate({\n      locationId: location.id,\n      imageIndex: effectiveSelectedIndex,\n      confirm: true\n    }, {\n      onError: (error) => {\n        if (latestSelectRequestRef.current !== requestId) return\n        alert(error.message || t('selectFailed'))\n      }\n    })\n  }\n\n  // 撤回\n  const handleUndo = () => {\n    undoImage.mutate(location.id)\n  }\n\n  // 上传图片\n  const handleUpload = () => {\n    const file = fileInputRef.current?.files?.[0]\n    if (!file) return\n\n    uploadImage.mutate(\n      {\n        file,\n        locationId: location.id,\n        labelText: location.name,\n        imageIndex: currentImageIndex\n      },\n      {\n        onError: (error) => alert(error.message || t('uploadFailed')),\n        onSettled: () => {\n          if (fileInputRef.current) fileInputRef.current.value = ''\n        }\n      }\n    )\n  }\n\n  // 删除场景\n  const handleDelete = () => {\n    deleteLocation.mutate(location.id, {\n      onSettled: () => setShowDeleteConfirm(false)\n    })\n  }\n\n  // 多图选择模式\n  if (displaySlotCount > 1) {\n    const selectionStatusText = isTaskRunning || generatedImageCount < displaySlotCount\n      ? tAssets('image.generatedProgress', { generated: generatedImageCount, total: displaySlotCount })\n      : effectiveSelectedIndex !== null\n        ? tAssets('image.optionSelected', { number: effectiveSelectedIndex + 1 })\n        : tAssets('image.selectFirst')\n\n    return (\n      <div className=\"col-span-3 glass-surface p-4 relative\">\n        <input ref={fileInputRef} type=\"file\" accept=\"image/*\" onChange={handleUpload} className=\"hidden\" />\n\n        {/* 顶部：名字 + 操作 */}\n        <div className=\"flex items-start justify-between mb-4\">\n          <div className=\"flex-1 min-w-0\">\n            <div className=\"flex items-center gap-2 mb-1\">\n              <span className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">{location.name}</span>\n            </div>\n            {location.summary && (\n              <div className=\"text-xs text-[var(--glass-text-secondary)] mb-1 line-clamp-2\" title={location.summary}>\n                {location.summary}\n              </div>\n            )}\n          <div className=\"text-xs text-[var(--glass-text-tertiary)]\">{selectionStatusText}</div>\n        </div>\n          <div className=\"flex items-center gap-1 ml-2\">\n            <ImageGenerationInlineCountButton\n              prefix={isTaskRunning ? (\n                <TaskStatusInline state={displayTaskPresentation} className=\"[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]\" />\n              ) : (\n                <AppIcon name=\"refresh\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n              )}\n              suffix={null}\n              value={generationCount}\n              options={getImageGenerationCountOptions('location')}\n              onValueChange={setGenerationCount}\n              onClick={() => handleGenerate(generationCount)}\n              disabled={isTaskRunning}\n              ariaLabel={tAssets('image.selectCount')}\n              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\"\n              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\"\n            />\n            {hasPreviousVersion && (\n              <button onClick={handleUndo} className=\"glass-btn-base glass-btn-soft h-6 w-6 rounded-md\" title={tAssets('image.undo')}>\n                <AppIcon name=\"sparkles\" className=\"w-4 h-4 text-[var(--glass-tone-warning-fg)]\" />\n              </button>\n            )}\n            <button onClick={() => setShowDeleteConfirm(true)} className=\"glass-btn-base glass-btn-soft h-6 w-6 rounded-md\">\n              <AppIcon name=\"trash\" className=\"w-4 h-4 text-[var(--glass-tone-danger-fg)]\" />\n            </button>\n          </div>\n        </div>\n\n        {/* 任务失败错误提示 */}\n        {taskErrorDisplay && !isTaskRunning && (\n          <div className=\"flex items-center gap-2 mb-3 p-2 rounded-lg bg-[var(--glass-danger-ring)] text-[var(--glass-tone-danger-fg)]\">\n            <AppIcon name=\"alert\" className=\"w-4 h-4 shrink-0\" />\n            <span className=\"text-xs line-clamp-2\">{taskErrorDisplay.message}</span>\n          </div>\n        )}\n\n        {/* 图片列表 */}\n        <div className=\"grid grid-cols-3 gap-3\">\n          {displaySelectionImages.map((img) => {\n            const isThisSelected = img.isSelected\n            const hasPendingEmptySlots = isTaskRunning && generatedImageCount < displaySlotCount\n            const slotTaskRunning = hasPendingEmptySlots\n              ? !img.imageUrl && isTaskRunning\n              : !!img.imageTaskRunning\n            const phase = resolveGroupedImageSlotPhase(\n              { imageUrl: img.imageUrl },\n              {\n                isGroupRunning: isTaskRunning,\n                isSlotRunning: slotTaskRunning,\n                hasPendingEmptySlots,\n              },\n            )\n            const imageError = resolveErrorDisplay(img.lastError || {\n              code: img.imageErrorMessage || null,\n              message: img.imageErrorMessage || null,\n            })\n            return (\n              <div key={img.id} className=\"relative group/thumb\">\n                <div\n                  onClick={() => {\n                    if (img.imageUrl) {\n                      onImageClick?.(img.imageUrl)\n                    }\n                  }}\n                  className={`rounded-lg overflow-hidden border-2 transition-all ${img.imageUrl ? 'cursor-zoom-in' : 'cursor-default'} ${isThisSelected ? 'border-[var(--glass-stroke-success)] ring-2 ring-[var(--glass-success-ring)]' : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'}`}\n                >\n                  {img.imageUrl ? (\n                    <MediaImageWithLoading\n                      src={img.imageUrl}\n                      alt={`${location.name} ${img.imageIndex + 1}`}\n                      containerClassName=\"w-full min-h-[88px]\"\n                      className=\"w-full h-auto object-contain\"\n                    />\n                  ) : (\n                    <div className=\"flex min-h-[88px] items-center justify-center bg-[var(--glass-bg-muted)]\">\n                      {imageError && !isTaskRunning ? (\n                        <div className=\"flex flex-col items-center justify-center px-3 py-6 text-center\">\n                          <AppIcon name=\"alert\" className=\"mb-2 h-6 w-6 text-[var(--glass-tone-danger-fg)]\" />\n                          <span className=\"text-xs font-medium text-[var(--glass-tone-danger-fg)]\">{tAssets('common.generateFailed')}</span>\n                        </div>\n                      ) : (\n                        <div className=\"flex flex-col items-center justify-center gap-2 px-3 py-6 text-[var(--glass-text-tertiary)]\">\n                          <div className=\"h-12 w-12 animate-pulse rounded-xl bg-[var(--glass-bg-surface-strong)]\" />\n                          <span className=\"text-xs\">{tAssets('image.generatingPlaceholder')}</span>\n                        </div>\n                      )}\n                    </div>\n                  )}\n                  {phase === 'generating' && (\n                    <ImageGenerationSlotOverlay label={tAssets('image.generating')} />\n                  )}\n                  {phase === 'regenerating' && (\n                    <ImageGenerationSlotOverlay label={tAssets('image.regenerating')} />\n                  )}\n                  <div className={`absolute bottom-2 left-2 text-xs px-2 py-0.5 rounded ${isThisSelected ? 'glass-chip glass-chip-success' : 'glass-chip glass-chip-neutral'}`}>\n                    {tAssets('image.optionNumber', { number: img.imageIndex + 1 })}\n                  </div>\n                </div>\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    if (!img.imageUrl || phase === 'generating' || phase === 'regenerating') return\n                    handleSelectImage(isThisSelected ? null : img.imageIndex)\n                  }}\n                  disabled={!img.imageUrl || phase === 'generating' || phase === 'regenerating'}\n                  className={`absolute top-2 right-2 glass-btn-base h-7 w-7 rounded-full ${isThisSelected ? 'glass-btn-tone-success' : 'glass-btn-secondary'} disabled:opacity-50`}\n                >\n                  <AppIcon name=\"check\" className=\"w-4 h-4\" />\n                </button>\n              </div>\n            )\n          })}\n        </div>\n\n        {/* 确认按钮 */}\n        {effectiveSelectedIndex !== null && (\n          <div className=\"mt-4 flex justify-end\">\n            <button onClick={handleConfirmSelection} disabled={selectImage.isPending} className=\"glass-btn-base glass-btn-tone-success px-4 py-2 rounded-lg flex items-center gap-2 text-sm\">\n              {selectImage.isPending ? (\n                <TaskStatusInline state={selectImageRunningState} className=\"text-white [&>span]:sr-only [&_svg]:text-white\" />\n              ) : (\n                <AppIcon name=\"check\" className=\"w-4 h-4\" />\n              )}\n              {tAssets('image.confirmOption', { number: effectiveSelectedIndex + 1 })}\n            </button>\n          </div>\n        )}\n\n        {/* 删除确认 */}\n        {showDeleteConfirm && (\n          <div className=\"absolute inset-0 glass-overlay flex items-center justify-center z-20 rounded-xl\">\n            <div className=\"glass-surface-modal p-4 m-4\">\n              <p className=\"mb-4 text-sm text-[var(--glass-text-primary)]\">{t('confirmDeleteLocation')}</p>\n              <div className=\"flex gap-2 justify-end\">\n                <button onClick={() => setShowDeleteConfirm(false)} className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm\">{t('cancel')}</button>\n                <button onClick={handleDelete} className=\"glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm\">{t('delete')}</button>\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n    )\n  }\n\n  // 单图模式\n  return (\n    <div className=\"glass-surface overflow-hidden relative group\">\n      <input ref={fileInputRef} type=\"file\" accept=\"image/*\" onChange={handleUpload} className=\"hidden\" />\n\n      {/* 图片区域 */}\n      <div className=\"relative bg-[var(--glass-bg-muted)] min-h-[100px]\">\n        {displayImageUrl ? (\n          <>\n            <MediaImageWithLoading\n              src={displayImageUrl}\n              alt={location.name}\n              containerClassName=\"w-full min-h-[120px]\"\n              className=\"w-full h-auto object-contain cursor-zoom-in\"\n              onClick={() => onImageClick?.(displayImageUrl)}\n            />\n            {/* 操作按钮 - 非生成时显示 */}\n            {!isTaskRunning && (\n              <div className=\"absolute top-2 left-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity\">\n                <button onClick={() => fileInputRef.current?.click()} disabled={uploadImage.isPending} className=\"glass-btn-base glass-btn-secondary h-7 w-7 rounded-full\">\n                  <AppIcon name=\"upload\" className=\"w-4 h-4 text-[var(--glass-tone-success-fg)]\" />\n                </button>\n                <button onClick={() => onImageEdit?.('location', location.id, location.name, currentImageIndex)} className=\"glass-btn-base glass-btn-tone-info h-7 w-7 rounded-full\">\n                  <AppIcon name=\"edit\" className=\"w-4 h-4\" />\n                </button>\n                <button onClick={() => handleGenerate()} className=\"glass-btn-base glass-btn-secondary h-7 w-7 rounded-full\">\n                  <AppIcon name=\"refresh\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n                </button>\n                {hasPreviousVersion && (\n                  <button onClick={handleUndo} className=\"glass-btn-base glass-btn-secondary h-7 w-7 rounded-full\">\n                    <AppIcon name=\"sparkles\" className=\"w-4 h-4 text-[var(--glass-tone-warning-fg)]\" />\n                  </button>\n                )}\n              </div>\n            )}\n          </>\n        ) : (\n          <div className=\"flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]\">\n            <AppIcon name=\"globe2\" className=\"w-12 h-12 mb-3\" />\n            <ImageGenerationInlineCountButton\n              prefix={<span>{tAssets('image.generateCountPrefix')}</span>}\n              suffix={<span>{tAssets('image.generateCountSuffix')}</span>}\n              value={generationCount}\n              options={getImageGenerationCountOptions('location')}\n              onValueChange={setGenerationCount}\n              onClick={() => handleGenerate(generationCount)}\n              ariaLabel={tAssets('image.selectCount')}\n              className=\"glass-btn-base glass-btn-primary flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg\"\n              selectClassName=\"appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-current outline-none cursor-pointer leading-none transition-colors\"\n            />\n          </div>\n        )}\n        {isTaskRunning && (\n          <TaskStatusOverlay state={displayTaskPresentation} />\n        )}\n        {taskErrorDisplay && !isTaskRunning && (\n          <div className=\"absolute inset-0 flex flex-col items-center justify-center bg-[var(--glass-danger-ring)] text-[var(--glass-tone-danger-fg)] p-3 gap-1\">\n            <AppIcon name=\"alert\" className=\"w-6 h-6\" />\n            <span className=\"text-xs text-center font-medium line-clamp-3\">{taskErrorDisplay.message}</span>\n          </div>\n        )}\n      </div>\n\n      {/* 信息区域 */}\n      <div className=\"p-3\">\n        <div className=\"flex items-center justify-between\">\n          <h3 className=\"font-medium text-[var(--glass-text-primary)] text-sm truncate\">{location.name}</h3>\n          <div className=\"flex items-center gap-1\">\n            {/* 编辑按钮 */}\n            <button\n              onClick={() => onEdit?.(location, currentImageIndex)}\n              className=\"glass-btn-base glass-btn-soft h-6 w-6 rounded-md opacity-0 group-hover:opacity-100\"\n              title={tAssets('video.panelCard.editPrompt')}\n            >\n              <AppIcon name=\"edit\" className=\"w-4 h-4 text-[var(--glass-text-secondary)]\" />\n            </button>\n            {/* 删除按钮 */}\n            <button onClick={() => setShowDeleteConfirm(true)} className=\"glass-btn-base glass-btn-soft h-6 w-6 rounded-md text-[var(--glass-tone-danger-fg)] opacity-0 group-hover:opacity-100\">\n              <AppIcon name=\"trash\" className=\"w-4 h-4\" />\n            </button>\n          </div>\n        </div>\n        {location.summary && <p className=\"mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2\">{location.summary}</p>}\n      </div>\n\n      {/* 删除确认 */}\n      {showDeleteConfirm && (\n        <div className=\"absolute inset-0 glass-overlay flex items-center justify-center z-20\">\n          <div className=\"glass-surface-modal p-4 m-4\">\n            <p className=\"mb-4 text-sm text-[var(--glass-text-primary)]\">{t('confirmDeleteLocation')}</p>\n            <div className=\"flex gap-2 justify-end\">\n              <button onClick={() => setShowDeleteConfirm(false)} className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm\">{t('cancel')}</button>\n              <button onClick={handleDelete} className=\"glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm\">{t('delete')}</button>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport default LocationCard\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/LocationEditModal.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\n\n/**\n * 资产中心 - 场景编辑弹窗\n * 与项目级资产库的 LocationEditModal 保持一致\n */\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport { shouldShowError } from '@/lib/error-utils'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport {\n    useRefreshGlobalAssets,\n    useUpdateLocationName,\n    useAiModifyLocationDescription,\n    useUpdateLocationSummary,\n} from '@/lib/query/hooks'\n\ninterface LocationEditModalProps {\n    locationId: string\n    locationName: string\n    summary: string\n    imageIndex: number\n    description: string\n    onClose: () => void\n    onSave: () => void  // 触发生成图片\n}\n\nexport function LocationEditModal({\n    locationId,\n    locationName,\n    summary,\n    imageIndex,\n    description,\n    onClose,\n    onSave\n}: LocationEditModalProps) {\n    // 🔥 使用 React Query\n    const onRefresh = useRefreshGlobalAssets()\n    const updateName = useUpdateLocationName()\n    const modifyDescription = useAiModifyLocationDescription()\n    const updateSummary = useUpdateLocationSummary()\n    const t = useTranslations('assets')\n\n    const [editingName, setEditingName] = useState(locationName)\n    const [editingDescription, setEditingDescription] = useState(description || summary || '')\n    const [aiModifyInstruction, setAiModifyInstruction] = useState('')\n    const [isAiModifying, setIsAiModifying] = useState(false)\n    const [isSaving, setIsSaving] = useState(false)\n    const aiModifyingState = isAiModifying\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'modify',\n            resource: 'image',\n            hasOutput: true,\n        })\n        : null\n    const savingState = isSaving\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'modify',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n\n    // AI 修改描述\n    const handleAiModify = async () => {\n        if (!aiModifyInstruction.trim()) return\n\n        try {\n            setIsAiModifying(true)\n            const data = await modifyDescription.mutateAsync({\n                locationId,\n                imageIndex,\n                currentDescription: editingDescription,\n                modifyInstruction: aiModifyInstruction,\n            })\n            setEditingDescription(data.modifiedDescription ?? '')\n            setAiModifyInstruction('')\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                const message = error instanceof Error ? error.message : String(error)\n                alert(t('modal.modifyFailed') + ': ' + message)\n            }\n        } finally {\n            setIsAiModifying(false)\n        }\n    }\n\n    // 保存名字\n    const handleSaveName = () => {\n        if (!editingName.trim() || editingName === locationName) return\n\n        updateName.mutate(\n            { locationId, name: editingName.trim() },\n            {\n                onError: (error) => {\n                    if (shouldShowError(error)) {\n                        alert(t('modal.saveName') + t('errors.failed'))\n                    }\n                }\n            }\n        )\n    }\n\n    // 仅保存（不生成图片）\n    const handleSaveOnly = async () => {\n        try {\n            setIsSaving(true)\n\n            // 如果名字变了，先保存名字和 summary\n            if (editingName.trim() !== locationName) {\n                await updateName.mutateAsync({ locationId, name: editingName.trim() })\n                await updateSummary.mutateAsync({ locationId, summary: editingDescription })\n            } else {\n                // 只保存 summary\n                await updateSummary.mutateAsync({ locationId, summary: editingDescription })\n            }\n\n            onRefresh()\n            onClose()\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                alert(t('errors.saveFailed'))\n            }\n        } finally {\n            setIsSaving(false)\n        }\n    }\n\n    // 保存并生成图片\n    const handleSaveAndGenerate = async () => {\n        const descToSave = editingDescription\n        const nameToSave = editingName.trim()\n\n        // 立即关闭弹窗\n        onClose()\n\n            // 后台执行保存和生成\n            ; (async () => {\n                try {\n                    // 保存名字和描述\n                    if (nameToSave !== locationName) {\n                        await updateName.mutateAsync({ locationId, name: nameToSave })\n                    }\n                    await updateSummary.mutateAsync({ locationId, summary: descToSave })\n\n                    // 触发生成\n                    onSave()\n                    onRefresh()\n                } catch (error: unknown) {\n                    _ulogError('保存并生成失败:', error)\n                    if (shouldShowError(error)) {\n                        alert(t('errors.saveFailed'))\n                    }\n                }\n            })()\n    }\n\n    return (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4\">\n            <div className=\"glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-y-auto\">\n                <div className=\"p-6 space-y-4\">\n                    {/* 标题 */}\n                    <div className=\"flex items-center justify-between\">\n                        <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                            {t('modal.editLocation')} - {locationName}\n                        </h3>\n                        <button onClick={onClose} className=\"glass-btn-base glass-btn-soft h-8 w-8 rounded-full text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]\">\n                            <AppIcon name=\"close\" className=\"w-6 h-6\" />\n                        </button>\n                    </div>\n\n                    {/* 场景名字编辑 */}\n                    <div className=\"space-y-2\">\n                        <label className=\"glass-field-label block\">\n                            {t('location.name')}\n                        </label>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={editingName}\n                                onChange={(e) => setEditingName(e.target.value)}\n                                className=\"glass-input-base flex-1 px-3 py-2\"\n                                placeholder={t('modal.namePlaceholder')}\n                            />\n                            {editingName !== locationName && (\n                                <button\n                                    onClick={handleSaveName}\n                                    disabled={updateName.isPending || !editingName.trim()}\n                                    className=\"glass-btn-base glass-btn-tone-success px-3 py-2 rounded-lg text-sm whitespace-nowrap\"\n                                >\n                                    {updateName.isPending ? t('smartImport.preview.saving') : t('modal.saveName')}\n                                </button>\n                            )}\n                        </div>\n                    </div>\n\n                    {/* AI 修改区域 */}\n                    <div className=\"space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]\">\n                        <label className=\"glass-field-label block flex items-center gap-2\">\n                            <AppIcon name=\"bolt\" className=\"w-4 h-4\" />\n                            {t('modal.smartModify')}\n                        </label>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={aiModifyInstruction}\n                                onChange={(e) => setAiModifyInstruction(e.target.value)}\n                                placeholder={t('modal.modifyPlaceholder')}\n                                className=\"glass-input-base flex-1 px-3 py-2\"\n                                disabled={isAiModifying}\n                                onKeyDown={(e) => {\n                                    if (e.key === 'Enter' && !e.shiftKey) {\n                                        e.preventDefault()\n                                        handleAiModify()\n                                    }\n                                }}\n                            />\n                            <button\n                                onClick={handleAiModify}\n                                disabled={isAiModifying || !aiModifyInstruction.trim()}\n                                className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap\"\n                            >\n                                {isAiModifying ? (\n                                    <TaskStatusInline state={aiModifyingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                                ) : (\n                                    <>\n                                        <AppIcon name=\"bolt\" className=\"w-4 h-4\" />\n                                        {t('modal.smartModify')}\n                                    </>\n                                )}\n                            </button>\n                        </div>\n                        <p className=\"glass-field-hint\">\n                            {t('modal.aiLocationTip')}\n                        </p>\n                    </div>\n\n                    {/* 描述编辑 */}\n                    <div className=\"space-y-2\">\n                        <label className=\"glass-field-label block\">\n                            {t('location.description')}\n                        </label>\n                        <textarea\n                            value={editingDescription}\n                            onChange={(e) => setEditingDescription(e.target.value)}\n                            className=\"glass-textarea-base w-full h-48 px-3 py-2 resize-none\"\n                            placeholder={t('modal.descPlaceholder')}\n                            disabled={isAiModifying}\n                        />\n                    </div>\n\n                    {/* 操作按钮 */}\n                    <div className=\"flex gap-3 justify-end\">\n                        <button\n                            onClick={onClose}\n                            className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg\"\n                            disabled={isSaving}\n                        >\n                            {t('common.cancel')}\n                        </button>\n                        <button\n                            onClick={handleSaveOnly}\n                            disabled={isSaving || !editingDescription.trim()}\n                            className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                        >\n                            {isSaving ? (\n                                <TaskStatusInline state={savingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                            ) : (\n                                t('modal.saveOnly')\n                            )}\n                        </button>\n                        <button\n                            onClick={handleSaveAndGenerate}\n                            disabled={isSaving || !editingDescription.trim()}\n                            className=\"glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                        >\n                            {t('modal.saveAndGenerate')}\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    )\n}\n\nexport default LocationEditModal\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/VoiceCard.tsx",
    "content": "'use client'\n\nimport { useState, useRef } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { useDeleteVoice } from '@/lib/query/mutations'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface Voice {\n    id: string\n    name: string\n    description: string | null\n    voiceId: string | null\n    voiceType: string\n    customVoiceUrl: string | null\n    voicePrompt: string | null\n    gender: string | null\n    language: string\n    folderId: string | null\n}\n\ninterface VoiceCardProps {\n    voice: Voice\n    onSelect?: (voice: Voice) => void  // 选择模式时使用\n    isSelected?: boolean  // 是否被选中\n    selectionMode?: boolean  // 是否在选择模式\n}\n\nexport function VoiceCard({ voice, onSelect, isSelected = false, selectionMode = false }: VoiceCardProps) {\n    // 🔥 使用 mutation hook\n    const deleteVoice = useDeleteVoice()\n    const t = useTranslations('assetHub')\n    const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\n    const [isPlaying, setIsPlaying] = useState(false)\n    const audioRef = useRef<HTMLAudioElement | null>(null)\n\n    // 播放预览\n    const handlePlay = () => {\n        if (!voice.customVoiceUrl) return\n\n        if (isPlaying && audioRef.current) {\n            audioRef.current.pause()\n            setIsPlaying(false)\n            return\n        }\n\n        const audio = new Audio(voice.customVoiceUrl)\n        audioRef.current = audio\n        audio.onended = () => setIsPlaying(false)\n        audio.onerror = () => setIsPlaying(false)\n        audio.play()\n        setIsPlaying(true)\n    }\n\n    // 删除音色\n    const handleDelete = () => {\n        deleteVoice.mutate(voice.id, {\n            onSettled: () => setShowDeleteConfirm(false)\n        })\n    }\n\n    // 选择模式点击\n    const handleCardClick = () => {\n        if (selectionMode && onSelect) {\n            onSelect(voice)\n        }\n    }\n\n    // 性别图标\n    const genderIcon = voice.gender === 'male' ? 'M' : voice.gender === 'female' ? 'F' : ''\n\n    return (\n        <div\n            onClick={handleCardClick}\n            className={`glass-surface overflow-hidden relative group transition-all ${selectionMode ? 'cursor-pointer hover:ring-2 hover:ring-[var(--glass-focus-ring-strong)]' : ''\n                } ${isSelected ? 'ring-2 ring-[var(--glass-stroke-focus)]' : ''}`}\n        >\n            {/* 选中标记 */}\n            {isSelected && (\n                <div className=\"absolute top-2 right-2 w-6 h-6 glass-chip glass-chip-info rounded-full flex items-center justify-center z-10 p-0\">\n                    <AppIcon name=\"checkSolid\" className=\"w-4 h-4 text-white\" />\n                </div>\n            )}\n\n            {/* 音色图标区域 */}\n            <div className=\"relative bg-[var(--glass-bg-muted)] p-6 flex items-center justify-center\">\n                <div className=\"w-16 h-16 rounded-full glass-surface-soft flex items-center justify-center\">\n                    <AppIcon name=\"mic\" className=\"w-8 h-8 text-[var(--glass-tone-info-fg)]\" />\n                </div>\n\n                {/* 性别标签 */}\n                {genderIcon && (\n                    <div className=\"absolute top-2 left-2 glass-chip glass-chip-neutral text-xs px-2 py-0.5 rounded-full\">\n                        {genderIcon}\n                    </div>\n                )}\n\n                {/* 试听按钮 */}\n                {voice.customVoiceUrl && (\n                    <button\n                        onClick={(e) => { e.stopPropagation(); handlePlay() }}\n                        className={`absolute bottom-2 right-2 w-10 h-10 rounded-full glass-btn-base flex items-center justify-center transition-all ${isPlaying\n                            ? 'glass-btn-tone-info animate-pulse'\n                            : 'glass-btn-secondary text-[var(--glass-tone-info-fg)]'\n                            }`}\n                    >\n                        {isPlaying ? (\n                            <AppIcon name=\"pause\" className=\"w-5 h-5\" />\n                        ) : (\n                            <AppIcon name=\"play\" className=\"w-5 h-5\" />\n                        )}\n                    </button>\n                )}\n            </div>\n\n            {/* 信息区域 */}\n            <div className=\"p-3\">\n                <div className=\"flex items-center justify-between\">\n                    <h3 className=\"font-medium text-[var(--glass-text-primary)] text-sm truncate\">{voice.name}</h3>\n                    {!selectionMode && (\n                        <button\n                            onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(true) }}\n                            className=\"glass-btn-base glass-btn-soft h-6 w-6 rounded-md text-[var(--glass-tone-danger-fg)] flex items-center justify-center opacity-0 group-hover:opacity-100\"\n                        >\n                            <AppIcon name=\"trash\" className=\"w-4 h-4\" />\n                        </button>\n                    )}\n                </div>\n                {voice.description && (\n                    <p className=\"mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2\">{voice.description}</p>\n                )}\n                {voice.voicePrompt && !voice.description && (\n                    <p className=\"mt-1 text-xs text-[var(--glass-text-tertiary)] line-clamp-2 italic\">{voice.voicePrompt}</p>\n                )}\n            </div>\n\n            {/* 删除确认 */}\n            {showDeleteConfirm && (\n                <div className=\"absolute inset-0 glass-overlay flex items-center justify-center z-20\">\n                    <div className=\"glass-surface-modal p-4 m-4\" onClick={(e) => e.stopPropagation()}>\n                        <p className=\"mb-4 text-sm text-[var(--glass-text-primary)]\">{t('confirmDeleteVoice')}</p>\n                        <div className=\"flex gap-2 justify-end\">\n                            <button onClick={() => setShowDeleteConfirm(false)} className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm\">{t('cancel')}</button>\n                            <button onClick={handleDelete} className=\"glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm\">{t('delete')}</button>\n                        </div>\n                    </div>\n                </div>\n            )}\n        </div>\n    )\n}\n\nexport default VoiceCard\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/VoiceCreationModal.tsx",
    "content": "'use client'\n\nimport VoiceCreationModalShell, { type VoiceCreationModalShellProps } from './voice-creation/VoiceCreationModalShell'\n\nexport type { VoiceCreationModalShellProps as VoiceCreationModalProps } from './voice-creation/VoiceCreationModalShell'\n\nexport default function VoiceCreationModal(props: VoiceCreationModalShellProps) {\n  return <VoiceCreationModalShell {...props} />\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/VoiceDesignDialog.tsx",
    "content": "'use client'\n\nimport VoiceDesignDialogBase, {\n  type VoiceDesignMutationPayload,\n  type VoiceDesignMutationResult,\n} from '@/components/voice/VoiceDesignDialogBase'\nimport { useDesignAssetHubVoice } from '@/lib/query/hooks'\n\ninterface VoiceDesignDialogProps {\n  isOpen: boolean\n  speaker: string\n  hasExistingVoice?: boolean\n  onClose: () => void\n  onSave: (voiceId: string, audioBase64: string) => void\n}\n\nexport default function VoiceDesignDialog({\n  isOpen,\n  speaker,\n  hasExistingVoice = false,\n  onClose,\n  onSave,\n}: VoiceDesignDialogProps) {\n  const designVoiceMutation = useDesignAssetHubVoice()\n\n  const handleDesignVoice = async (\n    payload: VoiceDesignMutationPayload,\n  ): Promise<VoiceDesignMutationResult> => {\n    return await designVoiceMutation.mutateAsync(payload)\n  }\n\n  return (\n    <VoiceDesignDialogBase\n      isOpen={isOpen}\n      speaker={speaker}\n      hasExistingVoice={hasExistingVoice}\n      onClose={onClose}\n      onSave={onSave}\n      onDesignVoice={handleDesignVoice}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/VoicePickerDialog.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\n\nimport { useState, useRef, useEffect } from 'react'\nimport { createPortal } from 'react-dom'\nimport { useTranslations } from 'next-intl'\nimport { useGlobalVoices } from '@/lib/query/hooks'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface Voice {\n    id: string\n    name: string\n    description: string | null\n    voiceId: string | null\n    voiceType: string\n    customVoiceUrl: string | null\n    voicePrompt: string | null\n    gender: string | null\n    language: string\n    folderId: string | null\n}\n\ninterface VoicePickerDialogProps {\n    isOpen: boolean\n    onClose: () => void\n    onSelect: (voice: Voice) => void\n}\n\nexport default function VoicePickerDialog({ isOpen, onClose, onSelect }: VoicePickerDialogProps) {\n    const t = useTranslations('assetHub')\n    const tv = useTranslations('voice.voiceDesign')\n    const voicesQuery = useGlobalVoices()\n    const [selectedVoice, setSelectedVoice] = useState<Voice | null>(null)\n    const [playingId, setPlayingId] = useState<string | null>(null)\n    const audioRef = useRef<HTMLAudioElement | null>(null)\n    const voices = (voicesQuery.data || []) as Voice[]\n    const loading = isOpen ? voicesQuery.isFetching : false\n    const loadingState = loading\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'process',\n            resource: 'audio',\n            hasOutput: false,\n        })\n        : null\n\n    const refetchVoices = voicesQuery.refetch\n\n    useEffect(() => {\n        if (!isOpen) return\n        refetchVoices().catch((error) => {\n            _ulogError('加载音色失败:', error)\n        })\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [isOpen])\n\n    // 播放预览\n    const handlePlay = (voice: Voice) => {\n        if (!voice.customVoiceUrl) return\n\n        if (playingId === voice.id && audioRef.current) {\n            audioRef.current.pause()\n            setPlayingId(null)\n            return\n        }\n\n        if (audioRef.current) {\n            audioRef.current.pause()\n        }\n\n        const audio = new Audio(voice.customVoiceUrl)\n        audioRef.current = audio\n        audio.onended = () => setPlayingId(null)\n        audio.onerror = () => setPlayingId(null)\n        audio.play()\n        setPlayingId(voice.id)\n    }\n\n    // 确认选择\n    const handleConfirm = () => {\n        if (selectedVoice) {\n            onSelect(selectedVoice)\n            onClose()\n        }\n    }\n\n    // 关闭时清理\n    const handleClose = () => {\n        if (audioRef.current) {\n            audioRef.current.pause()\n        }\n        setSelectedVoice(null)\n        setPlayingId(null)\n        onClose()\n    }\n\n    if (!isOpen) return null\n    if (typeof document === 'undefined') return null\n\n    const dialogContent = (\n        <>\n            {/* 背景遮罩 */}\n            <div className=\"fixed inset-0 z-[9999] glass-overlay\" onClick={handleClose} />\n\n            {/* 对话框 */}\n            <div\n                className=\"fixed z-[10000] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 glass-surface-modal w-full max-w-2xl max-h-[80vh] overflow-hidden\"\n                onClick={e => e.stopPropagation()}\n            >\n                {/* 头部 */}\n                <div className=\"flex items-center justify-between px-5 py-3 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]\">\n                    <div className=\"flex items-center gap-2\">\n                        <AppIcon name=\"mic\" className=\"w-5 h-5 text-[var(--glass-tone-info-fg)]\" />\n                        <h2 className=\"font-semibold text-[var(--glass-text-primary)]\">{t('voicePickerTitle')}</h2>\n                    </div>\n                    <button onClick={handleClose} className=\"glass-btn-base glass-btn-soft p-1 text-[var(--glass-text-tertiary)]\">\n                        <AppIcon name=\"close\" className=\"w-5 h-5\" />\n                    </button>\n                </div>\n\n                {/* 内容区 */}\n                <div className=\"p-5 overflow-y-auto max-h-[60vh]\">\n                    {loading ? (\n                        <div className=\"flex items-center justify-center py-12\">\n                            <TaskStatusInline state={loadingState} />\n                        </div>\n                    ) : voices.length === 0 ? (\n                        <div className=\"text-center py-12 text-[var(--glass-text-secondary)]\">\n                            <AppIcon name=\"mic\" className=\"w-16 h-16 mx-auto mb-4 text-[var(--glass-text-tertiary)]\" />\n                            <p>{t('voicePickerEmpty')}</p>\n                        </div>\n                    ) : (\n                        <div className=\"grid grid-cols-2 md:grid-cols-3 gap-3\">\n                            {voices.map(voice => {\n                                const isSelected = selectedVoice?.id === voice.id\n                                const isPlaying = playingId === voice.id\n                                const genderIcon = voice.gender === 'male' ? 'M' : voice.gender === 'female' ? 'F' : ''\n\n                                return (\n                                    <div\n                                        key={voice.id}\n                                        onClick={() => setSelectedVoice(voice)}\n                                        className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${isSelected\n                                            ? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'\n                                            : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)] bg-[var(--glass-bg-surface)]'\n                                            }`}\n                                    >\n                                        {/* 选中标记 */}\n                                        {isSelected && (\n                                            <div className=\"absolute -top-1.5 -right-1.5 w-5 h-5 glass-chip glass-chip-info rounded-full flex items-center justify-center p-0\">\n                                                <AppIcon name=\"checkSolid\" className=\"w-3 h-3 text-white\" />\n                                            </div>\n                                        )}\n\n                                        {/* 音色信息 */}\n                                        <div className=\"flex items-center gap-3\">\n                                            <div className=\"w-10 h-10 rounded-full glass-surface-soft flex items-center justify-center flex-shrink-0\">\n                                                <AppIcon name=\"mic\" className=\"w-5 h-5 text-[var(--glass-tone-info-fg)]\" />\n                                            </div>\n                                            <div className=\"flex-1 min-w-0\">\n                                                <div className=\"flex items-center gap-1\">\n                                                    <span className=\"font-medium text-[var(--glass-text-primary)] text-sm truncate\">{voice.name}</span>\n                                                    {genderIcon && <span className=\"glass-chip glass-chip-neutral text-[10px] px-1.5 py-0\">{genderIcon}</span>}\n                                                </div>\n                                                {voice.description && (\n                                                    <p className=\"text-xs text-[var(--glass-text-secondary)] truncate\">{voice.description}</p>\n                                                )}\n                                            </div>\n                                        </div>\n\n                                        {/* 试听按钮 */}\n                                        {voice.customVoiceUrl && (\n                                            <button\n                                                onClick={(e) => { e.stopPropagation(); handlePlay(voice) }}\n                                                className={`mt-2 w-full py-1.5 rounded-lg text-xs font-medium transition-all flex items-center justify-center gap-1 glass-btn-base ${isPlaying\n                                                    ? 'glass-btn-tone-info'\n                                                    : 'glass-btn-secondary text-[var(--glass-text-secondary)]'\n                                                    }`}\n                                            >\n                                                {isPlaying ? (\n                                                    <>\n                                                        <AppIcon name=\"pause\" className=\"w-3 h-3\" />\n                                                        {tv('playing')}\n                                                    </>\n                                                ) : (\n                                                    <>\n                                                        <AppIcon name=\"play\" className=\"w-3 h-3\" />\n                                                        {tv('preview')}\n                                                    </>\n                                                )}\n                                            </button>\n                                        )}\n                                    </div>\n                                )\n                            })}\n                        </div>\n                    )}\n                </div>\n\n                {/* 底部操作 */}\n                <div className=\"flex gap-2 p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]\">\n                    <button\n                        onClick={handleClose}\n                        className=\"glass-btn-base glass-btn-secondary flex-1 py-2 rounded-lg text-sm\"\n                    >\n                        {t('cancel')}\n                    </button>\n                    <button\n                        onClick={handleConfirm}\n                        disabled={!selectedVoice}\n                        className=\"glass-btn-base glass-btn-primary flex-1 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium\"\n                    >\n                        {t('voicePickerConfirm')}\n                    </button>\n                </div>\n            </div>\n        </>\n    )\n\n    return createPortal(dialogContent, document.body)\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/VoiceSettings.tsx",
    "content": "'use client'\n\n/**\n * 音色设置组件 - 从 CharacterCard 提取\n * 支持上传自定义音频和 AI 声音设计\n */\n\nimport { useRef, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { shouldShowError } from '@/lib/error-utils'\nimport { useUploadCharacterVoice } from '@/lib/query/mutations'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface VoiceSettingsProps {\n    characterId: string\n    characterName: string\n    customVoiceUrl: string | null | undefined\n    projectId?: string  // 可选，Asset Hub 不需要\n    onVoiceChange?: (characterId: string, customVoiceUrl?: string) => void\n    onVoiceDesign?: (characterId: string, characterName: string) => void\n    onVoiceSelect?: (characterId: string) => void  // 从音色库选择\n    compact?: boolean  // 紧凑模式（单图卡片用）\n}\n\nexport default function VoiceSettings({\n    characterId,\n    characterName,\n    customVoiceUrl,\n    projectId,\n    onVoiceChange,\n    onVoiceDesign,\n    onVoiceSelect,\n    compact = false\n}: VoiceSettingsProps) {\n    const t = useTranslations('assetHub')\n    // 🔥 使用 mutation hook\n    const uploadVoice = useUploadCharacterVoice()\n    void projectId\n    const voiceFileInputRef = useRef<HTMLInputElement>(null)\n    const audioRef = useRef<HTMLAudioElement | null>(null)\n    const [isPreviewingVoice, setIsPreviewingVoice] = useState(false)\n    type UploadedVoiceResult = { audioUrl?: string }\n\n    const hasCustomVoice = !!customVoiceUrl\n\n    // 预览音色（播放/暂停自定义音频）\n    const handlePreviewVoice = async () => {\n        if (!customVoiceUrl) return\n\n        // 如果正在播放，点击则暂停\n        if (isPreviewingVoice && audioRef.current) {\n            audioRef.current.pause()\n            setIsPreviewingVoice(false)\n            return\n        }\n\n        try {\n            if (audioRef.current) {\n                audioRef.current.pause()\n            }\n            const audio = new Audio(customVoiceUrl)\n            audioRef.current = audio\n            audio.play()\n            audio.onended = () => setIsPreviewingVoice(false)\n            audio.onerror = () => setIsPreviewingVoice(false)\n            setIsPreviewingVoice(true)\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                const message = error instanceof Error ? error.message : String(error)\n                alert(t('voiceSettings.previewFailed', { error: message }))\n            }\n            setIsPreviewingVoice(false)\n        }\n    }\n\n    // 上传自定义音频\n    const handleUploadVoice = (e: React.ChangeEvent<HTMLInputElement>) => {\n        const file = e.target.files?.[0]\n        if (!file) return\n\n        uploadVoice.mutate(\n            { file, characterId },\n            {\n                onSuccess: (data) => {\n                    const result = (data || {}) as UploadedVoiceResult\n                    onVoiceChange?.(characterId, result.audioUrl)\n                },\n                onError: (error) => {\n                    if (shouldShowError(error)) {\n                        alert(t('voiceSettings.uploadFailed', { error: error.message }))\n                    }\n                },\n                onSettled: () => {\n                    if (voiceFileInputRef.current) {\n                        voiceFileInputRef.current.value = ''\n                    }\n                }\n            }\n        )\n    }\n\n    // 紧凑模式样式\n    const containerClass = compact\n        ? 'glass-surface-soft border border-[var(--glass-stroke-base)] rounded-xl p-3'\n        : 'mt-4 glass-surface-soft border border-[var(--glass-stroke-base)] rounded-xl p-4'\n\n    const headerClass = compact\n        ? 'flex items-center gap-2 mb-2 pb-2 border-b'\n        : 'flex items-center gap-2 mb-3 pb-2 border-b'\n\n    const iconSize = compact ? 'w-5 h-5' : 'w-6 h-6'\n    const innerIconSize = compact ? 'w-3 h-3' : 'w-3.5 h-3.5'\n\n    return (\n        <div className={containerClass}>\n            <div className={`${headerClass} ${hasCustomVoice ? 'border-[var(--glass-stroke-base)]' : 'border-[var(--glass-stroke-warning)]'}`}>\n                <div className={`${iconSize} rounded-full flex items-center justify-center ${hasCustomVoice ? 'glass-chip glass-chip-neutral p-0' : 'glass-chip glass-chip-warning p-0'}`}>\n                    <AppIcon name=\"mic\" className={`${innerIconSize} ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`} />\n                </div>\n                <span className={`text-${compact ? 'xs' : 'sm'} font-medium ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`}>\n                    {t('voiceSettings.title')}{!hasCustomVoice && <span className=\"text-[var(--glass-tone-warning-fg)]\">({t('voiceSettings.noVoice')})</span>}\n                </span>\n            </div>\n\n            {/* 隐藏的音频文件输入 */}\n            <input\n                ref={voiceFileInputRef}\n                type=\"file\"\n                accept=\"audio/*\"\n                onChange={handleUploadVoice}\n                className=\"hidden\"\n            />\n\n            <div className=\"flex gap-2 w-full justify-center flex-wrap\">\n                <button\n                    onClick={() => voiceFileInputRef.current?.click()}\n                    disabled={uploadVoice.isPending}\n                    className=\"glass-btn-base glass-btn-secondary flex-1 min-w-[70px] px-2 py-1.5 rounded-lg text-xs font-medium transition-all relative group whitespace-nowrap\"\n                >\n                    <div className=\"flex items-center justify-center gap-1\">\n                        {hasCustomVoice && <div className=\"w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full flex-shrink-0\"></div>}\n                        <span>{uploadVoice.isPending ? t('voiceSettings.uploading') : hasCustomVoice ? t('voiceSettings.uploaded') : t('voiceSettings.uploadAudio')}</span>\n                    </div>\n                </button>\n\n                {onVoiceDesign && (\n                    <button\n                        onClick={() => onVoiceDesign(characterId, characterName)}\n                        className=\"glass-btn-base glass-btn-tone-info flex-1 min-w-[70px] px-2 py-1.5 rounded-lg text-xs font-medium transition-all whitespace-nowrap\"\n                    >\n                        <div className=\"flex items-center justify-center gap-1\">\n                            <AppIcon name=\"bolt\" className=\"w-3.5 h-3.5 flex-shrink-0\" />\n                            <span>{t('voiceSettings.aiDesign')}</span>\n                        </div>\n                    </button>\n                )}\n\n                {onVoiceSelect && (\n                    <button\n                        onClick={() => onVoiceSelect(characterId)}\n                        className=\"glass-btn-base glass-btn-secondary flex-1 min-w-[70px] px-2 py-1.5 rounded-lg text-xs text-[var(--glass-tone-info-fg)] font-medium transition-all whitespace-nowrap\"\n                    >\n                        <div className=\"flex items-center justify-center gap-1\">\n                            <AppIcon name=\"folderCards\" className=\"w-3.5 h-3.5 flex-shrink-0\" />\n                            <span>{t('voiceSettings.voiceLibrary')}</span>\n                        </div>\n                    </button>\n                )}\n            </div>\n\n            {/* 试听按钮 - 仅在有音频时显示 */}\n            {hasCustomVoice && (\n                <button\n                    onClick={handlePreviewVoice}\n                    className={`glass-btn-base w-full mt-2 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${isPreviewingVoice\n                        ? 'glass-btn-tone-info border-[var(--glass-stroke-focus)]'\n                        : 'glass-btn-secondary text-[var(--glass-tone-info-fg)] border-[var(--glass-stroke-base)]'\n                        }`}\n                >\n                    <div className=\"flex items-center justify-center gap-2\">\n                        {isPreviewingVoice ? (\n                            <AppIcon name=\"pause\" className=\"w-4 h-4\" />\n                        ) : (\n                            <AppIcon name=\"play\" className=\"w-4 h-4\" />\n                        )}\n                        {isPreviewingVoice ? t('voiceSettings.pause') : t('voiceSettings.preview')}\n                    </div>\n                </button>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/voice-creation/VoiceCreationForm.tsx",
    "content": "import type { ReactNode } from 'react'\nimport type { VoiceCreationRuntime } from './hooks/useVoiceCreation'\nimport { AppIcon } from '@/components/ui/icons'\nimport { SegmentedControl } from '@/components/ui/SegmentedControl'\n\ninterface VoiceCreationFormProps {\n  runtime: VoiceCreationRuntime\n  children: ReactNode\n}\n\nexport default function VoiceCreationForm({ runtime, children }: VoiceCreationFormProps) {\n  const {\n    mode,\n    voiceName,\n    tHub,\n    tvCreate,\n    setVoiceName,\n    handleClose,\n    handleModeChange,\n  } = runtime\n\n  return (\n    <div\n      className=\"fixed z-[10000] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 glass-surface-modal w-full max-w-xl overflow-hidden\"\n      onClick={(e) => e.stopPropagation()}\n    >\n      <div className=\"flex items-center justify-between px-5 py-3 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]\">\n        <div className=\"flex items-center gap-2\">\n          <AppIcon name=\"mic\" className=\"w-5 h-5 text-[var(--glass-tone-info-fg)]\" />\n          <h2 className=\"font-semibold text-[var(--glass-text-primary)]\">{tHub('addVoice')}</h2>\n        </div>\n        <button onClick={handleClose} className=\"glass-btn-base glass-btn-soft p-1 text-[var(--glass-text-tertiary)]\">\n          <AppIcon name=\"close\" className=\"w-5 h-5\" />\n        </button>\n      </div>\n\n      <div className=\"flex border-b border-[var(--glass-stroke-base)]\">\n        <div className=\"flex-1 px-5 py-2.5\">\n          <SegmentedControl\n            options={[\n              { value: 'design' as const, label: tvCreate('aiDesignMode') },\n              { value: 'upload' as const, label: tvCreate('uploadMode') },\n            ]}\n            value={mode}\n            onChange={(val) => handleModeChange(val as 'design' | 'upload')}\n          />\n        </div>\n      </div>\n\n      <div className=\"p-5 space-y-4 max-h-[70vh] overflow-y-auto\">\n        <div>\n          <label className=\"glass-field-label mb-1 block\">{tHub('voiceName')}</label>\n          <input\n            type=\"text\"\n            value={voiceName}\n            onChange={(e) => setVoiceName(e.target.value)}\n            placeholder={tHub('voiceNamePlaceholder')}\n            className=\"glass-input-base w-full px-3 py-2 text-sm\"\n          />\n        </div>\n\n        {children}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/voice-creation/VoiceCreationModalLayout.tsx",
    "content": "'use client'\n\nimport { createPortal } from 'react-dom'\nimport VoiceCreationForm from './VoiceCreationForm'\nimport VoicePreviewSection from './VoicePreviewSection'\nimport { useVoiceCreation, type VoiceCreationModalShellProps } from './hooks/useVoiceCreation'\n\nexport type { VoiceCreationModalShellProps }\n\nexport default function VoiceCreationModalLayout(props: VoiceCreationModalShellProps) {\n  const runtime = useVoiceCreation(props)\n\n  if (!runtime.isOpen) return null\n  if (typeof document === 'undefined') return null\n\n  return createPortal(\n    <>\n      <div className=\"fixed inset-0 z-[9999] glass-overlay\" onClick={runtime.handleClose} />\n      <VoiceCreationForm runtime={runtime}>\n        <VoicePreviewSection runtime={runtime} />\n      </VoiceCreationForm>\n    </>,\n    document.body\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/voice-creation/VoiceCreationModalShell.tsx",
    "content": "'use client'\n\nimport VoiceCreationModalLayout, { type VoiceCreationModalShellProps } from './VoiceCreationModalLayout'\n\nexport type { VoiceCreationModalShellProps }\n\nexport default function VoiceCreationModalShell(props: VoiceCreationModalShellProps) {\n  return <VoiceCreationModalLayout {...props} />\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/voice-creation/VoicePreviewSection.tsx",
    "content": "import TaskStatusInline from '@/components/task/TaskStatusInline'\nimport VoiceDesignGeneratorSection from '@/components/voice/VoiceDesignGeneratorSection'\nimport type { VoiceCreationRuntime } from './hooks/useVoiceCreation'\n\ninterface VoicePreviewSectionProps {\n  runtime: VoiceCreationRuntime\n}\n\nexport default function VoicePreviewSection({ runtime }: VoicePreviewSectionProps) {\n  const {\n    mode,\n    voiceName,\n    voicePrompt,\n    previewText,\n    schemeCount,\n    isVoiceCreationSubmitting,\n    isSaving,\n    error,\n    generatedVoices,\n    selectedIndex,\n    playingIndex,\n    uploadFile,\n    uploadPreviewUrl,\n    isUploading,\n    isDragging,\n    fileInputRef,\n    voiceCreationSubmittingState,\n    uploadSubmittingState,\n    tHub,\n    tvCreate,\n    setVoicePrompt,\n    setPreviewText,\n    setSchemeCount,\n    setSelectedIndex,\n    setUploadFile,\n    setUploadPreviewUrl,\n    handleGenerate,\n    handlePlayVoice,\n    handleSaveDesigned,\n    handleFileSelect,\n    handleDragOver,\n    handleDragLeave,\n    handleDrop,\n    handlePlayUpload,\n    handleSaveUploaded,\n  } = runtime\n\n  return (\n    <>\n      {mode === 'design' && (\n        <VoiceDesignGeneratorSection\n          voicePrompt={voicePrompt}\n          onVoicePromptChange={setVoicePrompt}\n          previewText={previewText}\n          onPreviewTextChange={setPreviewText}\n          schemeCount={schemeCount}\n          onSchemeCountChange={setSchemeCount}\n          isSubmitting={isVoiceCreationSubmitting}\n          submittingState={voiceCreationSubmittingState}\n          error={error}\n          generatedVoices={generatedVoices}\n          selectedIndex={selectedIndex}\n          onSelectIndex={setSelectedIndex}\n          playingIndex={playingIndex}\n          onPlayVoice={handlePlayVoice}\n          onGenerate={() => {\n            void handleGenerate()\n          }}\n          footer={(\n            <div className=\"flex gap-2 pt-2\">\n              <button\n                onClick={() => {\n                  void handleGenerate()\n                }}\n                disabled={isVoiceCreationSubmitting}\n                className=\"glass-btn-base glass-btn-secondary flex-1 py-2 rounded-lg text-sm\"\n              >\n                {tHub('regenerate')}\n              </button>\n              <button\n                onClick={() => {\n                  void handleSaveDesigned()\n                }}\n                disabled={selectedIndex === null || isSaving || !voiceName.trim()}\n                className=\"glass-btn-base glass-btn-tone-success flex-1 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium\"\n              >\n                {isSaving ? tHub('modal.adding') : tHub('save')}\n              </button>\n            </div>\n          )}\n        />\n      )}\n\n      {mode === 'upload' && (\n        <>\n          {!uploadFile ? (\n            <div\n              onClick={() => fileInputRef.current?.click()}\n              onDragOver={handleDragOver}\n              onDragLeave={handleDragLeave}\n              onDrop={handleDrop}\n              className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${isDragging\n                ? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'\n                : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-bg-muted)]'\n                }`}\n            >\n              <div className=\"text-sm text-[var(--glass-text-secondary)] mb-2\">{tvCreate('dropOrClick')}</div>\n              <div className=\"text-xs text-[var(--glass-text-tertiary)]\">{tvCreate('supportedFormats')}</div>\n              <input\n                ref={fileInputRef}\n                type=\"file\"\n                accept=\"audio/*,.mp3,.wav,.ogg,.m4a,.aac\"\n                onChange={(e) => {\n                  const file = e.target.files?.[0]\n                  if (file) handleFileSelect(file)\n                }}\n                className=\"hidden\"\n              />\n            </div>\n          ) : (\n            <div className=\"glass-surface-soft border border-[var(--glass-stroke-base)] rounded-xl p-4\">\n              <div className=\"text-sm font-medium text-[var(--glass-text-primary)] truncate\">{uploadFile.name}</div>\n              <button\n                onClick={() => {\n                  setUploadFile(null)\n                  if (uploadPreviewUrl) URL.revokeObjectURL(uploadPreviewUrl)\n                  setUploadPreviewUrl(null)\n                }}\n                className=\"glass-btn-base glass-btn-soft p-1 mt-2\"\n              >\n                ×\n              </button>\n              {uploadPreviewUrl && (\n                <button\n                  onClick={handlePlayUpload}\n                  className=\"glass-btn-base glass-btn-tone-info w-full py-2 rounded-lg text-sm font-medium mt-2\"\n                >\n                  {tvCreate('previewAudio')}\n                </button>\n              )}\n            </div>\n          )}\n\n          {uploadFile && (\n            <button\n              onClick={handleSaveUploaded}\n              disabled={isUploading || !voiceName.trim()}\n              className=\"glass-btn-base glass-btn-tone-success w-full py-2.5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium flex items-center justify-center gap-2\"\n            >\n              {isUploading ? (\n                <TaskStatusInline\n                  state={uploadSubmittingState}\n                  className=\"text-white [&>span]:text-white [&_svg]:text-white\"\n                />\n              ) : (\n                tHub('save')\n              )}\n            </button>\n          )}\n        </>\n      )}\n\n      {mode === 'upload' && error && (\n        <div className=\"text-sm text-[var(--glass-tone-danger-fg)] bg-[var(--glass-tone-danger-bg)] px-3 py-2 rounded-lg\">\n          {error}\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/components/voice-creation/hooks/useVoiceCreation.tsx",
    "content": "'use client'\n\nimport { useState, useRef, useCallback } from 'react'\nimport { useTranslations } from 'next-intl'\nimport {\n    useDesignAssetHubVoice,\n    useSaveDesignedAssetHubVoice,\n    useUploadAssetHubVoice,\n} from '@/lib/query/hooks'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport {\n    DEFAULT_VOICE_SCHEME_COUNT,\n    generateVoiceDesignOptions,\n    type GeneratedVoice,\n} from '@/components/voice/voice-design-shared'\n\nexport interface VoiceCreationModalShellProps {\n    isOpen: boolean\n    folderId: string | null\n    onClose: () => void\n    onSuccess: () => void\n    /** 预填充的音色名称（如发言人名字） */\n    initialVoiceName?: string\n}\n\ntype CreationMode = 'design' | 'upload'\n\nexport function useVoiceCreation({ isOpen, folderId, onClose, onSuccess, initialVoiceName }: VoiceCreationModalShellProps) {\n    const t = useTranslations('common')\n    const tHub = useTranslations('assetHub')\n    const tv = useTranslations('voice.voiceDesign')\n    const tvCreate = useTranslations('voice.voiceCreate')\n\n    // 创建模式：设计 or 上传\n    const [mode, setMode] = useState<CreationMode>('design')\n\n    // 设计模式状态\n    const [voiceName, setVoiceName] = useState(initialVoiceName ?? '')\n    const [voicePrompt, setVoicePrompt] = useState('')\n    const [previewText, setPreviewText] = useState(tv('defaultPreviewText'))\n    const [schemeCount, setSchemeCount] = useState(String(DEFAULT_VOICE_SCHEME_COUNT))\n    const [isVoiceCreationSubmitting, setIsVoiceCreationSubmitting] = useState(false)\n    const [isSaving, setIsSaving] = useState(false)\n    const [error, setError] = useState<string | null>(null)\n    const [generatedVoices, setGeneratedVoices] = useState<GeneratedVoice[]>([])\n    const [selectedIndex, setSelectedIndex] = useState<number | null>(null)\n    const [playingIndex, setPlayingIndex] = useState<number | null>(null)\n    const audioRef = useRef<HTMLAudioElement | null>(null)\n    const voiceCreationSubmittingState = isVoiceCreationSubmitting\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'audio',\n            hasOutput: false,\n        })\n        : null\n\n    // 上传模式状态\n    const [uploadFile, setUploadFile] = useState<File | null>(null)\n    const [uploadPreviewUrl, setUploadPreviewUrl] = useState<string | null>(null)\n    const [isUploading, setIsUploading] = useState(false)\n    const [isDragging, setIsDragging] = useState(false)\n    const fileInputRef = useRef<HTMLInputElement>(null)\n    const uploadSubmittingState = isUploading\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'audio',\n            hasOutput: false,\n        })\n        : null\n    const designVoiceMutation = useDesignAssetHubVoice()\n    const saveDesignedMutation = useSaveDesignedAssetHubVoice()\n    const uploadVoiceMutation = useUploadAssetHubVoice()\n\n    // 生成音色\n    const handleGenerate = async () => {\n        if (!voicePrompt.trim()) {\n            setError(tv('pleaseSelectStyle'))\n            return\n        }\n\n        setIsVoiceCreationSubmitting(true)\n        setError(null)\n        setGeneratedVoices([])\n        setSelectedIndex(null)\n\n        try {\n            const voices = await generateVoiceDesignOptions({\n                count: schemeCount,\n                voicePrompt,\n                previewText,\n                defaultPreviewText: tv('defaultPreviewText'),\n                onDesignVoice: (payload) => designVoiceMutation.mutateAsync(payload),\n            })\n            setGeneratedVoices(voices)\n        } catch (err: unknown) {\n            const errMsg = err instanceof Error ? err.message : 'Unknown error'\n            const status = (err as Error & { status?: number }).status\n            if (status === 402) {\n                alert(t('insufficientBalance') + '\\n\\n' + t('insufficientBalanceDetail'))\n            } else if (errMsg === 'VOICE_DESIGN_EMPTY_RESULT') {\n                setError(tv('noVoiceGenerated'))\n            } else if (errMsg !== 'INSUFFICIENT_BALANCE') {\n                setError(errMsg || tv('generationError'))\n            }\n        } finally {\n            setIsVoiceCreationSubmitting(false)\n        }\n    }\n\n    // 播放音色（支持暂停切换）\n    const handlePlayVoice = (index: number) => {\n        // 点击正在播放的音色 → 暂停\n        if (playingIndex === index && audioRef.current) {\n            audioRef.current.pause()\n            setPlayingIndex(null)\n            return\n        }\n        // 停止当前播放\n        if (audioRef.current) {\n            audioRef.current.pause()\n        }\n        setPlayingIndex(index)\n        const audio = new Audio(generatedVoices[index].audioUrl)\n        audioRef.current = audio\n        audio.onended = () => setPlayingIndex(null)\n        audio.onerror = () => setPlayingIndex(null)\n        void audio.play()\n    }\n\n    // 保存音色到音色库（设计模式）\n    const handleSaveDesigned = async () => {\n        if (selectedIndex === null || !generatedVoices[selectedIndex]) return\n        if (!voiceName.trim()) {\n            setError(tHub('voiceNameRequired'))\n            return\n        }\n\n        setIsSaving(true)\n        setError(null)\n\n        try {\n            const voice = generatedVoices[selectedIndex]\n\n            await saveDesignedMutation.mutateAsync({\n                voiceId: voice.voiceId,\n                voiceBase64: voice.audioBase64,\n                voiceName: voiceName.trim(),\n                folderId,\n                voicePrompt: voicePrompt.trim()\n            })\n\n            onSuccess()\n            handleClose()\n        } catch (err: unknown) {\n            const errMsg = err instanceof Error ? err.message : tHub('saveVoiceFailed')\n            setError(errMsg)\n        } finally {\n            setIsSaving(false)\n        }\n    }\n\n    // 处理文件选择\n    const handleFileSelect = useCallback((file: File) => {\n        // 验证文件类型（仅音频）\n        const audioTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a', 'audio/aac']\n        const isValid = audioTypes.includes(file.type) || file.name.match(/\\.(mp3|wav|ogg|m4a|aac)$/i)\n\n        if (!isValid) {\n            setError(tvCreate('invalidFileType'))\n            return\n        }\n\n        // 验证文件大小（最大 50MB）\n        if (file.size > 50 * 1024 * 1024) {\n            setError(tvCreate('fileTooLarge'))\n            return\n        }\n\n        setUploadFile(file)\n        setError(null)\n\n        // 创建预览 URL\n        const url = URL.createObjectURL(file)\n        setUploadPreviewUrl(url)\n\n        // 自动填充名称（如果为空）\n        if (!voiceName.trim()) {\n            const baseName = file.name.replace(/\\.[^/.]+$/, '') // 移除扩展名\n            setVoiceName(baseName)\n        }\n    }, [voiceName, tvCreate])\n\n    // 处理拖放\n    const handleDragOver = useCallback((e: React.DragEvent) => {\n        e.preventDefault()\n        setIsDragging(true)\n    }, [])\n\n    const handleDragLeave = useCallback((e: React.DragEvent) => {\n        e.preventDefault()\n        setIsDragging(false)\n    }, [])\n\n    const handleDrop = useCallback((e: React.DragEvent) => {\n        e.preventDefault()\n        setIsDragging(false)\n        const file = e.dataTransfer.files[0]\n        if (file) {\n            handleFileSelect(file)\n        }\n    }, [handleFileSelect])\n\n    // 播放上传的音频\n    const handlePlayUpload = () => {\n        if (!uploadPreviewUrl) return\n        if (audioRef.current) {\n            audioRef.current.pause()\n        }\n        const audio = new Audio(uploadPreviewUrl)\n        audioRef.current = audio\n        audio.play()\n    }\n\n    // 上传文件保存\n    const handleSaveUploaded = async () => {\n        if (!uploadFile) return\n        if (!voiceName.trim()) {\n            setError(tHub('voiceNameRequired'))\n            return\n        }\n\n        setIsUploading(true)\n        setError(null)\n\n        try {\n            await uploadVoiceMutation.mutateAsync({\n                uploadFile,\n                voiceName: voiceName.trim(),\n                folderId\n            })\n\n            onSuccess()\n            handleClose()\n        } catch (err: unknown) {\n            const errMsg = err instanceof Error ? err.message : tvCreate('uploadFailed')\n            setError(errMsg)\n        } finally {\n            setIsUploading(false)\n        }\n    }\n\n    // 关闭弹窗\n    const handleClose = () => {\n        setMode('design')\n        setVoiceName(initialVoiceName ?? '')\n        setVoicePrompt('')\n        setPreviewText(tv('defaultPreviewText'))\n        setSchemeCount(String(DEFAULT_VOICE_SCHEME_COUNT))\n        setError(null)\n        setGeneratedVoices([])\n        setSelectedIndex(null)\n        setPlayingIndex(null)\n        setUploadFile(null)\n        if (uploadPreviewUrl) {\n            URL.revokeObjectURL(uploadPreviewUrl)\n        }\n        setUploadPreviewUrl(null)\n        setIsUploading(false)\n        if (audioRef.current) {\n            audioRef.current.pause()\n        }\n        onClose()\n    }\n\n    // 切换模式\n    const handleModeChange = (newMode: CreationMode) => {\n        setMode(newMode)\n        setError(null)\n        // 清理状态\n        setGeneratedVoices([])\n        setSelectedIndex(null)\n        setUploadFile(null)\n        if (uploadPreviewUrl) {\n            URL.revokeObjectURL(uploadPreviewUrl)\n        }\n        setUploadPreviewUrl(null)\n    }\n\n    return {\n        isOpen,\n        mode,\n        voiceName,\n        voicePrompt,\n        previewText,\n        schemeCount,\n        isVoiceCreationSubmitting,\n        isSaving,\n        error,\n        generatedVoices,\n        selectedIndex,\n        playingIndex,\n        uploadFile,\n        uploadPreviewUrl,\n        isUploading,\n        isDragging,\n        fileInputRef,\n        voiceCreationSubmittingState,\n        uploadSubmittingState,\n        t,\n        tHub,\n        tvCreate,\n        setMode,\n        setVoiceName,\n        setVoicePrompt,\n        setPreviewText,\n        setSchemeCount,\n        setError,\n        setGeneratedVoices,\n        setSelectedIndex,\n        setUploadFile,\n        setUploadPreviewUrl,\n        setIsDragging,\n        handleGenerate,\n        handlePlayVoice,\n        handleSaveDesigned,\n        handleFileSelect,\n        handleDragOver,\n        handleDragLeave,\n        handleDrop,\n        handlePlayUpload,\n        handleSaveUploaded,\n        handleClose,\n        handleModeChange,\n    }\n}\n\nexport type VoiceCreationRuntime = ReturnType<typeof useVoiceCreation>\n"
  },
  {
    "path": "src/app/[locale]/workspace/asset-hub/page.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { apiFetch } from '@/lib/api-fetch'\nimport JSZip from 'jszip'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { useQueryClient } from '@tanstack/react-query'\nimport Navbar from '@/components/Navbar'\nimport { FolderSidebar } from './components/FolderSidebar'\nimport { AssetGrid } from './components/AssetGrid'\nimport { CharacterCreationModal, LocationCreationModal, CharacterEditModal, LocationEditModal } from '@/components/shared/assets'\nimport { FolderModal } from './components/FolderModal'\nimport ImagePreviewModal from '@/components/ui/ImagePreviewModal'\nimport ImageEditModal from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/ImageEditModal'\nimport VoiceDesignDialog from './components/VoiceDesignDialog'\nimport VoiceCreationModal from './components/VoiceCreationModal'\nimport VoicePickerDialog from './components/VoicePickerDialog'\nimport {\n    useGlobalCharacters,\n    useGlobalLocations,\n    useGlobalVoices,\n    useGlobalFolders,\n    useSSE,\n    useModifyCharacterImage,\n    useModifyLocationImage,\n    type GlobalCharacter,\n} from '@/lib/query/hooks'\nimport { queryKeys } from '@/lib/query/keys'\nimport { AppIcon } from '@/components/ui/icons'\nimport { Link } from '@/i18n/navigation'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\n\nexport default function AssetHubPage() {\n    const t = useTranslations('assetHub')\n    const queryClient = useQueryClient()\n    const { count: characterGenerationCount } = useImageGenerationCount('character')\n    const { count: locationGenerationCount } = useImageGenerationCount('location')\n\n    // 文件夹选择状态\n    const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)\n\n    // 使用 React Query 获取数据\n    const { data: folders = [], isLoading: foldersLoading } = useGlobalFolders()\n    const { data: characters = [], isLoading: charactersLoading } = useGlobalCharacters(selectedFolderId)\n    const { data: locations = [], isLoading: locationsLoading } = useGlobalLocations(selectedFolderId)\n    const { data: voices = [], isLoading: voicesLoading } = useGlobalVoices(selectedFolderId)\n\n    const loading = foldersLoading || charactersLoading || locationsLoading || voicesLoading\n    useSSE({ projectId: 'global-asset-hub', enabled: true })\n\n    // Mutation hooks\n    const modifyCharacterImage = useModifyCharacterImage()\n    const modifyLocationImage = useModifyLocationImage()\n\n    // 弹窗状态\n    const [showAddCharacter, setShowAddCharacter] = useState(false)\n    const [showAddLocation, setShowAddLocation] = useState(false)\n    const [showFolderModal, setShowFolderModal] = useState(false)\n    const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null)\n    const [previewImage, setPreviewImage] = useState<string | null>(null)\n    const [imageEditModal, setImageEditModal] = useState<{\n        type: 'character' | 'location'\n        id: string\n        name: string\n        imageIndex: number\n        appearanceIndex?: number\n    } | null>(null)\n\n    const [voiceDesignCharacter, setVoiceDesignCharacter] = useState<{\n        id: string\n        name: string\n        hasExistingVoice: boolean\n    } | null>(null)\n\n    // 音色库弹窗状态\n    const [showAddVoice, setShowAddVoice] = useState(false)\n    const [voicePickerCharacterId, setVoicePickerCharacterId] = useState<string | null>(null)\n    const [isDownloading, setIsDownloading] = useState(false)\n\n\n    // 编辑角色弹窗状态\n    const [characterEditModal, setCharacterEditModal] = useState<{\n        characterId: string\n        characterName: string\n        appearanceId: string\n        appearanceIndex: number\n        changeReason: string\n        artStyle: string | null\n        description: string\n    } | null>(null)\n\n    // 编辑场景弹窗状态\n    const [locationEditModal, setLocationEditModal] = useState<{\n        locationId: string\n        locationName: string\n        summary: string\n        imageIndex: number\n        artStyle: string | null\n        description: string\n    } | null>(null)\n\n    // 创建文件夹\n    const handleCreateFolder = async (name: string) => {\n        try {\n            const res = await apiFetch('/api/asset-hub/folders', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ name })\n            })\n            if (res.ok) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.folders() })\n                setShowFolderModal(false)\n            }\n        } catch (error) {\n            _ulogError('创建文件夹失败:', error)\n        }\n    }\n\n    // 更新文件夹\n    const handleUpdateFolder = async (folderId: string, name: string) => {\n        try {\n            const res = await apiFetch(`/api/asset-hub/folders/${folderId}`, {\n                method: 'PATCH',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ name })\n            })\n            if (res.ok) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.folders() })\n                setEditingFolder(null)\n                setShowFolderModal(false)\n            }\n        } catch (error) {\n            _ulogError('更新文件夹失败:', error)\n        }\n    }\n\n    // 删除文件夹\n    const handleDeleteFolder = async (folderId: string) => {\n        if (!confirm(t('confirmDeleteFolder'))) return\n\n        try {\n            const res = await apiFetch(`/api/asset-hub/folders/${folderId}`, {\n                method: 'DELETE'\n            })\n            if (res.ok) {\n                if (selectedFolderId === folderId) {\n                    setSelectedFolderId(null)\n                }\n                queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.all() })\n            }\n        } catch (error) {\n            _ulogError('删除文件夹失败:', error)\n        }\n    }\n\n    // 打开图片编辑弹窗\n    const handleOpenImageEdit = (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => {\n        setImageEditModal({ type, id, name, imageIndex, appearanceIndex })\n    }\n\n    // 处理图片编辑确认 - 使用 mutation\n    const handleImageEdit = async (modifyPrompt: string, extraImageUrls?: string[]) => {\n        if (!imageEditModal) return\n\n        const { type, id, imageIndex, appearanceIndex } = imageEditModal\n        setImageEditModal(null)\n\n        if (type === 'character' && appearanceIndex !== undefined) {\n            modifyCharacterImage.mutate({\n                characterId: id,\n                appearanceIndex,\n                imageIndex,\n                modifyPrompt,\n                extraImageUrls\n            }, {\n                onError: () => {\n                    alert(t('editFailed'))\n                }\n            })\n        } else if (type === 'location') {\n            modifyLocationImage.mutate({\n                locationId: id,\n                imageIndex,\n                modifyPrompt,\n                extraImageUrls\n            }, {\n                onError: () => {\n                    alert(t('editFailed'))\n                }\n            })\n        }\n    }\n\n    // 打开 AI 声音设计对话框\n    const handleOpenVoiceDesign = (characterId: string, characterName: string) => {\n        const character = characters.find(c => c.id === characterId)\n        setVoiceDesignCharacter({\n            id: characterId,\n            name: characterName,\n            hasExistingVoice: !!character?.customVoiceUrl\n        })\n    }\n\n    // 保存 AI 设计的声音\n    const handleVoiceDesignSave = async (voiceId: string, audioBase64: string) => {\n        if (!voiceDesignCharacter) return\n\n        try {\n            const res = await apiFetch('/api/asset-hub/character-voice', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    characterId: voiceDesignCharacter.id,\n                    voiceId,\n                    audioBase64\n                })\n            })\n\n            if (res.ok) {\n                alert(t('voiceDesignSaved', { name: voiceDesignCharacter.name }))\n                queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })\n            } else {\n                const data = await res.json()\n                alert(\n                    typeof data.error === 'string'\n                        ? t('saveVoiceFailedDetail', { error: data.error })\n                        : t('saveVoiceFailed'),\n                )\n            }\n        } catch (error) {\n            _ulogError('保存声音失败:', error)\n            alert(t('saveVoiceFailed'))\n        }\n    }\n\n    // 打开角色编辑弹窗\n    const handleOpenCharacterEdit = (character: unknown, appearance: unknown) => {\n        const typedCharacter = character as GlobalCharacter\n        const typedAppearance = appearance as GlobalCharacter['appearances'][0]\n        setCharacterEditModal({\n            characterId: typedCharacter.id,\n            characterName: typedCharacter.name,\n            appearanceId: typedAppearance.id,\n            appearanceIndex: typedAppearance.appearanceIndex,\n            changeReason: typedAppearance.changeReason || t('appearanceLabel', { index: typedAppearance.appearanceIndex }),\n            artStyle: typedAppearance.artStyle || null,\n            description: typedAppearance.description || ''\n        })\n    }\n\n    // 打开场景编辑弹窗\n    const handleOpenLocationEdit = (location: unknown, imageIndex: number) => {\n        const typedLocation = location as {\n            id: string\n            name: string\n            summary: string | null\n            artStyle: string | null\n            images: Array<{ imageIndex: number; description: string | null }>\n        }\n        const image = typedLocation.images.find(img => img.imageIndex === imageIndex)\n        setLocationEditModal({\n            locationId: typedLocation.id,\n            locationName: typedLocation.name,\n            summary: typedLocation.summary || '',\n            imageIndex: imageIndex,\n            artStyle: typedLocation.artStyle || null,\n            description: image?.description || typedLocation.summary || ''\n        })\n    }\n\n    // 角色编辑后触发生成\n    const handleCharacterEditGenerate = async () => {\n        if (!characterEditModal) return\n\n        try {\n            await apiFetch('/api/asset-hub/generate-image', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'character',\n                    id: characterEditModal.characterId,\n                    appearanceIndex: characterEditModal.appearanceIndex,\n                    artStyle: characterEditModal.artStyle || undefined,\n                    count: characterGenerationCount,\n                })\n            })\n            queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })\n        } catch (error) {\n            _ulogError('触发生成失败:', error)\n        }\n    }\n\n    // 场景编辑后触发生成\n    const handleLocationEditGenerate = async () => {\n        if (!locationEditModal) return\n\n        try {\n            await apiFetch('/api/asset-hub/generate-image', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'location',\n                    id: locationEditModal.locationId,\n                    artStyle: locationEditModal.artStyle || undefined,\n                    count: locationGenerationCount,\n                })\n            })\n            queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.locations() })\n        } catch (error) {\n            _ulogError('触发生成失败:', error)\n        }\n    }\n\n    // 从音色库选择后绑定到角色\n    const handleVoiceSelect = async (voice: { id: string; customVoiceUrl: string | null }) => {\n        if (!voicePickerCharacterId) return\n\n        try {\n            const res = await apiFetch(`/api/asset-hub/characters/${voicePickerCharacterId}`, {\n                method: 'PATCH',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    globalVoiceId: voice.id,\n                    customVoiceUrl: voice.customVoiceUrl\n                })\n            })\n\n            if (res.ok) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })\n                setVoicePickerCharacterId(null)\n            } else {\n                const data = await res.json()\n                alert(\n                    typeof data.error === 'string'\n                        ? t('bindVoiceFailedDetail', { error: data.error })\n                        : t('bindVoiceFailed'),\n                )\n            }\n        } catch (error) {\n            _ulogError('绑定音色失败:', error)\n            alert(t('bindVoiceFailed'))\n        }\n    }\n\n    // 打包下载所有图片资产\n    const handleDownloadAll = async () => {\n        // 收集所有有效图片\n        const imageEntries: Array<{ filename: string; url: string }> = []\n\n        // 角色图片：每个角色每个外貌的当前选中图\n        for (const character of characters) {\n            for (const appearance of character.appearances) {\n                const url = appearance.imageUrl\n                if (!url) continue\n                const safeName = character.name.replace(/[/\\\\:*?\"<>|]/g, '_')\n                const filename = appearance.appearanceIndex === 0\n                    ? `characters/${safeName}.jpg`\n                    : `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`\n                imageEntries.push({ filename, url })\n            }\n        }\n\n        // 场景图片：每个场景的选中图\n        for (const location of locations) {\n            for (const image of location.images) {\n                const url = image.imageUrl\n                if (!url) continue\n                const safeName = location.name.replace(/[/\\\\:*?\"<>|]/g, '_')\n                const filename = location.images.length <= 1\n                    ? `locations/${safeName}.jpg`\n                    : `locations/${safeName}_${image.imageIndex + 1}.jpg`\n                imageEntries.push({ filename, url })\n            }\n        }\n\n        if (imageEntries.length === 0) {\n            alert(t('downloadEmpty'))\n            return\n        }\n\n        setIsDownloading(true)\n        try {\n            const zip = new JSZip()\n            // 并发 fetch 所有图片\n            await Promise.all(\n                imageEntries.map(async ({ filename, url }) => {\n                    try {\n                        const response = await fetch(url)\n                        if (!response.ok) return\n                        const blob = await response.blob()\n                        zip.file(filename, blob)\n                    } catch {\n                        // 单张图片失败不阻断整个流程\n                    }\n                })\n            )\n            const content = await zip.generateAsync({ type: 'blob' })\n            const link = document.createElement('a')\n            link.href = URL.createObjectURL(content)\n            link.download = `asset-hub_${new Date().toISOString().slice(0, 10)}.zip`\n            document.body.appendChild(link)\n            link.click()\n            document.body.removeChild(link)\n            URL.revokeObjectURL(link.href)\n        } catch (error) {\n            _ulogError('打包下载失败:', error)\n            alert(t('downloadFailed'))\n        } finally {\n            setIsDownloading(false)\n        }\n    }\n\n    return (\n        <div className=\"glass-page min-h-screen\">\n            <Navbar />\n            <div className=\"max-w-7xl mx-auto px-4 py-6\">\n                {/* 页面标题 */}\n                <div className=\"mb-6\">\n                    <h1 className=\"text-2xl font-bold text-[var(--glass-text-primary)]\">{t('title')}</h1>\n                    <p className=\"text-sm text-[var(--glass-text-secondary)] mt-1\">{t('description')}</p>\n                    <p className=\"text-xs text-[var(--glass-text-tertiary)] mt-2 flex items-center gap-1\">\n                        <AppIcon name=\"info\" className=\"w-3.5 h-3.5\" />\n                        {t('modelHint')}\n                        <Link href={{ pathname: '/profile' }} className=\"text-[var(--glass-tone-info-fg)] hover:underline\">{t('modelHintLink')}</Link>\n                        {t('modelHintSuffix')}\n                    </p>\n                </div>\n\n                <div className=\"flex gap-6\">\n                    {/* 左侧文件夹树 */}\n                    <FolderSidebar\n                        folders={folders}\n                        selectedFolderId={selectedFolderId}\n                        onSelectFolder={setSelectedFolderId}\n                        onCreateFolder={() => {\n                            setEditingFolder(null)\n                            setShowFolderModal(true)\n                        }}\n                        onEditFolder={(folder) => {\n                            setEditingFolder(folder)\n                            setShowFolderModal(true)\n                        }}\n                        onDeleteFolder={handleDeleteFolder}\n                    />\n\n                    {/* 右侧资产网格 */}\n                    <AssetGrid\n                        characters={characters}\n                        locations={locations}\n                        voices={voices}\n                        loading={loading}\n                        onAddCharacter={() => setShowAddCharacter(true)}\n                        onAddLocation={() => setShowAddLocation(true)}\n                        onAddVoice={() => setShowAddVoice(true)}\n                        onDownloadAll={handleDownloadAll}\n                        isDownloading={isDownloading}\n                        selectedFolderId={selectedFolderId}\n                        onImageClick={setPreviewImage}\n                        onImageEdit={handleOpenImageEdit}\n                        onVoiceDesign={handleOpenVoiceDesign}\n                        onCharacterEdit={handleOpenCharacterEdit}\n                        onLocationEdit={handleOpenLocationEdit}\n                        onVoiceSelect={(characterId) => setVoicePickerCharacterId(characterId)}\n                    />\n                </div>\n            </div>\n\n            {/* 新建角色弹窗 */}\n            {showAddCharacter && (\n                <CharacterCreationModal\n                    mode=\"asset-hub\"\n                    folderId={selectedFolderId}\n                    onClose={() => setShowAddCharacter(false)}\n                    onSuccess={() => {\n                        setShowAddCharacter(false)\n                        queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })\n                    }}\n                />\n            )}\n\n            {/* 新建场景弹窗 */}\n            {showAddLocation && (\n                <LocationCreationModal\n                    mode=\"asset-hub\"\n                    folderId={selectedFolderId}\n                    onClose={() => setShowAddLocation(false)}\n                    onSuccess={() => {\n                        setShowAddLocation(false)\n                        queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.locations() })\n                    }}\n                />\n            )}\n\n            {/* 文件夹编辑弹窗 */}\n            {showFolderModal && (\n                <FolderModal\n                    folder={editingFolder}\n                    onClose={() => {\n                        setShowFolderModal(false)\n                        setEditingFolder(null)\n                    }}\n                    onSave={(name) => {\n                        if (editingFolder) {\n                            handleUpdateFolder(editingFolder.id, name)\n                        } else {\n                            handleCreateFolder(name)\n                        }\n                    }}\n                />\n            )}\n\n            {/* 图片预览弹窗 */}\n            {previewImage && (\n                <ImagePreviewModal\n                    imageUrl={previewImage}\n                    onClose={() => setPreviewImage(null)}\n                />\n            )}\n\n            {/* 图片编辑弹窗 */}\n            {imageEditModal && (\n                <ImageEditModal\n                    type={imageEditModal.type}\n                    name={imageEditModal.name}\n                    onClose={() => setImageEditModal(null)}\n                    onConfirm={handleImageEdit}\n                />\n            )}\n\n            {/* AI 声音设计对话框 */}\n            {voiceDesignCharacter && (\n                <VoiceDesignDialog\n                    isOpen={!!voiceDesignCharacter}\n                    speaker={voiceDesignCharacter.name}\n                    hasExistingVoice={voiceDesignCharacter.hasExistingVoice}\n                    onClose={() => setVoiceDesignCharacter(null)}\n                    onSave={handleVoiceDesignSave}\n                />\n            )}\n\n            {/* 角色编辑弹窗 */}\n            {characterEditModal && (\n                <CharacterEditModal\n                    mode=\"asset-hub\"\n                    characterId={characterEditModal.characterId}\n                    characterName={characterEditModal.characterName}\n                    appearanceId={characterEditModal.appearanceId}\n                    appearanceIndex={characterEditModal.appearanceIndex}\n                    changeReason={characterEditModal.changeReason}\n                    description={characterEditModal.description}\n                    onClose={() => setCharacterEditModal(null)}\n                    onSave={handleCharacterEditGenerate}\n                />\n            )}\n\n            {/* 场景编辑弹窗 */}\n            {locationEditModal && (\n                <LocationEditModal\n                    mode=\"asset-hub\"\n                    locationId={locationEditModal.locationId}\n                    locationName={locationEditModal.locationName}\n                    summary={locationEditModal.summary}\n                    imageIndex={locationEditModal.imageIndex}\n                    description={locationEditModal.description}\n                    onClose={() => setLocationEditModal(null)}\n                    onSave={handleLocationEditGenerate}\n                />\n            )}\n\n            {/* 新建音色弹窗 */}\n            {showAddVoice && (\n                <VoiceCreationModal\n                    isOpen={showAddVoice}\n                    folderId={selectedFolderId}\n                    onClose={() => setShowAddVoice(false)}\n                    onSuccess={() => {\n                        setShowAddVoice(false)\n                        queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.voices() })\n                    }}\n                />\n            )}\n\n            {/* 从音色库选择弹窗 */}\n            {voicePickerCharacterId && (\n                <VoicePickerDialog\n                    isOpen={!!voicePickerCharacterId}\n                    onClose={() => setVoicePickerCharacterId(null)}\n                    onSelect={handleVoiceSelect}\n                />\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/app/[locale]/workspace/page.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useState, useEffect, useCallback } from 'react'\nimport { useSession } from 'next-auth/react'\nimport { useTranslations } from 'next-intl'\nimport Navbar from '@/components/Navbar'\nimport ConfirmDialog from '@/components/ConfirmDialog'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon, IconGradientDefs } from '@/components/ui/icons'\nimport { shouldGuideToModelSetup } from '@/lib/workspace/model-setup'\nimport { Link, useRouter } from '@/i18n/navigation'\nimport { apiFetch } from '@/lib/api-fetch'\n\ninterface ProjectStats {\n  episodes: number\n  images: number\n  videos: number\n  panels: number\n  firstEpisodePreview: string | null\n}\n\ninterface Project {\n  id: string\n  name: string\n  description: string | null\n  createdAt: string\n  updatedAt: string\n  totalCost?: number  // 项目总费用（CNY）\n  stats?: ProjectStats\n}\n\ninterface Pagination {\n  page: number\n  pageSize: number\n  total: number\n  totalPages: number\n}\n\nconst PAGE_SIZE = 7 // 加上新建项目按钮正好8个，4列布局下2行\nconst DEFAULT_BILLING_CURRENCY = 'CNY'\n\nfunction formatProjectCost(amount: number, currency = DEFAULT_BILLING_CURRENCY): string {\n  if (currency === 'USD') return `$${amount.toFixed(2)}`\n  return `¥${amount.toFixed(2)}`\n}\n\nexport default function WorkspacePage() {\n  const { data: session, status } = useSession()\n  const router = useRouter()\n  const [projects, setProjects] = useState<Project[]>([])\n  const [loading, setLoading] = useState(true)\n  const [showCreateModal, setShowCreateModal] = useState(false)\n  const [createLoading, setCreateLoading] = useState(false)\n  const [formData, setFormData] = useState({\n    name: '',\n    description: ''\n  })\n  const [editingProject, setEditingProject] = useState<Project | null>(null)\n  const [showEditModal, setShowEditModal] = useState(false)\n  const [editFormData, setEditFormData] = useState({\n    name: '',\n    description: ''\n  })\n  const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null)\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\n  const [projectToDelete, setProjectToDelete] = useState<Project | null>(null)\n\n  // 分页和搜索状态\n  const [pagination, setPagination] = useState<Pagination>({ page: 1, pageSize: PAGE_SIZE, total: 0, totalPages: 0 })\n  const [searchQuery, setSearchQuery] = useState('')\n  const [searchInput, setSearchInput] = useState('')\n  const [modelNotConfigured, setModelNotConfigured] = useState(false)\n\n  const t = useTranslations('workspace')\n  const tc = useTranslations('common')\n\n  // 检查用户是否已登录\n  useEffect(() => {\n    if (status === 'loading') return\n    if (!session) {\n      router.push({ pathname: '/auth/signin' })\n      return\n    }\n  }, [session, status, router])\n\n  // 获取项目列表\n  const fetchProjects = useCallback(async (page: number = 1, search: string = '') => {\n    try {\n      setLoading(true)\n      const params = new URLSearchParams({\n        page: page.toString(),\n        pageSize: PAGE_SIZE.toString()\n      })\n      if (search.trim()) {\n        params.set('search', search.trim())\n      }\n\n      const response = await apiFetch(`/api/projects?${params}`)\n      if (response.ok) {\n        const data = await response.json()\n        setProjects(data.projects)\n        setPagination(data.pagination)\n      }\n    } catch (error) {\n      _ulogError('获取项目失败:', error)\n    } finally {\n      setLoading(false)\n    }\n  }, [])\n\n  // 初始加载和搜索/分页变化时重新获取\n  useEffect(() => {\n    if (session) {\n      fetchProjects(pagination.page, searchQuery)\n    }\n  }, [session, pagination.page, searchQuery, fetchProjects])\n\n  // 搜索处理\n  const handleSearch = () => {\n    setSearchQuery(searchInput)\n    setPagination(prev => ({ ...prev, page: 1 }))\n  }\n\n  // 打开新建项目弹窗并检测模型配置\n  const openCreateModal = useCallback(() => {\n    setShowCreateModal(true)\n    // 异步检测模型配置状态\n    void (async () => {\n      try {\n        const res = await apiFetch('/api/user-preference')\n        if (res.ok) {\n          const payload: unknown = await res.json()\n          setModelNotConfigured(shouldGuideToModelSetup(payload))\n        }\n      } catch {\n        // 忽略检测失败\n      }\n    })()\n  }, [])\n\n  // 分页处理\n  const handlePageChange = (newPage: number) => {\n    setPagination(prev => ({ ...prev, page: newPage }))\n  }\n\n  const handleCreateProject = async (e: React.FormEvent) => {\n    e.preventDefault()\n    if (!formData.name.trim()) return\n\n    setCreateLoading(true)\n    try {\n      const response = await apiFetch('/api/projects', {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json'\n        },\n        body: JSON.stringify({\n          ...formData,\n          mode: 'novel-promotion' // 固定为 novel-promotion\n        })\n      })\n\n      if (response.ok) {\n        let shouldOpenModelSetup = true\n        const preferenceResponse = await apiFetch('/api/user-preference')\n        if (preferenceResponse.ok) {\n          const preferencePayload: unknown = await preferenceResponse.json()\n          shouldOpenModelSetup = shouldGuideToModelSetup(preferencePayload)\n        } else {\n          _ulogError('获取用户偏好失败:', { status: preferenceResponse.status })\n        }\n\n        // 创建成功后刷新第一页\n        setSearchQuery('')\n        setSearchInput('')\n        setPagination(prev => ({ ...prev, page: 1 }))\n        void fetchProjects(1, '')\n        setShowCreateModal(false)\n        setFormData({ name: '', description: '' })\n\n        if (shouldOpenModelSetup) {\n          alert(t('analysisModelRequiredAfterCreate'))\n          router.push({ pathname: '/profile' })\n        }\n      } else {\n        alert(t('createFailed'))\n      }\n    } catch (error) {\n      _ulogError('创建项目失败:', error)\n      alert(t('createFailed'))\n    } finally {\n      setCreateLoading(false)\n    }\n  }\n\n  const formatDate = (dateString: string) => {\n    const date = new Date(dateString)\n    // 转换为北京时间 (UTC+8)\n    const beijingTime = new Date(date.getTime() + 8 * 60 * 60 * 1000)\n    return beijingTime.toLocaleDateString('zh-CN', {\n      year: 'numeric',\n      month: 'short',\n      day: 'numeric',\n      hour: '2-digit',\n      minute: '2-digit',\n      timeZone: 'Asia/Shanghai'\n    })\n  }\n\n  const handleEditProject = async (e: React.FormEvent) => {\n    e.preventDefault()\n    if (!editingProject || !editFormData.name.trim()) return\n\n    setCreateLoading(true)\n    try {\n      const response = await apiFetch(`/api/projects/${editingProject.id}`, {\n        method: 'PATCH',\n        headers: {\n          'Content-Type': 'application/json'\n        },\n        body: JSON.stringify(editFormData)\n      })\n\n      if (response.ok) {\n        const data = await response.json()\n        setProjects(projects.map(p => p.id === editingProject.id ? data.project : p))\n        setShowEditModal(false)\n        setEditingProject(null)\n        setEditFormData({ name: '', description: '' })\n      } else {\n        alert(t('updateFailed'))\n      }\n    } catch {\n      alert(t('updateFailed'))\n    } finally {\n      setCreateLoading(false)\n    }\n  }\n\n  const handleDeleteProject = async () => {\n    if (!projectToDelete) return\n\n    setDeletingProjectId(projectToDelete.id)\n    setShowDeleteConfirm(false)\n\n    try {\n      const response = await apiFetch(`/api/projects/${projectToDelete.id}`, {\n        method: 'DELETE'\n      })\n\n      if (response.ok) {\n        // 删除成功后重新获取当前页\n        fetchProjects(pagination.page, searchQuery)\n      } else {\n        alert(t('deleteFailed'))\n      }\n    } catch {\n      alert(t('deleteFailed'))\n    } finally {\n      setDeletingProjectId(null)\n      setProjectToDelete(null)\n    }\n  }\n\n  const openDeleteConfirm = (project: Project, e: React.MouseEvent) => {\n    e.preventDefault()  // 阻止 Link 导航\n    e.stopPropagation()\n    setProjectToDelete(project)\n    setShowDeleteConfirm(true)\n  }\n\n  const cancelDelete = () => {\n    setShowDeleteConfirm(false)\n    setProjectToDelete(null)\n  }\n\n  const openEditModal = (project: Project, e: React.MouseEvent) => {\n    e.preventDefault()  // 阻止 Link 导航\n    e.stopPropagation()\n    setEditingProject(project)\n    setEditFormData({\n      name: project.name,\n      description: project.description || ''\n    })\n    setShowEditModal(true)\n  }\n\n  if (status === 'loading' || !session) {\n    return (\n      <div className=\"glass-page min-h-screen flex items-center justify-center\">\n        <div className=\"text-[var(--glass-text-secondary)]\">{tc('loading')}</div>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"glass-page min-h-screen\">\n      {/* Header - 统一导航栏 */}\n      <Navbar />\n\n      {/* Main Content */}\n      <main className=\"max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-10 py-8\">\n        <div className=\"mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4\">\n          <div>\n            <h1 className=\"text-3xl font-bold text-[var(--glass-text-primary)] mb-2\">{t('title')}</h1>\n            <p className=\"text-[var(--glass-text-secondary)]\">{t('subtitle')}</p>\n          </div>\n\n          {/* 搜索框 */}\n          <div className=\"flex gap-2\">\n            <input\n              type=\"text\"\n              value={searchInput}\n              onChange={(e) => setSearchInput(e.target.value)}\n              onKeyDown={(e) => e.key === 'Enter' && handleSearch()}\n              placeholder={t('searchPlaceholder')}\n              className=\"glass-input-base w-64 px-3 py-2\"\n            />\n            <button\n              onClick={handleSearch}\n              className=\"glass-btn-base glass-btn-primary px-4 py-2\"\n            >\n              {t('searchButton')}\n            </button>\n            {searchQuery && (\n              <button\n                onClick={() => {\n                  setSearchInput('')\n                  setSearchQuery('')\n                  setPagination(prev => ({ ...prev, page: 1 }))\n                }}\n                className=\"glass-btn-base glass-btn-secondary px-4 py-2\"\n              >\n                {t('clearButton')}\n              </button>\n            )}\n          </div>\n        </div>\n\n        {/* Projects Grid */}\n        <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6\">\n          {/* New Project Card */}\n          <div\n            onClick={() => openCreateModal()}\n            className=\"glass-surface p-6 cursor-pointer group flex items-center justify-center bg-gradient-to-br from-blue-500/5 via-cyan-500/5 to-blue-600/5 hover:from-blue-500/10 hover:via-cyan-500/10 hover:to-blue-600/10 transition-all duration-300\"\n          >\n            <div className=\"flex flex-col items-center gap-3\">\n              <div className=\"w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:shadow-blue-500/40 group-hover:scale-110 transition-all duration-300\">\n                <AppIcon name=\"plus\" className=\"w-6 h-6 text-white\" />\n              </div>\n              <span className=\"text-sm font-medium text-[var(--glass-text-secondary)] group-hover:text-[var(--glass-text-primary)] transition-colors\">{t('newProject')}</span>\n            </div>\n          </div>\n\n          {/* Project Cards */}\n          {loading ? (\n            // Loading skeleton\n            Array.from({ length: 3 }).map((_, index) => (\n              <div key={index} className=\"glass-surface p-6 animate-pulse\">\n                <div className=\"h-4 bg-[var(--glass-bg-muted)] rounded mb-3\"></div>\n                <div className=\"h-3 bg-[var(--glass-bg-muted)] rounded mb-2\"></div>\n                <div className=\"h-3 bg-[var(--glass-bg-muted)] rounded w-2/3\"></div>\n              </div>\n            ))\n          ) : (\n            projects.map((project) => (\n              <Link\n                key={project.id}\n                href={{ pathname: `/workspace/${project.id}` }}\n                className=\"glass-surface cursor-pointer relative group block hover:border-[var(--glass-tone-info-fg)]/40 transition-all duration-300 overflow-hidden\"\n              >\n                {/* 悬停光效 */}\n                <div className=\"absolute inset-0 rounded-[inherit] bg-gradient-to-br from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none\" />\n\n                <div className=\"p-5 relative z-10\">\n                  {/* 操作按钮 */}\n                  <div className=\"absolute top-3 right-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20\">\n                    <button\n                      onClick={(e) => openEditModal(project, e)}\n                      className=\"glass-btn-base glass-btn-secondary p-2 rounded-lg transition-colors\"\n                      title={t('editProject')}\n                    >\n                      <AppIcon name=\"editSquare\" className=\"w-4 h-4 text-[var(--glass-tone-info-fg)]\" />\n                    </button>\n                    <button\n                      onClick={(e) => openDeleteConfirm(project, e)}\n                      className=\"glass-btn-base glass-btn-secondary p-2 rounded-lg transition-colors\"\n                      title={t('deleteProject')}\n                      disabled={deletingProjectId === project.id}\n                    >\n                      {deletingProjectId === project.id ? (\n                        <TaskStatusInline\n                          state={resolveTaskPresentationState({\n                            phase: 'processing',\n                            intent: 'process',\n                            resource: 'text',\n                            hasOutput: true,\n                          })}\n                          className=\"[&>span]:sr-only\"\n                        />\n                      ) : (\n                        <AppIcon name=\"trash\" className=\"w-4 h-4 text-[var(--glass-tone-danger-fg)]\" />\n                      )}\n                    </button>\n                  </div>\n\n                  {/* 标题 */}\n                  <h3 className=\"text-lg font-bold text-[var(--glass-text-primary)] mb-2 line-clamp-2 pr-20 group-hover:text-[var(--glass-tone-info-fg)] transition-colors\">\n                    {project.name}\n                  </h3>\n\n                  {/* 描述：优先用户描述，fallback 到第一集故事 */}\n                  {(project.description || project.stats?.firstEpisodePreview) && (\n                    <div className=\"flex items-start gap-2 mb-4\">\n                      <AppIcon name=\"fileText\" className=\"w-4 h-4 text-[var(--glass-text-tertiary)] mt-0.5 flex-shrink-0\" />\n                      <p className=\"text-sm text-[var(--glass-text-secondary)] line-clamp-2 leading-relaxed\">\n                        {project.description || project.stats?.firstEpisodePreview}\n                      </p>\n                    </div>\n                  )}\n\n                  {/* 统计信息 - 整行统一渐变 */}\n                  {project.stats && (project.stats.episodes > 0 || project.stats.images > 0 || project.stats.videos > 0) ? (\n                    <div className=\"flex items-center gap-2 mb-3\">\n                      {/* 共享渐变定义 */}\n                      <IconGradientDefs className=\"w-0 h-0 absolute\" aria-hidden=\"true\" />\n                      <AppIcon name=\"statsBarGradient\" className=\"w-4 h-4 flex-shrink-0\" />\n                      <div className=\"flex items-center gap-3 text-sm font-semibold bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent\">\n                        {project.stats.episodes > 0 && (\n                          <span className=\"flex items-center gap-1\" title={t('statsEpisodes')}>\n                            <AppIcon name=\"statsEpisodeGradient\" className=\"w-3.5 h-3.5\" />\n                            {project.stats.episodes}\n                          </span>\n                        )}\n                        {project.stats.images > 0 && (\n                          <span className=\"flex items-center gap-1\" title={t('statsImages')}>\n                            <AppIcon name=\"statsImageGradient\" className=\"w-3.5 h-3.5\" />\n                            {project.stats.images}\n                          </span>\n                        )}\n                        {project.stats.videos > 0 && (\n                          <span className=\"flex items-center gap-1\" title={t('statsVideos')}>\n                            <AppIcon name=\"statsVideoGradient\" className=\"w-3.5 h-3.5\" />\n                            {project.stats.videos}\n                          </span>\n                        )}\n                      </div>\n                    </div>\n                  ) : (\n                    <div className=\"flex items-center gap-2.5 mb-3\">\n                      <AppIcon name=\"statsBar\" className=\"w-4 h-4 text-[var(--glass-text-tertiary)] flex-shrink-0\" />\n                      <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('noContent')}</span>\n                    </div>\n                  )}\n\n                  {/* 底部信息 */}\n                  <div className=\"flex items-center justify-between text-[11px] text-[var(--glass-text-tertiary)]\">\n                    <div className=\"flex items-center gap-1\">\n                      <AppIcon name=\"clock\" className=\"w-3 h-3\" />\n                      {formatDate(project.updatedAt)}\n                    </div>\n                    {project.totalCost !== undefined && project.totalCost > 0 && (\n                      <span className=\"text-[11px] font-mono font-medium text-[var(--glass-text-secondary)]\">\n                        {formatProjectCost(project.totalCost)}\n                      </span>\n                    )}\n                  </div>\n                </div>\n              </Link>\n            ))\n          )}\n        </div>\n\n        {/* Empty State */}\n        {!loading && projects.length === 0 && (\n          <div className=\"text-center py-12\">\n            <div className=\"w-16 h-16 bg-[var(--glass-bg-muted)] rounded-xl flex items-center justify-center mx-auto mb-4\">\n              <AppIcon name=\"folderCards\" className=\"w-8 h-8 text-[var(--glass-text-tertiary)]\" />\n            </div>\n            <h3 className=\"text-lg font-medium text-[var(--glass-text-primary)] mb-2\">\n              {searchQuery ? t('noResults') : t('noProjects')}\n            </h3>\n            <p className=\"text-[var(--glass-text-secondary)] mb-6\">\n              {searchQuery ? t('noResultsDesc') : t('noProjectsDesc')}\n            </p>\n            {!searchQuery && (\n              <button\n                onClick={() => openCreateModal()}\n                className=\"glass-btn-base glass-btn-primary px-6 py-3\"\n              >\n                {t('newProject')}\n              </button>\n            )}\n          </div>\n        )}\n\n        {/* 分页控件 */}\n        {!loading && pagination.totalPages > 1 && (\n          <div className=\"mt-8 flex items-center justify-center gap-2\">\n            <button\n              onClick={() => handlePageChange(pagination.page - 1)}\n              disabled={pagination.page <= 1}\n              className=\"glass-btn-base glass-btn-secondary px-3 py-2 disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n              <AppIcon name=\"chevronLeft\" className=\"w-5 h-5\" />\n            </button>\n\n            {/* 页码按钮 */}\n            {Array.from({ length: pagination.totalPages }, (_, i) => i + 1)\n              .filter(page => {\n                // 显示第一页、最后一页、当前页及其前后两页\n                return page === 1 ||\n                  page === pagination.totalPages ||\n                  Math.abs(page - pagination.page) <= 2\n              })\n              .map((page, index, array) => (\n                <span key={page} className=\"flex items-center\">\n                  {/* 显示省略号 */}\n                  {index > 0 && array[index - 1] !== page - 1 && (\n                    <span className=\"px-2 text-[var(--glass-text-tertiary)]\">...</span>\n                  )}\n                  <button\n                    onClick={() => handlePageChange(page)}\n                    className={`glass-btn-base px-4 py-2 ${page === pagination.page\n                      ? 'glass-btn-primary'\n                      : 'glass-btn-secondary'\n                      }`}\n                  >\n                    {page}\n                  </button>\n                </span>\n              ))}\n\n            <button\n              onClick={() => handlePageChange(pagination.page + 1)}\n              disabled={pagination.page >= pagination.totalPages}\n              className=\"glass-btn-base glass-btn-secondary px-3 py-2 disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n              <AppIcon name=\"chevronRight\" className=\"w-5 h-5\" />\n            </button>\n\n            <span className=\"ml-4 text-sm text-[var(--glass-text-tertiary)]\">\n              {t('totalProjects', { count: pagination.total })}\n            </span>\n          </div>\n        )}\n      </main>\n\n      {/* Create Project Modal - 简化版，只有名称和描述 */}\n      {showCreateModal && (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 backdrop-blur-sm\">\n          <div className=\"glass-surface-modal p-6 w-full max-w-md mx-4\">\n            <h2 className=\"text-xl font-bold text-[var(--glass-text-primary)] mb-4\">{t('createProject')}</h2>\n            {modelNotConfigured && (\n              <div className=\"flex items-start gap-2 mb-4 px-3 py-2.5 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400\">\n                <AppIcon name=\"alert\" className=\"w-4 h-4 shrink-0 mt-0.5\" />\n                <span className=\"text-[12px] leading-relaxed\">\n                  {t('modelNotConfigured.before')}\n                  <Link\n                    href={{ pathname: '/profile' }}\n                    className=\"font-semibold underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-300 mx-0.5\"\n                    onClick={() => setShowCreateModal(false)}\n                  >\n                    {t('modelNotConfigured.link')}\n                  </Link>\n                  {t('modelNotConfigured.after')}\n                </span>\n              </div>\n            )}\n            <form onSubmit={handleCreateProject}>\n              <div className=\"mb-4\">\n                <label htmlFor=\"name\" className=\"glass-field-label block mb-2\">\n                  {t('projectName')} *\n                </label>\n                <input\n                  id=\"name\"\n                  type=\"text\"\n                  value={formData.name}\n                  onChange={(e) => setFormData({ ...formData, name: e.target.value })}\n                  className=\"glass-input-base w-full px-3 py-2\"\n                  placeholder={t('projectNamePlaceholder')}\n                  maxLength={100}\n                  required\n                  autoFocus\n                />\n              </div>\n              <div className=\"mb-6\">\n                <label htmlFor=\"description\" className=\"glass-field-label block mb-2\">\n                  {t('projectDescription')}\n                </label>\n                <textarea\n                  id=\"description\"\n                  value={formData.description}\n                  onChange={(e) => setFormData({ ...formData, description: e.target.value })}\n                  className=\"glass-textarea-base w-full px-3 py-2\"\n                  placeholder={t('projectDescriptionPlaceholder')}\n                  rows={3}\n                  maxLength={500}\n                />\n              </div>\n              <div className=\"flex justify-end space-x-3\">\n                <button\n                  type=\"button\"\n                  onClick={() => {\n                    setShowCreateModal(false)\n                    setFormData({ name: '', description: '' })\n                  }}\n                  className=\"glass-btn-base glass-btn-secondary px-4 py-2\"\n                  disabled={createLoading}\n                >\n                  {tc('cancel')}\n                </button>\n                <button\n                  type=\"submit\"\n                  className=\"glass-btn-base glass-btn-primary px-4 py-2 disabled:opacity-50\"\n                  disabled={createLoading || !formData.name.trim()}\n                >\n                  {createLoading ? t('creating') : t('createProject')}\n                </button>\n              </div>\n            </form>\n          </div>\n        </div>\n      )}\n\n      {/* Edit Project Modal */}\n      {showEditModal && editingProject && (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 backdrop-blur-sm\">\n          <div className=\"glass-surface-modal p-6 w-full max-w-md mx-4\">\n            <h2 className=\"text-xl font-bold text-[var(--glass-text-primary)] mb-4\">{t('editProject')}</h2>\n            <form onSubmit={handleEditProject}>\n              <div className=\"mb-4\">\n                <label htmlFor=\"edit-name\" className=\"glass-field-label block mb-2\">\n                  {t('projectName')} *\n                </label>\n                <input\n                  id=\"edit-name\"\n                  type=\"text\"\n                  value={editFormData.name}\n                  onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })}\n                  className=\"glass-input-base w-full px-3 py-2\"\n                  placeholder={t('projectNamePlaceholder')}\n                  maxLength={100}\n                  required\n                />\n              </div>\n              <div className=\"mb-6\">\n                <label htmlFor=\"edit-description\" className=\"glass-field-label block mb-2\">\n                  {t('projectDescription')}\n                </label>\n                <textarea\n                  id=\"edit-description\"\n                  value={editFormData.description}\n                  onChange={(e) => setEditFormData({ ...editFormData, description: e.target.value })}\n                  className=\"glass-textarea-base w-full px-3 py-2\"\n                  placeholder={t('projectDescriptionPlaceholder')}\n                  rows={3}\n                  maxLength={500}\n                />\n              </div>\n              <div className=\"flex justify-end space-x-3\">\n                <button\n                  type=\"button\"\n                  onClick={() => {\n                    setShowEditModal(false)\n                    setEditingProject(null)\n                    setEditFormData({ name: '', description: '' })\n                  }}\n                  className=\"glass-btn-base glass-btn-secondary px-4 py-2\"\n                  disabled={createLoading}\n                >\n                  {tc('cancel')}\n                </button>\n                <button\n                  type=\"submit\"\n                  className=\"glass-btn-base glass-btn-primary px-4 py-2 disabled:opacity-50\"\n                  disabled={createLoading || !editFormData.name.trim()}\n                >\n                  {createLoading ? t('saving') : tc('save')}\n                </button>\n              </div>\n            </form>\n          </div>\n        </div>\n      )}\n\n      {/* 删除确认对话框 */}\n      <ConfirmDialog\n        show={showDeleteConfirm}\n        title={t('deleteProject')}\n        message={t('deleteConfirm', { name: projectToDelete?.name || '' })}\n        confirmText={tc('delete')}\n        cancelText={tc('cancel')}\n        type=\"danger\"\n        onConfirm={handleDeleteProject}\n        onCancel={cancelDelete}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/api/admin/download-logs/route.ts",
    "content": "import { NextResponse } from 'next/server'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\nimport { readAllLogs } from '@/lib/logging/file-writer'\n\nexport const dynamic = 'force-dynamic'\n\n// GET - 下载所有日志\nexport const GET = apiHandler(async () => {\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n\n    const logs = await readAllLogs()\n    if (!logs) {\n        return NextResponse.json({ error: 'No logs available' }, { status: 404 })\n    }\n\n    const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)\n    const filename = `waoowaoo-logs-${timestamp}.txt`\n\n    return new NextResponse(logs, {\n        status: 200,\n        headers: {\n            'Content-Type': 'text/plain; charset=utf-8',\n            'Content-Disposition': `attachment; filename=\"${filename}\"`,\n        },\n    })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/ai-design-character/route.ts",
    "content": "import { createHash } from 'crypto'\nimport { NextRequest } from 'next/server'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\nimport { getUserModelConfig } from '@/lib/config-service'\n\n/**\n * 资产中心 - AI 设计角色描述（任务化）\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = (await request.json().catch(() => ({}))) as Record<string, unknown>\n  const userInstruction = typeof body.userInstruction === 'string' ? body.userInstruction.trim() : ''\n  if (!userInstruction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const userConfig = await getUserModelConfig(session.user.id)\n  if (!userConfig.analysisModel) {\n    throw new ApiError('MISSING_CONFIG')\n  }\n\n  const dedupeDigest = createHash('sha1')\n    .update(`${session.user.id}:character:${userInstruction}`)\n    .digest('hex')\n    .slice(0, 16)\n\n  const payload = {\n    userInstruction,\n    analysisModel: userConfig.analysisModel,\n    displayMode: 'detail' as const}\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId: 'global-asset-hub',\n    type: TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER,\n    targetType: 'GlobalAssetHubCharacterDesign',\n    targetId: session.user.id,\n    routePath: '/api/asset-hub/ai-design-character',\n    body: payload,\n    dedupeKey: `asset_hub_ai_design_character:${dedupeDigest}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/ai-design-location/route.ts",
    "content": "import { createHash } from 'crypto'\nimport { NextRequest } from 'next/server'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\nimport { getUserModelConfig } from '@/lib/config-service'\n\n/**\n * 资产中心 - AI 设计场景描述（任务化）\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = (await request.json().catch(() => ({}))) as Record<string, unknown>\n  const userInstruction = typeof body.userInstruction === 'string' ? body.userInstruction.trim() : ''\n  if (!userInstruction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const userConfig = await getUserModelConfig(session.user.id)\n  if (!userConfig.analysisModel) {\n    throw new ApiError('MISSING_CONFIG')\n  }\n\n  const dedupeDigest = createHash('sha1')\n    .update(`${session.user.id}:location:${userInstruction}`)\n    .digest('hex')\n    .slice(0, 16)\n\n  const payload = {\n    userInstruction,\n    analysisModel: userConfig.analysisModel,\n    displayMode: 'detail' as const}\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId: 'global-asset-hub',\n    type: TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,\n    targetType: 'GlobalAssetHubLocationDesign',\n    targetId: session.user.id,\n    routePath: '/api/asset-hub/ai-design-location',\n    body: payload,\n    dedupeKey: `asset_hub_ai_design_location:${dedupeDigest}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/ai-modify-character/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\n/**\n * 资产中心 - AI 修改角色形象描述（任务化）\n * POST /api/asset-hub/ai-modify-character\n * body: { characterId, appearanceIndex, currentDescription, modifyInstruction }\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const payload = await request.json()\n  const { characterId, appearanceIndex, currentDescription, modifyInstruction } = payload ?? {}\n\n  if (!characterId || appearanceIndex === undefined || !currentDescription || !modifyInstruction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const character = await prisma.globalCharacter.findUnique({\n    where: { id: characterId },\n    select: { id: true, userId: true }})\n  if (!character || character.userId !== session.user.id) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId: 'global-asset-hub',\n    type: TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER,\n    targetType: 'GlobalCharacter',\n    targetId: characterId,\n    routePath: '/api/asset-hub/ai-modify-character',\n    body: { characterId, appearanceIndex, currentDescription, modifyInstruction },\n    dedupeKey: `asset_hub_ai_modify_character:${characterId}:${appearanceIndex}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/ai-modify-location/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\n/**\n * 资产中心 - AI 修改场景描述（任务化）\n * POST /api/asset-hub/ai-modify-location\n * body: { locationId, imageIndex, currentDescription, modifyInstruction }\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const payload = await request.json()\n  const { locationId, imageIndex, currentDescription, modifyInstruction } = payload ?? {}\n\n  if (!locationId || imageIndex === undefined || !currentDescription || !modifyInstruction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const location = await prisma.globalLocation.findUnique({\n    where: { id: locationId },\n    select: { id: true, userId: true, name: true }})\n  if (!location || location.userId !== session.user.id) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId: 'global-asset-hub',\n    type: TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION,\n    targetType: 'GlobalLocation',\n    targetId: locationId,\n    routePath: '/api/asset-hub/ai-modify-location',\n    body: {\n      locationId,\n      locationName: location.name,\n      imageIndex,\n      currentDescription,\n      modifyInstruction},\n    dedupeKey: `asset_hub_ai_modify_location:${locationId}:${imageIndex}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/appearances/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\nimport { PRIMARY_APPEARANCE_INDEX, isArtStyleValue } from '@/lib/constants'\nimport { buildCharacterDescriptionFields } from '@/lib/assets/description-fields'\n\ninterface AppearanceBody {\n    characterId?: string\n    changeReason?: string\n    description?: string\n    appearanceIndex?: number\n    artStyle?: string\n}\n\ninterface GlobalCharacterAppearanceSummary {\n    id: string\n    appearanceIndex: number\n    artStyle?: string | null\n    description?: string | null\n    descriptions?: string | null\n}\n\ninterface GlobalCharacterRecord {\n    appearances?: GlobalCharacterAppearanceSummary[]\n}\n\ninterface AssetHubAppearancesDb {\n    globalCharacter: {\n        findFirst(args: Record<string, unknown>): Promise<GlobalCharacterRecord | null>\n    }\n    globalCharacterAppearance: {\n        create(args: Record<string, unknown>): Promise<unknown>\n        findFirst(args: Record<string, unknown>): Promise<GlobalCharacterAppearanceSummary | null>\n        update(args: Record<string, unknown>): Promise<unknown>\n        deleteMany(args: Record<string, unknown>): Promise<unknown>\n    }\n}\n\n/**\n * POST /api/asset-hub/appearances\n * 添加子形象\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n    const db = prisma as unknown as AssetHubAppearancesDb\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = (await request.json()) as AppearanceBody\n    const { characterId, changeReason, description, artStyle } = body\n\n    if (!characterId || !changeReason) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    const character = await db.globalCharacter.findFirst({\n        where: { id: characterId, userId: session.user.id },\n        include: { appearances: true }\n    })\n    if (!character) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    const maxIndex = character.appearances?.reduce((max, appearance) => Math.max(max, appearance.appearanceIndex), 0) || 0\n    const nextIndex = maxIndex + 1\n    const inputArtStyle = typeof artStyle === 'string' ? artStyle.trim() : ''\n    const inheritedArtStyle = (() => {\n        if (inputArtStyle) return inputArtStyle\n        const primaryAppearance = character.appearances?.find((item) => item.appearanceIndex === PRIMARY_APPEARANCE_INDEX)\n            || character.appearances?.[0]\n        const stored = typeof primaryAppearance?.artStyle === 'string' ? primaryAppearance.artStyle.trim() : ''\n        return stored\n    })()\n    if (!isArtStyleValue(inheritedArtStyle)) {\n        throw new ApiError('INVALID_PARAMS', {\n            code: 'INVALID_ART_STYLE',\n            message: 'artStyle is required and must be a supported value',\n        })\n    }\n\n    const appearance = await db.globalCharacterAppearance.create({\n        data: {\n            characterId,\n            appearanceIndex: nextIndex,\n            changeReason,\n            artStyle: inheritedArtStyle,\n            description: description?.trim() || null,\n            descriptions: description?.trim() ? JSON.stringify([description.trim()]) : null,\n            imageUrls: encodeImageUrls([]),\n            previousImageUrls: encodeImageUrls([])}\n    })\n\n    return NextResponse.json({ success: true, appearance })\n})\n\n/**\n * PATCH /api/asset-hub/appearances\n * 更新子形象描述\n */\nexport const PATCH = apiHandler(async (request: NextRequest) => {\n    const db = prisma as unknown as AssetHubAppearancesDb\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = (await request.json()) as AppearanceBody\n    const { characterId, appearanceIndex, description, changeReason, artStyle } = body\n\n    if (!characterId || appearanceIndex === undefined) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    const character = await db.globalCharacter.findFirst({\n        where: { id: characterId, userId: session.user.id }\n    })\n    if (!character) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    const appearance = await db.globalCharacterAppearance.findFirst({\n        where: { characterId, appearanceIndex }\n    })\n    if (!appearance) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    const updateData: Record<string, unknown> = {}\n    if (description !== undefined) {\n        const nextDescription = description.trim()\n        const descriptionFields = buildCharacterDescriptionFields({\n            descriptions: appearance.descriptions ?? null,\n            fallbackDescription: appearance.description ?? null,\n            index: 0,\n            nextDescription,\n        })\n        updateData.description = descriptionFields.description\n        updateData.descriptions = descriptionFields.descriptions\n    }\n    if (changeReason !== undefined) {\n        updateData.changeReason = changeReason\n    }\n    if (artStyle !== undefined) {\n        if (typeof artStyle !== 'string') {\n            throw new ApiError('INVALID_PARAMS', {\n                code: 'INVALID_ART_STYLE',\n                message: 'artStyle must be a supported value',\n            })\n        }\n        const normalizedArtStyle = artStyle.trim()\n        if (!isArtStyleValue(normalizedArtStyle)) {\n            throw new ApiError('INVALID_PARAMS', {\n                code: 'INVALID_ART_STYLE',\n                message: 'artStyle must be a supported value',\n            })\n        }\n        updateData.artStyle = normalizedArtStyle\n    }\n\n    await db.globalCharacterAppearance.update({\n        where: { id: appearance.id },\n        data: updateData\n    })\n\n    return NextResponse.json({ success: true })\n})\n\n/**\n * DELETE /api/asset-hub/appearances\n * 删除子形象\n */\nexport const DELETE = apiHandler(async (request: NextRequest) => {\n    const db = prisma as unknown as AssetHubAppearancesDb\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const { searchParams } = new URL(request.url)\n    const characterId = searchParams.get('characterId')\n    const appearanceIndex = searchParams.get('appearanceIndex')\n\n    if (!characterId || !appearanceIndex) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    const character = await db.globalCharacter.findFirst({\n        where: { id: characterId, userId: session.user.id }\n    })\n    if (!character) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    if (parseInt(appearanceIndex, 10) === PRIMARY_APPEARANCE_INDEX) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    await db.globalCharacterAppearance.deleteMany({\n        where: { characterId, appearanceIndex: parseInt(appearanceIndex, 10) }\n    })\n\n    return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/character-voice/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { uploadObject, generateUniqueKey, getSignedUrl } from '@/lib/storage'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface VoiceDesignPayload {\n    voiceId?: string\n    audioBase64?: string\n}\n\ninterface CharacterVoiceJsonBody {\n    characterId?: string\n    voiceDesign?: VoiceDesignPayload\n    voiceType?: string | null\n    voiceId?: string | null\n    customVoiceUrl?: string | null\n}\n\ninterface AssetHubCharacterVoiceDb {\n    globalCharacter: {\n        findFirst(args: Record<string, unknown>): Promise<{ id: string } | null>\n        update(args: Record<string, unknown>): Promise<unknown>\n    }\n}\n\n/**\n * POST /api/asset-hub/character-voice\n * 上传自定义音色音频\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n    const db = prisma as unknown as AssetHubCharacterVoiceDb\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const contentType = request.headers.get('content-type') || ''\n\n    // 处理 JSON 请求（AI 声音设计）\n    if (contentType.includes('application/json')) {\n        const body = (await request.json()) as CharacterVoiceJsonBody\n        const { characterId, voiceDesign } = body\n\n        if (!characterId || !voiceDesign) {\n            throw new ApiError('INVALID_PARAMS')\n        }\n\n        const { voiceId, audioBase64 } = voiceDesign\n        if (!voiceId || !audioBase64) {\n            throw new ApiError('INVALID_PARAMS')\n        }\n\n        // 验证角色属于用户\n        const character = await db.globalCharacter.findFirst({\n            where: { id: characterId, userId: session.user.id }\n        })\n        if (!character) {\n            throw new ApiError('NOT_FOUND')\n        }\n\n        const audioBuffer = Buffer.from(audioBase64, 'base64')\n        const key = generateUniqueKey(`global-voice/${session.user.id}/${characterId}`, 'wav')\n        const cosUrl = await uploadObject(audioBuffer, key)\n\n        await db.globalCharacter.update({\n            where: { id: characterId },\n            data: {\n                voiceType: 'qwen-designed',\n                voiceId: voiceId,\n                customVoiceUrl: cosUrl\n            }\n        })\n\n        const signedAudioUrl = getSignedUrl(cosUrl, 7200)\n\n        return NextResponse.json({\n            success: true,\n            audioUrl: signedAudioUrl\n        })\n    }\n\n    // 处理 FormData 请求（文件上传）\n    const formData = await request.formData()\n    const file = formData.get('file') as File\n    const characterId = formData.get('characterId') as string\n\n    if (!file || !characterId) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 验证角色属于用户\n    const character = await db.globalCharacter.findFirst({\n        where: { id: characterId, userId: session.user.id }\n    })\n    if (!character) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 验证文件类型\n    const allowedTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a']\n    if (!allowedTypes.includes(file.type) && !file.name.match(/\\.(mp3|wav|ogg|m4a)$/i)) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    const arrayBuffer = await file.arrayBuffer()\n    const buffer = Buffer.from(arrayBuffer)\n    const ext = file.name.split('.').pop()?.toLowerCase() || 'mp3'\n    const key = generateUniqueKey(`global-voice/${session.user.id}/${characterId}`, ext)\n    const audioUrl = await uploadObject(buffer, key)\n\n    await db.globalCharacter.update({\n        where: { id: characterId },\n        data: {\n            voiceType: 'uploaded',\n            voiceId: null,\n            customVoiceUrl: audioUrl\n        }\n    })\n\n    const signedAudioUrl = getSignedUrl(audioUrl, 7200)\n\n    return NextResponse.json({\n        success: true,\n        audioUrl: signedAudioUrl\n    })\n})\n\n/**\n * PATCH /api/asset-hub/character-voice\n * 更新角色音色设置\n */\nexport const PATCH = apiHandler(async (request: NextRequest) => {\n    const db = prisma as unknown as AssetHubCharacterVoiceDb\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = (await request.json()) as CharacterVoiceJsonBody\n    const { characterId, voiceType, voiceId, customVoiceUrl } = body\n\n    if (!characterId) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 验证角色属于用户\n    const character = await db.globalCharacter.findFirst({\n        where: { id: characterId, userId: session.user.id }\n    })\n    if (!character) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    await db.globalCharacter.update({\n        where: { id: characterId },\n        data: {\n            voiceType: voiceType || null,\n            voiceId: voiceId || null,\n            customVoiceUrl: customVoiceUrl || null\n        }\n    })\n\n    return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/characters/[characterId]/appearances/[appearanceIndex]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\nimport { PRIMARY_APPEARANCE_INDEX, isArtStyleValue } from '@/lib/constants'\n\n// 更新形象描述\nexport const PATCH = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ characterId: string; appearanceIndex: string }> }\n) => {\n    const { characterId, appearanceIndex } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const character = await prisma.globalCharacter.findUnique({\n        where: { id: characterId }\n    })\n\n    if (!character || character.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    const body = await request.json()\n    const { description, descriptionIndex, changeReason, artStyle } = body\n\n    const appearance = await prisma.globalCharacterAppearance.findFirst({\n        where: { characterId, appearanceIndex: parseInt(appearanceIndex, 10) }\n    })\n\n    if (!appearance) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    const updateData: Record<string, unknown> = {}\n\n    if (description !== undefined) {\n        const trimmedDescription = description.trim()\n        let descriptions: string[] = []\n        if (appearance.descriptions) {\n            try { descriptions = JSON.parse(appearance.descriptions) } catch { }\n        }\n        if (descriptions.length === 0) {\n            descriptions = [appearance.description || '']\n        }\n        if (descriptionIndex !== undefined && descriptionIndex !== null) {\n            descriptions[descriptionIndex] = trimmedDescription\n        } else {\n            descriptions[0] = trimmedDescription\n        }\n        updateData.descriptions = JSON.stringify(descriptions)\n        updateData.description = descriptions[0]\n    }\n\n    if (changeReason !== undefined) {\n        updateData.changeReason = changeReason\n    }\n    if (artStyle !== undefined) {\n        if (typeof artStyle !== 'string') {\n            throw new ApiError('INVALID_PARAMS', {\n                code: 'INVALID_ART_STYLE',\n                message: 'artStyle must be a supported value',\n            })\n        }\n        const normalizedArtStyle = artStyle.trim()\n        if (!isArtStyleValue(normalizedArtStyle)) {\n            throw new ApiError('INVALID_PARAMS', {\n                code: 'INVALID_ART_STYLE',\n                message: 'artStyle must be a supported value',\n            })\n        }\n        updateData.artStyle = normalizedArtStyle\n    }\n\n    await prisma.globalCharacterAppearance.update({\n        where: { id: appearance.id },\n        data: updateData\n    })\n\n    return NextResponse.json({ success: true })\n})\n\n// 添加新形象\nexport const POST = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ characterId: string; appearanceIndex: string }> }\n) => {\n    const { characterId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const character = await prisma.globalCharacter.findUnique({\n        where: { id: characterId },\n        include: { appearances: true }\n    })\n\n    if (!character || character.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    const body = await request.json()\n    const { description, changeReason, artStyle } = body\n\n    if (!description) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    const maxIndex = character.appearances.reduce((max, a) => Math.max(max, a.appearanceIndex), 0)\n    const newIndex = maxIndex + 1\n    const inputArtStyle = typeof artStyle === 'string' ? artStyle.trim() : ''\n    const fallbackArtStyle = (() => {\n        if (inputArtStyle) return inputArtStyle\n        const primaryAppearance = character.appearances.find((item) => item.appearanceIndex === PRIMARY_APPEARANCE_INDEX)\n            || character.appearances[0]\n        const stored = typeof primaryAppearance?.artStyle === 'string' ? primaryAppearance.artStyle.trim() : ''\n        return stored\n    })()\n    if (!isArtStyleValue(fallbackArtStyle)) {\n        throw new ApiError('INVALID_PARAMS', {\n            code: 'INVALID_ART_STYLE',\n            message: 'artStyle is required and must be a supported value',\n        })\n    }\n\n    const appearance = await prisma.globalCharacterAppearance.create({\n        data: {\n            characterId,\n            appearanceIndex: newIndex,\n            changeReason: changeReason || '形象变化',\n            artStyle: fallbackArtStyle,\n            description: description.trim(),\n            descriptions: JSON.stringify([description.trim()]),\n            imageUrls: encodeImageUrls([]),\n            previousImageUrls: encodeImageUrls([])}\n    })\n\n    return NextResponse.json({ success: true, appearance })\n})\n\n// 删除形象\nexport const DELETE = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ characterId: string; appearanceIndex: string }> }\n) => {\n    const { characterId, appearanceIndex } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const character = await prisma.globalCharacter.findUnique({\n        where: { id: characterId },\n        include: { appearances: true }\n    })\n\n    if (!character || character.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    if (character.appearances.length <= 1) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    const appearance = await prisma.globalCharacterAppearance.findFirst({\n        where: { characterId, appearanceIndex: parseInt(appearanceIndex, 10) }\n    })\n\n    if (!appearance) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    await prisma.globalCharacterAppearance.delete({\n        where: { id: appearance.id }\n    })\n\n    return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/characters/[characterId]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\nimport {\n    collectBailianManagedVoiceIds,\n    cleanupUnreferencedBailianVoices,\n} from '@/lib/providers/bailian'\n\n// 获取单个角色\nexport const GET = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ characterId: string }> }\n) => {\n    const { characterId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const character = await prisma.globalCharacter.findUnique({\n        where: { id: characterId },\n        include: { appearances: true }\n    })\n\n    if (!character || character.userId !== session.user.id) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    return NextResponse.json({ character })\n})\n\n// 更新角色\nexport const PATCH = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ characterId: string }> }\n) => {\n    const { characterId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const character = await prisma.globalCharacter.findUnique({\n        where: { id: characterId }\n    })\n\n    if (!character || character.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    const body = await request.json()\n    const { name, aliases, profileData, profileConfirmed, voiceId, voiceType, customVoiceUrl, folderId, globalVoiceId } = body\n\n    const updateData: Record<string, unknown> = {}\n    if (name !== undefined) updateData.name = name.trim()\n    if (aliases !== undefined) updateData.aliases = aliases\n    if (profileData !== undefined) updateData.profileData = profileData\n    if (profileConfirmed !== undefined) updateData.profileConfirmed = profileConfirmed\n    if (voiceId !== undefined) updateData.voiceId = voiceId\n    if (voiceType !== undefined) updateData.voiceType = voiceType\n    if (customVoiceUrl !== undefined) updateData.customVoiceUrl = customVoiceUrl\n    if (globalVoiceId !== undefined) updateData.globalVoiceId = globalVoiceId\n    if (folderId !== undefined) {\n        if (folderId) {\n            const folder = await prisma.globalAssetFolder.findUnique({\n                where: { id: folderId }\n            })\n            if (!folder || folder.userId !== session.user.id) {\n                throw new ApiError('INVALID_PARAMS')\n            }\n        }\n        updateData.folderId = folderId || null\n    }\n\n    const updatedCharacter = await prisma.globalCharacter.update({\n        where: { id: characterId },\n        data: updateData,\n        include: { appearances: true }\n    })\n\n    return NextResponse.json({ success: true, character: updatedCharacter })\n})\n\n// 删除角色\nexport const DELETE = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ characterId: string }> }\n) => {\n    const { characterId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const character = await prisma.globalCharacter.findUnique({\n        where: { id: characterId }\n    })\n\n    if (!character || character.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    const candidateVoiceIds = collectBailianManagedVoiceIds([\n        {\n            voiceId: character.voiceId,\n            voiceType: character.voiceType,\n        },\n    ])\n    await cleanupUnreferencedBailianVoices({\n        voiceIds: candidateVoiceIds,\n        scope: {\n            userId: session.user.id,\n            excludeGlobalCharacterId: character.id,\n        },\n    })\n\n    await prisma.globalCharacter.delete({\n        where: { id: characterId }\n    })\n\n    return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/characters/route.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\nimport { attachMediaFieldsToGlobalCharacter } from '@/lib/media/attach'\nimport { resolveMediaRefFromLegacyValue } from '@/lib/media/service'\nimport { PRIMARY_APPEARANCE_INDEX, isArtStyleValue } from '@/lib/constants'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { resolveTaskLocale } from '@/lib/task/resolve-locale'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\n\nfunction toObject(value: unknown): Record<string, unknown> {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n    return value as Record<string, unknown>\n}\n\n// 获取用户所有角色（支持 folderId 筛选）\nexport const GET = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const { searchParams } = new URL(request.url)\n    const folderId = searchParams.get('folderId')\n\n    const where: Record<string, unknown> = { userId: session.user.id }\n    if (folderId === 'null') {\n        where.folderId = null\n    } else if (folderId) {\n        where.folderId = folderId\n    }\n\n    const characters = await prisma.globalCharacter.findMany({\n        where,\n        include: { appearances: true },\n        orderBy: { createdAt: 'desc' }\n    })\n\n    const signedCharacters = await Promise.all(\n        characters.map((char) => attachMediaFieldsToGlobalCharacter(char))\n    )\n\n    return NextResponse.json({ characters: signedCharacters })\n})\n\n// 新建角色\nexport const POST = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = await request.json()\n    const taskLocale = resolveTaskLocale(request, body)\n    const bodyMeta = toObject((body as Record<string, unknown>).meta)\n    const acceptLanguage = request.headers.get('accept-language') || ''\n    const {\n        name,\n        description,\n        folderId,\n        initialImageUrl,\n        referenceImageUrl,\n        referenceImageUrls,\n        generateFromReference,\n        artStyle,\n        customDescription\n    } = body\n    const count = normalizeImageGenerationCount('reference-to-character', (body as Record<string, unknown>).count)\n\n    if (!name) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n    const normalizedArtStyle = typeof artStyle === 'string' ? artStyle.trim() : ''\n    if (!isArtStyleValue(normalizedArtStyle)) {\n        throw new ApiError('INVALID_PARAMS', {\n            code: 'INVALID_ART_STYLE',\n            message: 'artStyle is required and must be a supported value',\n        })\n    }\n\n    let allReferenceImages: string[] = []\n    if (referenceImageUrls && Array.isArray(referenceImageUrls)) {\n        allReferenceImages = referenceImageUrls.slice(0, 5)\n    } else if (referenceImageUrl) {\n        allReferenceImages = [referenceImageUrl]\n    }\n\n    if (folderId) {\n        const folder = await prisma.globalAssetFolder.findUnique({\n            where: { id: folderId }\n        })\n        if (!folder || folder.userId !== session.user.id) {\n            throw new ApiError('INVALID_PARAMS')\n        }\n    }\n\n    const character = await prisma.globalCharacter.create({\n        data: {\n            userId: session.user.id,\n            folderId: folderId || null,\n            name: name.trim(),\n            aliases: null\n        }\n    })\n\n    const descText = description?.trim() || `${name.trim()} 的角色设定`\n    const imageMedia = await resolveMediaRefFromLegacyValue(initialImageUrl || null)\n    const appearance = await prisma.globalCharacterAppearance.create({\n        data: {\n            characterId: character.id,\n            appearanceIndex: PRIMARY_APPEARANCE_INDEX,\n            changeReason: '初始形象',\n            artStyle: normalizedArtStyle,\n            description: descText,\n            descriptions: JSON.stringify([descText]),\n            imageUrl: initialImageUrl || null,\n            imageMediaId: imageMedia?.id || null,\n            imageUrls: encodeImageUrls(initialImageUrl ? [initialImageUrl] : []),\n            previousImageUrls: encodeImageUrls([])}\n    })\n\n    if (generateFromReference && allReferenceImages.length > 0) {\n        const { getBaseUrl } = await import('@/lib/env')\n        const baseUrl = getBaseUrl()\n        fetch(`${baseUrl}/api/asset-hub/reference-to-character`, {\n            method: 'POST',\n            headers: {\n                'Content-Type': 'application/json',\n                'Cookie': request.headers.get('cookie') || '',\n                ...(acceptLanguage ? { 'Accept-Language': acceptLanguage } : {})\n            },\n            body: JSON.stringify({\n                referenceImageUrls: allReferenceImages,\n                characterName: name.trim(),\n                characterId: character.id,\n                appearanceId: appearance.id,\n                count,\n                isBackgroundJob: true,\n                artStyle: normalizedArtStyle,\n                customDescription: customDescription || undefined,\n                locale: taskLocale || undefined,\n                meta: {\n                    ...bodyMeta,\n                    locale: taskLocale || bodyMeta.locale || undefined,\n                },\n            })\n        }).catch(err => {\n            _ulogError('[Characters API] 后台生成任务触发失败:', err)\n        })\n    }\n\n    const characterWithAppearances = await prisma.globalCharacter.findUnique({\n        where: { id: character.id },\n        include: { appearances: true }\n    })\n\n    const withMedia = characterWithAppearances\n        ? await attachMediaFieldsToGlobalCharacter(characterWithAppearances)\n        : characterWithAppearances\n\n    return NextResponse.json({ success: true, character: withMedia })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/folders/[folderId]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\n\n// 更新文件夹\nexport const PATCH = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ folderId: string }> }\n) => {\n    const { folderId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = await request.json()\n    const { name } = body\n\n    if (!name?.trim()) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 验证所有权\n    const folder = await prisma.globalAssetFolder.findUnique({\n        where: { id: folderId }\n    })\n\n    if (!folder || folder.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    const updatedFolder = await prisma.globalAssetFolder.update({\n        where: { id: folderId },\n        data: { name: name.trim() }\n    })\n\n    return NextResponse.json({ success: true, folder: updatedFolder })\n})\n\n// 删除文件夹\nexport const DELETE = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ folderId: string }> }\n) => {\n    const { folderId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    // 验证所有权\n    const folder = await prisma.globalAssetFolder.findUnique({\n        where: { id: folderId }\n    })\n\n    if (!folder || folder.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    // 删除前，将文件夹内的资产移动到根目录（folderId = null）\n    await prisma.globalCharacter.updateMany({\n        where: { folderId },\n        data: { folderId: null }\n    })\n\n    await prisma.globalLocation.updateMany({\n        where: { folderId },\n        data: { folderId: null }\n    })\n\n    // 删除文件夹\n    await prisma.globalAssetFolder.delete({\n        where: { id: folderId }\n    })\n\n    return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/folders/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\n\n// 获取用户所有文件夹\nexport const GET = apiHandler(async () => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const folders = await prisma.globalAssetFolder.findMany({\n        where: { userId: session.user.id },\n        orderBy: { name: 'asc' }\n    })\n\n    return NextResponse.json({ folders })\n})\n\n// 创建文件夹\nexport const POST = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = await request.json()\n    const { name } = body\n\n    if (!name?.trim()) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    const folder = await prisma.globalAssetFolder.create({\n        data: {\n            userId: session.user.id,\n            name: name.trim()\n        }\n    })\n\n    return NextResponse.json({ success: true, folder })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/generate-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { getUserModelConfig, buildImageBillingPayloadFromUserConfig } from '@/lib/config-service'\nimport { prisma } from '@/lib/prisma'\nimport {\n  hasGlobalCharacterOutput,\n  hasGlobalLocationOutput\n} from '@/lib/task/has-output'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { PRIMARY_APPEARANCE_INDEX, isArtStyleValue } from '@/lib/constants'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\nimport { ensureGlobalLocationImageSlots } from '@/lib/image-generation/location-slots'\n\nfunction toNumber(value: unknown) {\n  const parsed = Number(value)\n  return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction normalizeString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction resolveRequestedArtStyle(body: Record<string, unknown>): string | null {\n  if (!Object.prototype.hasOwnProperty.call(body, 'artStyle')) return null\n  const artStyle = normalizeString(body.artStyle)\n  if (!isArtStyleValue(artStyle)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'INVALID_ART_STYLE',\n      message: 'artStyle must be a supported value',\n    })\n  }\n  return artStyle\n}\n\nasync function resolveStoredArtStyle(input: {\n  userId: string\n  type: 'character' | 'location'\n  id: string\n  appearanceIndex: number\n}): Promise<string> {\n  if (input.type === 'character') {\n    const appearance = await prisma.globalCharacterAppearance.findFirst({\n      where: {\n        characterId: input.id,\n        appearanceIndex: input.appearanceIndex,\n        character: { userId: input.userId },\n      },\n      select: { artStyle: true },\n    })\n    if (!appearance) {\n      throw new ApiError('NOT_FOUND')\n    }\n    const artStyle = normalizeString(appearance.artStyle)\n    if (!isArtStyleValue(artStyle)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MISSING_ART_STYLE',\n        message: 'Character appearance artStyle is not configured',\n      })\n    }\n    return artStyle\n  }\n\n  const location = await prisma.globalLocation.findFirst({\n    where: {\n      id: input.id,\n      userId: input.userId,\n    },\n    select: { artStyle: true },\n  })\n  if (!location) {\n    throw new ApiError('NOT_FOUND')\n  }\n  const artStyle = normalizeString(location.artStyle)\n  if (!isArtStyleValue(artStyle)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MISSING_ART_STYLE',\n      message: 'Location artStyle is not configured',\n    })\n  }\n  return artStyle\n}\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const rawBody = await request.json().catch(() => ({}))\n  const body = toObject(rawBody)\n  const locale = resolveRequiredTaskLocale(request, body)\n  const type = normalizeString(body.type)\n  const id = normalizeString(body.id)\n  if (!type || !id) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (type !== 'character' && type !== 'location') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  const appearanceIndex = toNumber(body.appearanceIndex)\n  const resolvedAppearanceIndex = appearanceIndex ?? PRIMARY_APPEARANCE_INDEX\n  const count = type === 'character'\n    ? normalizeImageGenerationCount('character', body.count)\n    : normalizeImageGenerationCount('location', body.count)\n  const requestedArtStyle = resolveRequestedArtStyle(body)\n  const artStyle = requestedArtStyle || await resolveStoredArtStyle({\n    userId: session.user.id,\n    type,\n    id,\n    appearanceIndex: resolvedAppearanceIndex,\n  })\n  if (type === 'location' && toNumber(body.imageIndex) === null) {\n    const location = await prisma.globalLocation.findFirst({\n      where: { id, userId: session.user.id },\n      select: { name: true, summary: true },\n    })\n    if (!location) {\n      throw new ApiError('NOT_FOUND')\n    }\n    await ensureGlobalLocationImageSlots({\n      locationId: id,\n      count,\n      fallbackDescription: location.summary || location.name,\n    })\n  }\n  const payloadBase: Record<string, unknown> = type === 'character'\n    ? { ...body, id, type, appearanceIndex: resolvedAppearanceIndex, artStyle, count }\n    : { ...body, id, type, artStyle, count }\n\n  const targetType = type === 'character' ? 'GlobalCharacter' : 'GlobalLocation'\n  const hasOutputAtStart = type === 'character'\n    ? await hasGlobalCharacterOutput({\n      characterId: id,\n      appearanceIndex: resolvedAppearanceIndex\n    })\n    : await hasGlobalLocationOutput({\n      locationId: id\n    })\n  const userModelConfig = await getUserModelConfig(session.user.id)\n  const imageModel = type === 'character'\n    ? userModelConfig.characterModel\n    : userModelConfig.locationModel\n\n  let billingPayload: Record<string, unknown>\n  try {\n    billingPayload = buildImageBillingPayloadFromUserConfig({\n      userModelConfig,\n      imageModel,\n      basePayload: payloadBase,\n    })\n  } catch (err) {\n    const message = err instanceof Error ? err.message : 'Image model capability not configured'\n    throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })\n  }\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId: 'global-asset-hub',\n    type: TASK_TYPE.ASSET_HUB_IMAGE,\n    targetType,\n    targetId: id,\n    payload: withTaskUiPayload(billingPayload, { hasOutputAtStart }),\n    dedupeKey: `${TASK_TYPE.ASSET_HUB_IMAGE}:${targetType}:${id}:${type === 'character' ? resolvedAppearanceIndex : 'na'}:${toNumber(body.imageIndex) === null ? count : `single:${toNumber(body.imageIndex)}`}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.ASSET_HUB_IMAGE, billingPayload)\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/locations/[locationId]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\n\n// 获取单个场景\nexport const GET = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ locationId: string }> }\n) => {\n    const { locationId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const location = await prisma.globalLocation.findUnique({\n        where: { id: locationId },\n        include: { images: true }\n    })\n\n    if (!location || location.userId !== session.user.id) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    return NextResponse.json({ location })\n})\n\n// 更新场景\nexport const PATCH = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ locationId: string }> }\n) => {\n    const { locationId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const location = await prisma.globalLocation.findUnique({\n        where: { id: locationId }\n    })\n\n    if (!location || location.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    const body = await request.json()\n    const { name, summary, folderId } = body\n\n    const updateData: Record<string, unknown> = {}\n    if (name !== undefined) updateData.name = name.trim()\n    if (summary !== undefined) updateData.summary = summary?.trim() || null\n    if (folderId !== undefined) {\n        if (folderId) {\n            const folder = await prisma.globalAssetFolder.findUnique({\n                where: { id: folderId }\n            })\n            if (!folder || folder.userId !== session.user.id) {\n                throw new ApiError('INVALID_PARAMS')\n            }\n        }\n        updateData.folderId = folderId || null\n    }\n\n    const updatedLocation = await prisma.globalLocation.update({\n        where: { id: locationId },\n        data: updateData,\n        include: { images: true }\n    })\n\n    return NextResponse.json({ success: true, location: updatedLocation })\n})\n\n// 删除场景\nexport const DELETE = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ locationId: string }> }\n) => {\n    const { locationId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const location = await prisma.globalLocation.findUnique({\n        where: { id: locationId }\n    })\n\n    if (!location || location.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    await prisma.globalLocation.delete({\n        where: { id: locationId }\n    })\n\n    return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/locations/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\nimport { attachMediaFieldsToGlobalLocation } from '@/lib/media/attach'\nimport { isArtStyleValue } from '@/lib/constants'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\n\n// 获取用户所有场景（支持 folderId 筛选）\nexport const GET = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const { searchParams } = new URL(request.url)\n    const folderId = searchParams.get('folderId')\n\n    const where: Record<string, unknown> = { userId: session.user.id }\n    if (folderId === 'null') {\n        where.folderId = null\n    } else if (folderId) {\n        where.folderId = folderId\n    }\n\n    const locations = await prisma.globalLocation.findMany({\n        where,\n        include: { images: true },\n        orderBy: { createdAt: 'desc' }\n    })\n\n    const signedLocations = await Promise.all(\n        locations.map((loc) => attachMediaFieldsToGlobalLocation(loc))\n    )\n\n    return NextResponse.json({ locations: signedLocations })\n})\n\n// 新建场景\nexport const POST = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = await request.json()\n    const { name, summary, folderId, artStyle } = body\n    const count = Object.prototype.hasOwnProperty.call(body as Record<string, unknown>, 'count')\n        ? normalizeImageGenerationCount('location', (body as Record<string, unknown>).count)\n        : 1\n\n    if (!name) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n    const normalizedArtStyle = typeof artStyle === 'string' ? artStyle.trim() : ''\n    if (!isArtStyleValue(normalizedArtStyle)) {\n        throw new ApiError('INVALID_PARAMS', {\n            code: 'INVALID_ART_STYLE',\n            message: 'artStyle is required and must be a supported value',\n        })\n    }\n\n    if (folderId) {\n        const folder = await prisma.globalAssetFolder.findUnique({\n            where: { id: folderId }\n        })\n        if (!folder || folder.userId !== session.user.id) {\n            throw new ApiError('INVALID_PARAMS')\n        }\n    }\n\n    const location = await prisma.globalLocation.create({\n        data: {\n            userId: session.user.id,\n            folderId: folderId || null,\n            name: name.trim(),\n            artStyle: normalizedArtStyle,\n            summary: summary?.trim() || null\n        }\n    })\n\n    await prisma.globalLocationImage.createMany({\n        data: Array.from({ length: count }, (_value, imageIndex) => ({\n            locationId: location.id,\n            imageIndex,\n            description: summary?.trim() || name.trim(),\n        }))\n    })\n\n    const locationWithImages = await prisma.globalLocation.findUnique({\n        where: { id: location.id },\n        include: { images: true }\n    })\n\n    const withMedia = locationWithImages\n        ? await attachMediaFieldsToGlobalLocation(locationWithImages)\n        : locationWithImages\n\n    return NextResponse.json({ success: true, location: withMedia })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/modify-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { getUserModelConfig, buildImageBillingPayloadFromUserConfig } from '@/lib/config-service'\nimport {\n  hasGlobalCharacterAppearanceOutput,\n  hasGlobalLocationImageOutput\n} from '@/lib/task/has-output'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { sanitizeImageInputsForTaskPayload } from '@/lib/media/outbound-image'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\n\nfunction toNumber(value: unknown) {\n  const parsed = Number(value)\n  return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const type = body?.type\n  const modifyPrompt = body?.modifyPrompt\n  const id = body?.id\n  const appearanceIndex = body?.appearanceIndex\n  const imageIndex = body?.imageIndex\n\n  if (!type || !modifyPrompt || !id) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (type !== 'character' && type !== 'location') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const extraImageAudit = sanitizeImageInputsForTaskPayload(\n    Array.isArray(body?.extraImageUrls) ? body.extraImageUrls : [],\n  )\n  const rejectedRelativePathCount = extraImageAudit.issues.filter(\n    (issue) => issue.reason === 'relative_path_rejected',\n  ).length\n  if (rejectedRelativePathCount > 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const targetType = type === 'character' ? 'GlobalCharacterAppearance' : 'GlobalLocationImage'\n  const targetId = type === 'character'\n    ? `${id}:${appearanceIndex ?? PRIMARY_APPEARANCE_INDEX}:${imageIndex ?? 0}`\n    : `${id}:${imageIndex ?? 0}`\n  const hasOutputAtStart = type === 'character'\n    ? await hasGlobalCharacterAppearanceOutput({\n      targetId,\n      characterId: id,\n      appearanceIndex: toNumber(appearanceIndex),\n      imageIndex: toNumber(imageIndex)\n    })\n    : await hasGlobalLocationImageOutput({\n      targetId,\n      locationId: id,\n      imageIndex: toNumber(imageIndex)\n    })\n\n  const payload = {\n    ...body,\n    extraImageUrls: extraImageAudit.normalized,\n    meta: {\n      ...toObject(body?.meta),\n      outboundImageInputAudit: {\n        extraImageUrls: extraImageAudit.issues\n      }\n    }\n  }\n\n  const userModelConfig = await getUserModelConfig(session.user.id)\n  const imageModel = userModelConfig.editModel\n\n  let billingPayload: Record<string, unknown>\n  try {\n    billingPayload = buildImageBillingPayloadFromUserConfig({\n      userModelConfig,\n      imageModel,\n      basePayload: payload,\n    })\n  } catch (err) {\n    const message = err instanceof Error ? err.message : 'Image model capability not configured'\n    throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })\n  }\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId: 'global-asset-hub',\n    type: TASK_TYPE.ASSET_HUB_MODIFY,\n    targetType,\n    targetId,\n    payload: withTaskUiPayload(billingPayload, {\n      intent: 'modify',\n      hasOutputAtStart\n    }),\n    dedupeKey: `${TASK_TYPE.ASSET_HUB_MODIFY}:${targetId}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.ASSET_HUB_MODIFY, billingPayload)\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/picker/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\nimport { resolveMediaRefFromLegacyValue } from '@/lib/media/service'\nimport { decodeImageUrlsFromDb } from '@/lib/contracts/image-urls-contract'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\n\n/**\n * GET /api/asset-hub/picker\n * 获取用户的全局资产列表，用于在项目中选择要复制的资产\n * \n * Query params:\n * - type: 'character' | 'location'\n */\nexport const GET = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const { searchParams } = new URL(request.url)\n    const type = searchParams.get('type') || 'character'\n\n    if (type === 'character') {\n        const characters = await prisma.globalCharacter.findMany({\n            where: { userId: session.user.id },\n            include: {\n                appearances: {\n                    orderBy: { appearanceIndex: 'asc' }\n                },\n                folder: true\n            },\n            orderBy: { updatedAt: 'desc' }\n        })\n\n        const processedCharacters = await Promise.all(characters.map(async (char) => {\n            const primaryAppearance = char.appearances.find((a) => a.appearanceIndex === PRIMARY_APPEARANCE_INDEX) || char.appearances[0]\n            let previewUrl = null\n\n            if (primaryAppearance) {\n                const urls = decodeImageUrlsFromDb(primaryAppearance.imageUrls, 'globalCharacterAppearance.imageUrls')\n                const selectedUrl = urls[primaryAppearance.selectedIndex ?? 0] || urls[0] || primaryAppearance.imageUrl\n                if (selectedUrl) {\n                    const media = await resolveMediaRefFromLegacyValue(selectedUrl)\n                    previewUrl = media?.url || selectedUrl\n                }\n            }\n\n            return {\n                id: char.id,\n                name: char.name,\n                folderName: char.folder?.name || null,\n                previewUrl,\n                appearanceCount: char.appearances.length,\n                hasVoice: !!(char.voiceId || char.customVoiceUrl)\n            }\n        }))\n\n        return NextResponse.json({ characters: processedCharacters })\n    }\n\n    if (type === 'location') {\n        const locations = await prisma.globalLocation.findMany({\n            where: { userId: session.user.id },\n            include: {\n                images: {\n                    orderBy: { imageIndex: 'asc' }\n                },\n                folder: true\n            },\n            orderBy: { updatedAt: 'desc' }\n        })\n\n        const processedLocations = await Promise.all(locations.map(async (loc) => {\n            const selectedImage = loc.images.find((img) => img.isSelected) || loc.images[0]\n            let previewUrl = null\n\n            if (selectedImage?.imageUrl) {\n                const media = await resolveMediaRefFromLegacyValue(selectedImage.imageUrl)\n                previewUrl = media?.url || selectedImage.imageUrl\n            }\n\n            return {\n                id: loc.id,\n                name: loc.name,\n                summary: loc.summary,\n                folderName: loc.folder?.name || null,\n                previewUrl,\n                imageCount: loc.images.length\n            }\n        }))\n\n        return NextResponse.json({ locations: processedLocations })\n    }\n\n    if (type === 'voice') {\n        const voices = await prisma.globalVoice.findMany({\n            where: { userId: session.user.id },\n            include: {\n                folder: true\n            },\n            orderBy: { updatedAt: 'desc' }\n        })\n\n        const processedVoices = await Promise.all(voices.map(async (voice) => {\n            let previewUrl = null\n            if (voice.customVoiceUrl) {\n                const media = await resolveMediaRefFromLegacyValue(voice.customVoiceUrl)\n                previewUrl = media?.url || voice.customVoiceUrl\n            }\n\n            return {\n                id: voice.id,\n                name: voice.name,\n                description: voice.description,\n                folderName: voice.folder?.name || null,\n                previewUrl,\n                voiceId: voice.voiceId,\n                voiceType: voice.voiceType,\n                gender: voice.gender,\n                language: voice.language\n            }\n        }))\n\n        return NextResponse.json({ voices: processedVoices })\n    }\n\n    throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/reference-to-character/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\n\nfunction parseReferenceImages(body: Record<string, unknown>): string[] {\n  const list = Array.isArray(body.referenceImageUrls)\n    ? body.referenceImageUrls.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)\n    : []\n  if (list.length > 0) return list.slice(0, 5)\n  const single = typeof body.referenceImageUrl === 'string' ? body.referenceImageUrl.trim() : ''\n  return single ? [single] : []\n}\n\n/**\n * 资产中心 - 参考图转角色（任务化）\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = (await request.json().catch(() => ({}))) as Record<string, unknown>\n  const referenceImages = parseReferenceImages(body)\n  if (referenceImages.length === 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  const count = normalizeImageGenerationCount('reference-to-character', body.count)\n  body.count = count\n\n  const isBackgroundJob = body.isBackgroundJob === true || body.isBackgroundJob === 1 || body.isBackgroundJob === '1'\n  const characterId = typeof body.characterId === 'string' ? body.characterId : ''\n  const appearanceId = typeof body.appearanceId === 'string' ? body.appearanceId : ''\n  if (isBackgroundJob && (!characterId || !appearanceId)) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId: 'global-asset-hub',\n    type: TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,\n    targetType: appearanceId ? 'GlobalCharacterAppearance' : 'GlobalCharacter',\n    targetId: appearanceId || characterId || session.user.id,\n    routePath: '/api/asset-hub/reference-to-character',\n    body,\n    dedupeKey: `asset_hub_reference_to_character:${appearanceId || characterId || session.user.id}:${count}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/select-image/route.ts",
    "content": "import { logWarn as _ulogWarn } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\nimport { deleteObject } from '@/lib/storage'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\n\ninterface SelectImageBody {\n    type?: 'character' | 'location'\n    id?: string\n    appearanceIndex?: number\n    imageIndex?: number\n    confirm?: boolean\n}\n\n/**\n * POST /api/asset-hub/select-image\n * 选择/确认图片方案\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = (await request.json()) as SelectImageBody\n    const { type, id, appearanceIndex, imageIndex, confirm } = body\n\n    if (type === 'character') {\n        const appearance = await prisma.globalCharacterAppearance.findFirst({\n            where: {\n                characterId: id,\n                appearanceIndex: appearanceIndex ?? PRIMARY_APPEARANCE_INDEX,\n                character: { userId: session.user.id }\n            }\n        })\n\n        if (!appearance) {\n            throw new ApiError('NOT_FOUND')\n        }\n\n        if (confirm && appearance.selectedIndex !== null) {\n            // 确认选择：只保留选中的图片，删除其他候选\n            const imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'globalCharacterAppearance.imageUrls')\n            const selectedUrl = imageUrls[appearance.selectedIndex]\n\n            if (!selectedUrl) {\n                throw new ApiError('NOT_FOUND')\n            }\n\n            // 从存储中删除未选中的图片\n            for (let i = 0; i < imageUrls.length; i++) {\n                if (i !== appearance.selectedIndex && imageUrls[i]) {\n                    const key = await resolveStorageKeyFromMediaValue(imageUrls[i]!)\n                    if (key) {\n                        try { await deleteObject(key) } catch { _ulogWarn('Failed to delete image:', key) }\n                    }\n                }\n            }\n\n            // 同时处理 descriptions，只保留选中的描述\n            let descriptions: string[] = []\n            if (appearance.descriptions) {\n                try { descriptions = JSON.parse(appearance.descriptions) } catch { /* ignore */ }\n            }\n            const selectedDescription = descriptions[appearance.selectedIndex] || appearance.description || ''\n\n            await prisma.globalCharacterAppearance.update({\n                where: { id: appearance.id },\n                data: {\n                    imageUrl: selectedUrl,\n                    imageUrls: encodeImageUrls([selectedUrl]),\n                    selectedIndex: 0,\n                    description: selectedDescription,\n                    descriptions: JSON.stringify([selectedDescription]),\n                }\n            })\n        } else {\n            // 只是选择，不确认\n            await prisma.globalCharacterAppearance.update({\n                where: { id: appearance.id },\n                data: { selectedIndex: imageIndex }\n            })\n        }\n\n        return NextResponse.json({ success: true })\n\n    } else if (type === 'location') {\n        const location = await prisma.globalLocation.findFirst({\n            where: { id, userId: session.user.id },\n            include: { images: { orderBy: { imageIndex: 'asc' } } }\n        })\n\n        if (!location) {\n            throw new ApiError('NOT_FOUND')\n        }\n\n        const images = location.images || []\n        const selectedImg = images.find((img) => img.isSelected)\n        const confirmIndex = imageIndex ?? selectedImg?.imageIndex\n\n        if (confirm && confirmIndex !== null && confirmIndex !== undefined) {\n            // 确认选择：只保留选中的图片，删除其他候选\n            const targetImage = images.find((img) => img.imageIndex === confirmIndex)\n            if (!targetImage) {\n                throw new ApiError('NOT_FOUND')\n            }\n\n            // 从存储中删除未选中的图片\n            const imagesToDelete = images.filter((img) => img.id !== targetImage.id)\n            for (const img of imagesToDelete) {\n                if (img.imageUrl) {\n                    const key = await resolveStorageKeyFromMediaValue(img.imageUrl)\n                    if (key) {\n                        try { await deleteObject(key) } catch { _ulogWarn('Failed to delete image:', key) }\n                    }\n                }\n            }\n\n            // 在事务中更新数据库\n            await prisma.$transaction(async (tx) => {\n                // 删除未选中的图片记录\n                await tx.globalLocationImage.deleteMany({\n                    where: { locationId: id!, id: { not: targetImage.id } }\n                })\n                // 将选中图片的 imageIndex 重置为 0\n                await tx.globalLocationImage.update({\n                    where: { id: targetImage.id },\n                    data: { imageIndex: 0, isSelected: true }\n                })\n            })\n        } else {\n            // 只是选择，不确认\n            await prisma.globalLocationImage.updateMany({\n                where: { locationId: id },\n                data: { isSelected: false }\n            })\n\n            if (imageIndex !== null && imageIndex !== undefined) {\n                const targetImage = images.find((img) => img.imageIndex === imageIndex)\n                if (targetImage) {\n                    await prisma.globalLocationImage.update({\n                        where: { id: targetImage.id },\n                        data: { isSelected: true }\n                    })\n                }\n            }\n        }\n\n        return NextResponse.json({ success: true })\n\n    } else {\n        throw new ApiError('INVALID_PARAMS')\n    }\n})\n\n"
  },
  {
    "path": "src/app/api/asset-hub/undo-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\n\ninterface UndoImageBody {\n    type?: 'character' | 'location'\n    id?: string\n    appearanceIndex?: number\n}\n\ninterface GlobalCharacterAppearanceRecord {\n    id: string\n    imageUrl: string | null\n    description: string | null\n    descriptions: unknown\n    previousImageUrl: string | null\n    previousImageUrls: string | null\n    previousDescription: string | null\n    previousDescriptions: unknown\n}\n\ninterface GlobalLocationImageRecord {\n    id: string\n    imageUrl: string | null\n    description: string | null\n    previousImageUrl: string | null\n    previousDescription: string | null\n}\n\ninterface GlobalLocationRecord {\n    images?: GlobalLocationImageRecord[]\n}\n\ninterface AssetHubUndoDb {\n    globalCharacterAppearance: {\n        findFirst(args: Record<string, unknown>): Promise<GlobalCharacterAppearanceRecord | null>\n        update(args: Record<string, unknown>): Promise<unknown>\n    }\n    globalLocation: {\n        findFirst(args: Record<string, unknown>): Promise<GlobalLocationRecord | null>\n    }\n    globalLocationImage: {\n        update(args: Record<string, unknown>): Promise<unknown>\n    }\n}\n\n/**\n * POST /api/asset-hub/undo-image\n * 撤回到上一版本图片（同时恢复描述词）\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n    const db = prisma as unknown as AssetHubUndoDb\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = (await request.json()) as UndoImageBody\n    const { type, id, appearanceIndex } = body\n\n    if (type === 'character') {\n        const appearance = await db.globalCharacterAppearance.findFirst({\n            where: {\n                characterId: id,\n                appearanceIndex: appearanceIndex ?? PRIMARY_APPEARANCE_INDEX,\n                character: { userId: session.user.id }\n            }\n        })\n\n        if (!appearance) {\n            throw new ApiError('NOT_FOUND')\n        }\n\n        const previousImageUrls = decodeImageUrlsFromDb(appearance.previousImageUrls, 'globalCharacterAppearance.previousImageUrls')\n        if (!appearance.previousImageUrl && previousImageUrls.length === 0) {\n            throw new ApiError('INVALID_PARAMS')\n        }\n\n        const restoredImageUrls = previousImageUrls.length > 0\n            ? previousImageUrls\n            : (appearance.previousImageUrl ? [appearance.previousImageUrl] : [])\n\n        // 恢复上一版本（图片 + 描述词）\n        await db.globalCharacterAppearance.update({\n            where: { id: appearance.id },\n            data: {\n                imageUrl: appearance.previousImageUrl || restoredImageUrls[0] || null,\n                imageUrls: encodeImageUrls(restoredImageUrls),\n                previousImageUrl: null,\n                previousImageUrls: encodeImageUrls([]),\n                selectedIndex: null,\n                // 🔥 同时恢复描述词\n                description: appearance.previousDescription ?? appearance.description,\n                descriptions: appearance.previousDescriptions ?? appearance.descriptions,\n                previousDescription: null,\n                previousDescriptions: null\n            }\n        })\n\n        return NextResponse.json({ success: true, message: '已撤回到上一版本（图片和描述词）' })\n\n    } else if (type === 'location') {\n        const location = await db.globalLocation.findFirst({\n            where: { id, userId: session.user.id },\n            include: { images: true }\n        })\n\n        if (!location) {\n            throw new ApiError('NOT_FOUND')\n        }\n\n        // 恢复所有图片的上一版本（图片 + 描述词）\n        for (const img of location.images || []) {\n            if (img.previousImageUrl) {\n                await db.globalLocationImage.update({\n                    where: { id: img.id },\n                    data: {\n                        imageUrl: img.previousImageUrl,\n                        previousImageUrl: null,\n                        // 🔥 同时恢复描述词\n                        description: img.previousDescription ?? img.description,\n                        previousDescription: null\n                    }\n                })\n            }\n        }\n\n        return NextResponse.json({ success: true, message: '已撤回到上一版本（图片和描述词）' })\n\n    } else {\n        throw new ApiError('INVALID_PARAMS')\n    }\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/update-asset-label/route.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { uploadObject, getSignedUrl, toFetchableUrl, generateUniqueKey } from '@/lib/storage'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport sharp from 'sharp'\nimport { initializeFonts, createLabelSVG } from '@/lib/fonts'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST /api/asset-hub/update-asset-label\n * 更新资产中心图片上的黑边标识符（修改名字后调用）\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n    await initializeFonts()\n\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = await request.json()\n    const { type, id, newName, appearanceIndex } = body\n\n    if (!type || !id || !newName) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    if (type === 'character') {\n        const character = await prisma.globalCharacter.findUnique({\n            where: { id },\n            include: { appearances: true },\n        })\n\n        if (!character || character.userId !== session.user.id) {\n            throw new ApiError('NOT_FOUND')\n        }\n\n        const updatePromises = character.appearances.map(async (appearance) => {\n            if (appearanceIndex !== undefined && appearance.appearanceIndex !== appearanceIndex) {\n                return null\n            }\n\n            let imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'globalCharacterAppearance.imageUrls')\n            if (imageUrls.length === 0 && appearance.imageUrl) {\n                imageUrls = [appearance.imageUrl]\n            }\n            if (imageUrls.length === 0) return null\n\n            const newLabelText = `${newName} - ${appearance.changeReason}`\n            const newImageUrls: string[] = await Promise.all(\n                imageUrls.map(async (url, i) => {\n                    if (!url) return ''\n                    try {\n                        return await updateImageLabel(url, newLabelText)\n                    } catch (e) {\n                        _ulogError(`Failed to update label for global character image ${i}:`, e)\n                        return url\n                    }\n                })\n            )\n\n            const firstUrl = newImageUrls.find((u) => !!u) || null\n\n            await prisma.globalCharacterAppearance.update({\n                where: { id: appearance.id },\n                data: {\n                    imageUrls: encodeImageUrls(newImageUrls),\n                    imageUrl: firstUrl,\n                },\n            })\n\n            return { appearanceIndex: appearance.appearanceIndex, imageUrls: newImageUrls }\n        })\n\n        const results = await Promise.all(updatePromises)\n        return NextResponse.json({ success: true, results: results.filter((r) => r !== null) })\n    }\n\n    if (type === 'location') {\n        const location = await prisma.globalLocation.findUnique({\n            where: { id },\n            include: { images: true },\n        })\n\n        if (!location || location.userId !== session.user.id) {\n            throw new ApiError('NOT_FOUND')\n        }\n\n        const updatePromises = location.images.map(async (image) => {\n            if (!image.imageUrl) return null\n\n            try {\n                const newImageUrl = await updateImageLabel(image.imageUrl, newName)\n\n                await prisma.globalLocationImage.update({\n                    where: { id: image.id },\n                    data: { imageUrl: newImageUrl },\n                })\n\n                return { imageIndex: image.imageIndex, imageUrl: newImageUrl }\n            } catch (e) {\n                _ulogError(`Failed to update label for global location image ${image.imageIndex}:`, e)\n                return null\n            }\n        })\n\n        const results = await Promise.all(updatePromises)\n        return NextResponse.json({ success: true, results: results.filter((r) => r !== null) })\n    }\n\n    throw new ApiError('INVALID_PARAMS')\n})\n\n/**\n * 更新图片的黑边标签\n * 生成新 COS key 上传，URL 变化后浏览器缓存失效，前端能立即看到新标签\n */\nasync function updateImageLabel(imageUrl: string, newLabelText: string): Promise<string> {\n    const originalKey = await resolveStorageKeyFromMediaValue(imageUrl)\n    if (!originalKey) {\n        throw new Error(`无法归一化媒体 key: ${imageUrl}`)\n    }\n    const signedUrl = getSignedUrl(originalKey, 3600)\n\n    const response = await fetch(toFetchableUrl(signedUrl))\n    if (!response.ok) {\n        throw new Error(`Failed to download image: ${response.status}`)\n    }\n    const buffer = Buffer.from(await response.arrayBuffer())\n\n    const meta = await sharp(buffer).metadata()\n    const w = meta.width || 2160\n    const h = meta.height || 2160\n\n    const fontSize = Math.floor(h * 0.04)\n    const pad = Math.floor(fontSize * 0.5)\n    const barH = fontSize + pad * 2\n\n    const croppedBuffer = await sharp(buffer)\n        .extract({ left: 0, top: barH, width: w, height: h - barH })\n        .toBuffer()\n\n    const svg = await createLabelSVG(w, barH, fontSize, pad, newLabelText)\n\n    const processed = await sharp(croppedBuffer)\n        .extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })\n        .composite([{ input: svg, top: 0, left: 0 }])\n        .jpeg({ quality: 90, mozjpeg: true })\n        .toBuffer()\n\n    // 生成新 key，使 URL 发生变化，强制浏览器绕过缓存\n    const newKey = generateUniqueKey('labeled-rename', 'jpg')\n    await uploadObject(processed, newKey)\n    return newKey\n}\n"
  },
  {
    "path": "src/app/api/asset-hub/upload-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { uploadObject, generateUniqueKey } from '@/lib/storage'\nimport sharp from 'sharp'\nimport { initializeFonts, createLabelSVG } from '@/lib/fonts'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface GlobalCharacterAppearanceRecord {\n    id: string\n    imageUrl: string | null\n    imageUrls: string | null\n    previousImageUrl: string | null\n    previousImageUrls: string | null\n    selectedIndex: number | null\n}\n\ninterface GlobalLocationImageRecord {\n    id: string\n    imageIndex: number\n    imageUrl: string | null\n}\n\ninterface GlobalLocationRecord {\n    images?: GlobalLocationImageRecord[]\n}\n\ninterface AssetHubUploadDb {\n    globalCharacterAppearance: {\n        findFirst(args: Record<string, unknown>): Promise<GlobalCharacterAppearanceRecord | null>\n        update(args: Record<string, unknown>): Promise<unknown>\n    }\n    globalLocation: {\n        findFirst(args: Record<string, unknown>): Promise<GlobalLocationRecord | null>\n    }\n    globalLocationImage: {\n        update(args: Record<string, unknown>): Promise<unknown>\n        create(args: Record<string, unknown>): Promise<unknown>\n    }\n}\n\n/**\n * POST /api/asset-hub/upload-image\n * 上传用户自定义图片作为角色或场景资产\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n    await initializeFonts()\n    const db = prisma as unknown as AssetHubUploadDb\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const formData = await request.formData()\n    const file = formData.get('file') as File\n    const type = formData.get('type') as string\n    const id = formData.get('id') as string\n    const appearanceIndex = formData.get('appearanceIndex') as string | null\n    const imageIndex = formData.get('imageIndex') as string | null\n    const labelText = formData.get('labelText') as string\n\n    if (!file || !type || !id || !labelText) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 读取文件并处理\n    const arrayBuffer = await file.arrayBuffer()\n    const buffer = Buffer.from(arrayBuffer)\n\n    const meta = await sharp(buffer).metadata()\n    const w = meta.width || 2160\n    const h = meta.height || 2160\n    const fontSize = Math.floor(h * 0.04)\n    const pad = Math.floor(fontSize * 0.5)\n    const barH = fontSize + pad * 2\n\n    const svg = await createLabelSVG(w, barH, fontSize, pad, labelText)\n\n    const processed = await sharp(buffer)\n        .extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })\n        .composite([{ input: svg, top: 0, left: 0 }])\n        .jpeg({ quality: 90, mozjpeg: true })\n        .toBuffer()\n\n    const keyPrefix = type === 'character'\n        ? `global-char-${id}-${appearanceIndex}-upload`\n        : `global-loc-${id}-upload`\n    const key = generateUniqueKey(keyPrefix, 'jpg')\n    await uploadObject(processed, key)\n\n    if (type === 'character' && appearanceIndex !== null) {\n        const appearance = await db.globalCharacterAppearance.findFirst({\n            where: {\n                characterId: id,\n                appearanceIndex: parseInt(appearanceIndex),\n                character: { userId: session.user.id }\n            }\n        })\n\n        if (!appearance) {\n            throw new ApiError('NOT_FOUND')\n        }\n\n        const currentImageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'globalCharacterAppearance.imageUrls')\n\n        // 保存历史版本\n        if (appearance.imageUrl || currentImageUrls.length > 0) {\n            await db.globalCharacterAppearance.update({\n                where: { id: appearance.id },\n                data: {\n                    previousImageUrl: appearance.imageUrl,\n                    previousImageUrls: appearance.imageUrls\n                }\n            })\n        }\n\n        const imageUrls = [...currentImageUrls]\n\n        const targetIndex = imageIndex !== null ? parseInt(imageIndex) : imageUrls.length\n        while (imageUrls.length <= targetIndex) {\n            imageUrls.push('')\n        }\n        imageUrls[targetIndex] = key\n\n        const selectedIndex = appearance.selectedIndex\n        const shouldUpdateImageUrl =\n            selectedIndex === targetIndex ||\n            (selectedIndex === null && targetIndex === 0) ||\n            imageUrls.filter(u => !!u).length === 1\n\n        const updateData: Record<string, unknown> = { imageUrls: encodeImageUrls(imageUrls) }\n        if (shouldUpdateImageUrl) {\n            updateData.imageUrl = key\n        }\n\n        await db.globalCharacterAppearance.update({\n            where: { id: appearance.id },\n            data: updateData\n        })\n\n        return NextResponse.json({ success: true, imageKey: key, imageIndex: targetIndex })\n\n    } else if (type === 'location') {\n        const location = await db.globalLocation.findFirst({\n            where: { id, userId: session.user.id },\n            include: { images: { orderBy: { imageIndex: 'asc' } } }\n        })\n\n        if (!location) {\n            throw new ApiError('NOT_FOUND')\n        }\n\n        if (imageIndex !== null) {\n            const targetImageIndex = parseInt(imageIndex)\n            const existingImage = location.images?.find((img) => img.imageIndex === targetImageIndex)\n\n            if (existingImage) {\n                // 保存历史版本\n                if (existingImage.imageUrl) {\n                    await db.globalLocationImage.update({\n                        where: { id: existingImage.id },\n                        data: { previousImageUrl: existingImage.imageUrl }\n                    })\n                }\n                await db.globalLocationImage.update({\n                    where: { id: existingImage.id },\n                    data: { imageUrl: key }\n                })\n            } else {\n                await db.globalLocationImage.create({\n                    data: {\n                        locationId: id,\n                        imageIndex: targetImageIndex,\n                        imageUrl: key,\n                        description: labelText,\n                        isSelected: targetImageIndex === 0\n                    }\n                })\n            }\n\n            return NextResponse.json({ success: true, imageKey: key, imageIndex: targetImageIndex })\n        } else {\n            const maxIndex = location.images?.length || 0\n            await db.globalLocationImage.create({\n                data: {\n                    locationId: id,\n                    imageIndex: maxIndex,\n                    imageUrl: key,\n                    description: labelText,\n                    isSelected: maxIndex === 0\n                }\n            })\n\n            return NextResponse.json({ success: true, imageKey: key, imageIndex: maxIndex })\n        }\n    }\n\n    throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/upload-temp/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { generateUniqueKey, getSignedUrl, uploadObject } from '@/lib/storage'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST /api/asset-hub/upload-temp\n * 上传临时文件（Base64），返回签名 URL\n * 支持图片和音频格式\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = await request.json()\n    const { imageBase64, base64, extension } = body\n\n    // 支持两种调用方式：\n    // 1. 图片模式：{ imageBase64: \"data:image/...\" }\n    // 2. 通用模式：{ base64: \"...\", type: \"audio/wav\", extension: \"wav\" }\n\n    let buffer: Buffer\n    let ext: string\n\n    if (imageBase64) {\n        // 图片模式\n        const matches = imageBase64.match(/^data:image\\/(\\w+);base64,(.+)$/)\n        if (!matches) {\n            throw new ApiError('INVALID_PARAMS')\n        }\n        ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]\n        buffer = Buffer.from(matches[2], 'base64')\n    } else if (base64 && extension) {\n        // 通用模式（音频等）\n        buffer = Buffer.from(base64, 'base64')\n        ext = extension\n    } else {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 上传到 COS\n    const key = generateUniqueKey(`temp-${session.user.id}-${Date.now()}`, ext)\n    await uploadObject(buffer, key)\n\n    // 返回签名 URL（有效期 1 小时）\n    const signedUrl = getSignedUrl(key, 3600)\n\n    return NextResponse.json({\n        success: true,\n        url: signedUrl,\n        key\n    })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/voice-design/route.ts",
    "content": "import { createHash } from 'crypto'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { validatePreviewText, validateVoicePrompt } from '@/lib/providers/bailian/voice-design'\n\n/**\n * 声音设计 API (Asset Hub)\n * POST /api/asset-hub/voice-design\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = (await request.json().catch(() => ({}))) as Record<string, unknown>\n  const locale = resolveRequiredTaskLocale(request, body)\n  const voicePrompt = typeof body.voicePrompt === 'string' ? body.voicePrompt.trim() : ''\n  const previewText = typeof body.previewText === 'string' ? body.previewText.trim() : ''\n  const preferredName = typeof body.preferredName === 'string' && body.preferredName.trim()\n    ? body.preferredName.trim()\n    : 'custom_voice'\n  const language = body.language === 'en' ? 'en' : 'zh'\n\n  const promptValidation = validateVoicePrompt(voicePrompt)\n  if (!promptValidation.valid) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  const textValidation = validatePreviewText(previewText)\n  if (!textValidation.valid) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const digest = createHash('sha1')\n    .update(`${session.user.id}:${voicePrompt}:${previewText}:${preferredName}:${language}`)\n    .digest('hex')\n    .slice(0, 16)\n\n  const payload = {\n    voicePrompt,\n    previewText,\n    preferredName,\n    language,\n    displayMode: 'detail' as const}\n\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId: 'global-asset-hub',\n    type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,\n    targetType: 'GlobalAssetHubVoiceDesign',\n    targetId: session.user.id,\n    payload,\n    dedupeKey: `${TASK_TYPE.ASSET_HUB_VOICE_DESIGN}:${digest}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.ASSET_HUB_VOICE_DESIGN, payload)})\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/voices/[id]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\n\n// 删除音色\nexport const DELETE = apiHandler(async (\n    request: NextRequest,\n    { params }: { params: Promise<{ id: string }> }\n) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const { id } = await params\n\n    const voice = await prisma.globalVoice.findUnique({\n        where: { id }\n    })\n\n    if (!voice) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    if (voice.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    await prisma.globalVoice.delete({\n        where: { id }\n    })\n\n    return NextResponse.json({ success: true })\n})\n\n// 更新音色\nexport const PATCH = apiHandler(async (\n    request: NextRequest,\n    { params }: { params: Promise<{ id: string }> }\n) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const { id } = await params\n    const body = await request.json()\n\n    const voice = await prisma.globalVoice.findUnique({\n        where: { id }\n    })\n\n    if (!voice) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    if (voice.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    const updatedVoice = await prisma.globalVoice.update({\n        where: { id },\n        data: {\n            name: body.name?.trim() || voice.name,\n            description: body.description !== undefined ? body.description?.trim() || null : voice.description,\n            folderId: body.folderId !== undefined ? body.folderId : voice.folderId\n        }\n    })\n\n    return NextResponse.json({ success: true, voice: updatedVoice })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/voices/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\nimport { attachMediaFieldsToGlobalVoice } from '@/lib/media/attach'\nimport { resolveMediaRefFromLegacyValue } from '@/lib/media/service'\n\n// 获取用户所有音色（支持 folderId 筛选）\nexport const GET = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const { searchParams } = new URL(request.url)\n    const folderId = searchParams.get('folderId')\n\n    const where: Record<string, unknown> = { userId: session.user.id }\n    if (folderId === 'null') {\n        where.folderId = null\n    } else if (folderId) {\n        where.folderId = folderId\n    }\n\n    const voices = await prisma.globalVoice.findMany({\n        where,\n        orderBy: { createdAt: 'desc' }\n    })\n\n    const signedVoices = await Promise.all(\n        voices.map((voice) => attachMediaFieldsToGlobalVoice(voice))\n    )\n\n    return NextResponse.json({ voices: signedVoices })\n})\n\n// 新建音色\nexport const POST = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = await request.json()\n    const {\n        name,\n        description,\n        folderId,\n        voiceId,\n        voiceType,\n        customVoiceUrl,\n        voicePrompt,\n        gender,\n        language\n    } = body\n\n    if (!name) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    if (folderId) {\n        const folder = await prisma.globalAssetFolder.findUnique({\n            where: { id: folderId }\n        })\n        if (!folder || folder.userId !== session.user.id) {\n            throw new ApiError('INVALID_PARAMS')\n        }\n    }\n\n    const customVoiceMedia = await resolveMediaRefFromLegacyValue(customVoiceUrl || null)\n    const voice = await prisma.globalVoice.create({\n        data: {\n            userId: session.user.id,\n            folderId: folderId || null,\n            name: name.trim(),\n            description: description?.trim() || null,\n            voiceId: voiceId || null,\n            voiceType: voiceType || 'qwen-designed',\n            customVoiceUrl: customVoiceUrl || null,\n            customVoiceMediaId: customVoiceMedia?.id || null,\n            voicePrompt: voicePrompt?.trim() || null,\n            gender: gender || null,\n            language: language || 'zh'\n        }\n    })\n\n    const withMedia = await attachMediaFieldsToGlobalVoice(voice)\n    return NextResponse.json({ success: true, voice: withMedia })\n})\n"
  },
  {
    "path": "src/app/api/asset-hub/voices/upload/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { uploadObject, generateUniqueKey, getSignedUrl } from '@/lib/storage'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST /api/asset-hub/voices/upload\n * 上传音频文件到音色库\n */\nexport const POST = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const formData = await request.formData()\n    const file = formData.get('file') as File\n    const name = formData.get('name') as string\n    const folderId = formData.get('folderId') as string | null\n    const description = formData.get('description') as string | null\n\n    if (!file) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    if (!name || !name.trim()) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 支持的音频类型\n    const audioTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a', 'audio/aac']\n    const isAudioFile = audioTypes.includes(file.type) || file.name.match(/\\.(mp3|wav|ogg|m4a|aac)$/i)\n\n    if (!isAudioFile) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 验证 folderId（如果提供）\n    if (folderId) {\n        const folder = await prisma.globalAssetFolder.findUnique({\n            where: { id: folderId }\n        })\n        if (!folder || folder.userId !== session.user.id) {\n            throw new ApiError('INVALID_PARAMS')\n        }\n    }\n\n    const arrayBuffer = await file.arrayBuffer()\n    const buffer = Buffer.from(arrayBuffer)\n    const audioExt = file.name.split('.').pop()?.toLowerCase() || 'mp3'\n\n    // 上传到 COS\n    const key = generateUniqueKey(`voices/${session.user.id}/${Date.now()}`, audioExt)\n    const cosUrl = await uploadObject(buffer, key)\n\n    // 创建音色记录\n    const voice = await prisma.globalVoice.create({\n        data: {\n            userId: session.user.id,\n            folderId: folderId || null,\n            name: name.trim(),\n            description: description?.trim() || null,\n            voiceId: null,\n            voiceType: 'uploaded',\n            customVoiceUrl: cosUrl,\n            voicePrompt: null,\n            gender: null,\n            language: 'zh'\n        }\n    })\n\n    // 签名 URL\n    const signedUrl = getSignedUrl(cosUrl, 7 * 24 * 3600)\n\n    return NextResponse.json({\n        success: true,\n        voice: {\n            ...voice,\n            customVoiceUrl: signedUrl\n        }\n    })\n})\n"
  },
  {
    "path": "src/app/api/auth/[...nextauth]/route.ts",
    "content": "import NextAuth from \"next-auth\"\nimport { NextRequest, NextResponse } from \"next/server\"\nimport { authOptions } from \"@/lib/auth\"\nimport { checkRateLimit, getClientIp, AUTH_LOGIN_LIMIT } from '@/lib/rate-limit'\nimport { logAuthAction } from '@/lib/logging/semantic'\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst nextAuthHandler = (NextAuth as any)(authOptions)\n\n/**\n * 登录 POST 请求加 IP 限流保护。\n * 仅对 callback/credentials（即实际登录行为）做限流，\n * 其他 NextAuth 内部路由（session / csrf 等）不限制。\n *\n * ⚠️ NextAuth 客户端 signIn() 期望响应体包含 { url } 字段，\n *    如果返回自定义 JSON 格式会导致 signIn() 内部 new URL(data.url) 抛异常。\n *    因此限流时返回 NextAuth 兼容的格式：{ url: \"...?error=RateLimited\" }\n */\nasync function handlePost(req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) {\n    const { nextauth: segments } = await ctx.params\n    const isCredentialsCallback =\n        segments.length >= 2\n        && segments[0] === 'callback'\n        && segments[1] === 'credentials'\n\n    if (isCredentialsCallback) {\n        const ip = getClientIp(req)\n        const rateResult = await checkRateLimit('auth:login', ip, AUTH_LOGIN_LIMIT)\n        if (rateResult.limited) {\n            logAuthAction('LOGIN', 'unknown', { error: 'Rate limited', ip })\n            // 返回 NextAuth 兼容的错误格式，signIn() 会解析 URL 中的 error 参数\n            const origin = req.nextUrl.origin\n            return NextResponse.json(\n                { url: `${origin}/auth/signin?error=RateLimited` },\n                {\n                    status: 429,\n                    headers: { 'Retry-After': String(rateResult.retryAfterSeconds) },\n                },\n            )\n        }\n    }\n\n    return nextAuthHandler(req, ctx)\n}\n\nfunction handleGet(req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) {\n    return nextAuthHandler(req, ctx)\n}\n\nexport { handleGet as GET, handlePost as POST }\n"
  },
  {
    "path": "src/app/api/auth/register/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\"\nimport bcrypt from \"bcryptjs\"\nimport { logAuthAction } from '@/lib/logging/semantic'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { prisma } from '@/lib/prisma'\nimport { checkRateLimit, getClientIp, AUTH_REGISTER_LIMIT } from '@/lib/rate-limit'\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  // 🛡️ IP 限流\n  const ip = getClientIp(request)\n  const rateResult = await checkRateLimit('auth:register', ip, AUTH_REGISTER_LIMIT)\n  if (rateResult.limited) {\n    logAuthAction('REGISTER', 'unknown', { error: 'Rate limited', ip })\n    return NextResponse.json(\n      { success: false, message: `请求过于频繁，请 ${rateResult.retryAfterSeconds} 秒后再试` },\n      {\n        status: 429,\n        headers: { 'Retry-After': String(rateResult.retryAfterSeconds) },\n      },\n    )\n  }\n\n  let name = 'unknown'\n  const body = await request.json()\n  name = body.name || 'unknown'\n  const { password } = body\n\n  // 验证输入\n  if (!name || !password) {\n    logAuthAction('REGISTER', name, { error: 'Missing credentials' })\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (password.length < 6) {\n    logAuthAction('REGISTER', name, { error: 'Password too short' })\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 检查用户是否已存在\n  const existingUser = await prisma.user.findUnique({\n    where: { name }\n  })\n\n  if (existingUser) {\n    logAuthAction('REGISTER', name, { error: 'Phone number already exists' })\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 哈希密码\n  const hashedPassword = await bcrypt.hash(password, 12)\n\n  // 创建用户（事务）\n  const user = await prisma.$transaction(async (tx) => {\n    // 创建用户\n    const newUser = await tx.user.create({\n      data: {\n        name,\n        password: hashedPassword\n      }\n    })\n\n    // 💰 创建用户余额记录（初始余额为0）\n    await tx.userBalance.create({\n      data: {\n        userId: newUser.id,\n        balance: 0,\n        frozenAmount: 0,\n        totalSpent: 0\n      }\n    })\n\n    return newUser\n  })\n\n  logAuthAction('REGISTER', name, { userId: user.id, success: true })\n\n  return NextResponse.json(\n    {\n      message: \"注册成功\",\n      user: {\n        id: user.id,\n        name: user.name\n      }\n    },\n    { status: 201 }\n  )\n})\n"
  },
  {
    "path": "src/app/api/cos/image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\nexport const GET = apiHandler(async (request: NextRequest) => {\n  const { searchParams } = new URL(request.url)\n  const key = searchParams.get('key')\n  const expires = searchParams.get('expires') || '3600'\n\n  if (!key) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const location = `/api/storage/sign?key=${encodeURIComponent(key)}&expires=${encodeURIComponent(expires)}`\n  return NextResponse.redirect(new URL(location, request.url))\n})\n"
  },
  {
    "path": "src/app/api/files/[...path]/route.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\n/**\n * 本地文件服务API\n * \n * 仅在 STORAGE_TYPE=local 时使用\n * 提供本地文件的HTTP访问服务\n */\n\nimport { NextRequest, NextResponse } from 'next/server'\nimport * as fs from 'fs/promises'\nimport * as path from 'path'\n\nconst UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads'\n\n// MIME类型映射\nconst MIME_TYPES: Record<string, string> = {\n    '.png': 'image/png',\n    '.jpg': 'image/jpeg',\n    '.jpeg': 'image/jpeg',\n    '.gif': 'image/gif',\n    '.webp': 'image/webp',\n    '.mp4': 'video/mp4',\n    '.webm': 'video/webm',\n    '.mov': 'video/quicktime',\n    '.mp3': 'audio/mpeg',\n    '.wav': 'audio/wav',\n    '.ogg': 'audio/ogg',\n    '.json': 'application/json',\n    '.txt': 'text/plain',\n}\n\nfunction getMimeType(filePath: string): string {\n    const ext = path.extname(filePath).toLowerCase()\n    return MIME_TYPES[ext] || 'application/octet-stream'\n}\n\nexport async function GET(\n    request: NextRequest,\n    { params }: { params: Promise<{ path: string[] }> }\n) {\n    try {\n        const { path: pathSegments } = await params\n\n        // 解码路径（因为URL编码过）\n        const decodedPath = decodeURIComponent(pathSegments.join('/'))\n        const filePath = path.join(process.cwd(), UPLOAD_DIR, decodedPath)\n\n        // 安全检查：确保路径不会逃逸出上传目录\n        const normalizedPath = path.normalize(filePath)\n        const uploadDirPath = path.normalize(path.join(process.cwd(), UPLOAD_DIR))\n\n        if (!normalizedPath.startsWith(uploadDirPath + path.sep)) {\n            _ulogError(`[Files API] 路径逃逸尝试: ${decodedPath}`)\n            return NextResponse.json({ error: 'Access denied' }, { status: 403 })\n        }\n\n        // 读取文件\n        const buffer = await fs.readFile(filePath)\n        const mimeType = getMimeType(filePath)\n\n        // 返回文件内容\n        return new NextResponse(new Uint8Array(buffer), {\n            status: 200,\n            headers: {\n                'Content-Type': mimeType,\n                'Content-Length': buffer.length.toString(),\n                'Cache-Control': 'public, max-age=31536000', // 1年缓存\n            },\n        })\n\n    } catch (error: unknown) {\n        const code = typeof error === 'object' && error !== null && 'code' in error\n            ? (error as { code?: unknown }).code\n            : undefined\n        if (code === 'ENOENT') {\n            return NextResponse.json({ error: 'File not found' }, { status: 404 })\n        }\n\n        _ulogError('[Files API] 读取文件失败:', error)\n        return NextResponse.json({ error: 'Internal server error' }, { status: 500 })\n    }\n}\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/ai-create-character/route.ts",
    "content": "import { createHash } from 'crypto'\nimport { NextRequest } from 'next/server'\nimport { getProjectModelConfig } from '@/lib/config-service'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = (await request.json().catch(() => ({}))) as Record<string, unknown>\n  const userInstruction = typeof body.userInstruction === 'string' ? body.userInstruction.trim() : ''\n  if (!userInstruction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const modelConfig = await getProjectModelConfig(projectId, session.user.id)\n  if (!modelConfig.analysisModel) {\n    throw new ApiError('MISSING_CONFIG')\n  }\n\n  const dedupeDigest = createHash('sha1')\n    .update(`${projectId}:${session.user.id}:character:${userInstruction}`)\n    .digest('hex')\n    .slice(0, 16)\n\n  const payload = {\n    userInstruction,\n    analysisModel: modelConfig.analysisModel,\n    displayMode: 'detail' as const}\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    type: TASK_TYPE.AI_CREATE_CHARACTER,\n    targetType: 'NovelPromotionCharacterDesign',\n    targetId: projectId,\n    routePath: `/api/novel-promotion/${projectId}/ai-create-character`,\n    body: payload,\n    dedupeKey: `novel_promotion_ai_create_character:${dedupeDigest}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/ai-create-location/route.ts",
    "content": "import { createHash } from 'crypto'\nimport { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { getProjectModelConfig } from '@/lib/config-service'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = (await request.json().catch(() => ({}))) as Record<string, unknown>\n  const userInstruction = typeof body.userInstruction === 'string' ? body.userInstruction.trim() : ''\n  if (!userInstruction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const modelConfig = await getProjectModelConfig(projectId, session.user.id)\n  if (!modelConfig.analysisModel) {\n    throw new ApiError('MISSING_CONFIG')\n  }\n\n  const dedupeDigest = createHash('sha1')\n    .update(`${projectId}:${session.user.id}:location:${userInstruction}`)\n    .digest('hex')\n    .slice(0, 16)\n\n  const payload = {\n    userInstruction,\n    analysisModel: modelConfig.analysisModel,\n    displayMode: 'detail' as const}\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    type: TASK_TYPE.AI_CREATE_LOCATION,\n    targetType: 'NovelPromotionLocationDesign',\n    targetId: projectId,\n    routePath: `/api/novel-promotion/${projectId}/ai-create-location`,\n    body: payload,\n    dedupeKey: `novel_promotion_ai_create_location:${dedupeDigest}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json().catch(() => ({}))\n  const characterId = typeof body?.characterId === 'string' ? body.characterId.trim() : ''\n  const appearanceId = typeof body?.appearanceId === 'string' ? body.appearanceId.trim() : ''\n  const currentDescription = typeof body?.currentDescription === 'string' ? body.currentDescription.trim() : ''\n  const modifyInstruction = typeof body?.modifyInstruction === 'string' ? body.modifyInstruction.trim() : ''\n\n  if (!characterId || !appearanceId || !currentDescription || !modifyInstruction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    type: TASK_TYPE.AI_MODIFY_APPEARANCE,\n    targetType: 'CharacterAppearance',\n    targetId: appearanceId,\n    routePath: `/api/novel-promotion/${projectId}/ai-modify-appearance`,\n    body,\n    dedupeKey: `ai_modify_appearance:${appearanceId}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/ai-modify-location/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json().catch(() => ({}))\n  const locationId = typeof body?.locationId === 'string' ? body.locationId.trim() : ''\n  const imageIndexValue = Number(body?.imageIndex ?? 0)\n  const imageIndex = Number.isFinite(imageIndexValue) ? Math.max(0, Math.floor(imageIndexValue)) : 0\n  const currentDescription = typeof body?.currentDescription === 'string' ? body.currentDescription.trim() : ''\n  const modifyInstruction = typeof body?.modifyInstruction === 'string' ? body.modifyInstruction.trim() : ''\n\n  if (!locationId || !currentDescription || !modifyInstruction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    type: TASK_TYPE.AI_MODIFY_LOCATION,\n    targetType: 'NovelPromotionLocation',\n    targetId: locationId,\n    routePath: `/api/novel-promotion/${projectId}/ai-modify-location`,\n    body: {\n      ...body,\n      imageIndex},\n    dedupeKey: `ai_modify_location:${locationId}:${imageIndex}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/ai-modify-shot-prompt/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session, project } = authResult\n\n  const body = await request.json().catch(() => ({}))\n  const currentPrompt = typeof body?.currentPrompt === 'string' ? body.currentPrompt.trim() : ''\n  const modifyInstruction = typeof body?.modifyInstruction === 'string' ? body.modifyInstruction.trim() : ''\n  if (!currentPrompt || !modifyInstruction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const panelId = typeof body?.panelId === 'string' ? body.panelId.trim() : ''\n  const episodeId = typeof body?.episodeId === 'string' ? body.episodeId.trim() : ''\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    episodeId: episodeId || null,\n    type: TASK_TYPE.AI_MODIFY_SHOT_PROMPT,\n    targetType: panelId ? 'NovelPromotionPanel' : 'NovelPromotionProject',\n    targetId: panelId || projectId,\n    routePath: `/api/novel-promotion/${projectId}/ai-modify-shot-prompt`,\n    body,\n    dedupeKey: panelId ? `ai_modify_shot_prompt:${panelId}` : `ai_modify_shot_prompt:${projectId}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/analyze/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const body = await request.json().catch(() => ({}))\n  const episodeId = typeof body?.episodeId === 'string' ? body.episodeId : null\n\n  const authResult = await requireProjectAuth(projectId, {\n    include: { characters: true, locations: true },\n  })\n  if (isErrorResponse(authResult)) return authResult\n  const { session, project } = authResult\n\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    episodeId,\n    type: TASK_TYPE.ANALYZE_NOVEL,\n    targetType: 'NovelPromotionProject',\n    targetId: projectId,\n    routePath: `/api/novel-promotion/${projectId}/analyze`,\n    body: {\n      ...body,\n      displayMode: 'detail',\n    },\n    dedupeKey: `analyze_novel:${projectId}:${episodeId || 'global'}`,\n    priority: 1,\n  })\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/analyze-global/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\n/**\n * 全局资产分析（任务化）\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session, project } = authResult\n  const body = await request.json().catch(() => ({}))\n\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    type: TASK_TYPE.ANALYZE_GLOBAL,\n    targetType: 'NovelPromotionProject',\n    targetId: projectId,\n    routePath: `/api/novel-promotion/${projectId}/analyze-global`,\n    body,\n    dedupeKey: `analyze_global:${projectId}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/analyze-shot-variants/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const body = await request.json().catch(() => ({}))\n  const panelId = typeof body?.panelId === 'string' ? body.panelId.trim() : ''\n\n  if (!panelId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    episodeId: typeof body?.episodeId === 'string' ? body.episodeId : null,\n    type: TASK_TYPE.ANALYZE_SHOT_VARIANTS,\n    targetType: 'NovelPromotionPanel',\n    targetId: panelId,\n    routePath: `/api/novel-promotion/${projectId}/analyze-shot-variants`,\n    body,\n    dedupeKey: `analyze_shot_variants:${panelId}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/assets/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\nimport { attachMediaFieldsToProject } from '@/lib/media/attach'\n\n/**\n * GET - 获取项目资产（角色 + 场景）\n * 🔥 V6.5: 为 useProjectAssets hook 提供统一的资产数据接口\n */\nexport const GET = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    // 获取项目的角色和场景数据\n    const novelData = await prisma.novelPromotionProject.findUnique({\n        where: { projectId },\n        include: {\n            characters: {\n                include: {\n                    appearances: {\n                        orderBy: { appearanceIndex: 'asc' }\n                    }\n                },\n                orderBy: { createdAt: 'asc' }\n            },\n            locations: {\n                include: {\n                    images: {\n                        orderBy: { imageIndex: 'asc' }\n                    }\n                },\n                orderBy: { createdAt: 'asc' }\n            }\n        }\n    })\n\n    if (!novelData) {\n        return NextResponse.json({ characters: [], locations: [] })\n    }\n\n    // 为资产添加稳定媒体 URL（并保留兼容字段）\n    const withSignedUrls = await attachMediaFieldsToProject(novelData)\n\n    return NextResponse.json({\n        characters: withSignedUrls.characters || [],\n        locations: withSignedUrls.locations || []\n    })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/character/appearance/route.ts",
    "content": "import { logInfo as _ulogInfo, logWarn as _ulogWarn } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { deleteObject } from '@/lib/storage'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST - 为现有角色添加子形象\n * Body: { characterId, changeReason, description }\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { characterId, changeReason, description } = body\n\n  if (!characterId || !changeReason || !description) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 验证角色存在\n  const character = await prisma.novelPromotionCharacter.findUnique({\n    where: { id: characterId },\n    include: {\n      appearances: { orderBy: { appearanceIndex: 'asc' } },\n      novelPromotionProject: true\n    }\n  })\n\n  if (!character) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 验证角色属于当前项目\n  if (character.novelPromotionProject.projectId !== projectId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 计算新的 appearanceIndex\n  const maxIndex = character.appearances.reduce(\n    (max, app) => Math.max(max, app.appearanceIndex),\n    0\n  )\n  const newIndex = maxIndex + 1\n\n  // 创建子形象\n  const newAppearance = await prisma.characterAppearance.create({\n    data: {\n      characterId,\n      appearanceIndex: newIndex,\n      changeReason: changeReason.trim(),\n      description: description.trim(),\n      descriptions: JSON.stringify([description.trim()]),\n      imageUrls: encodeImageUrls([]),\n      previousImageUrls: encodeImageUrls([])}\n  })\n\n  _ulogInfo(`✓ 添加子形象: ${character.name} - ${changeReason} (index: ${newIndex})`)\n\n  return NextResponse.json({\n    success: true,\n    appearance: newAppearance\n  })\n})\n\n/**\n * PATCH - 更新角色形象描述\n * Body: { characterId, appearanceId, description, descriptionIndex }\n */\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { characterId, appearanceId, description, descriptionIndex } = body\n\n  if (!characterId || !appearanceId || !description) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 验证形象存在\n  const appearance = await prisma.characterAppearance.findUnique({\n    where: { id: appearanceId },\n    include: { character: { include: { novelPromotionProject: true } } }\n  })\n\n  if (!appearance) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  if (appearance.characterId !== characterId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 验证角色属于当前项目\n  if (appearance.character.novelPromotionProject.projectId !== projectId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 更新描述\n  const trimmedDesc = description.trim()\n\n  // 更新 descriptions 数组\n  let descriptions: string[] = []\n  try {\n    descriptions = appearance.descriptions ? JSON.parse(appearance.descriptions) : []\n  } catch {\n    descriptions = []\n  }\n\n  // 如果指定了 descriptionIndex，更新对应位置；否则更新/添加第一个\n  const idx = typeof descriptionIndex === 'number' ? descriptionIndex : 0\n  if (idx >= 0 && idx < descriptions.length) {\n    descriptions[idx] = trimmedDesc\n  } else {\n    descriptions.push(trimmedDesc)\n  }\n\n  await prisma.characterAppearance.update({\n    where: { id: appearanceId },\n    data: {\n      description: trimmedDesc,\n      descriptions: JSON.stringify(descriptions)\n    }\n  })\n\n  _ulogInfo(`✓ 更新形象描述: ${appearance.character.name} - ${appearance.changeReason || '形象' + appearance.appearanceIndex}`)\n\n  return NextResponse.json({\n    success: true\n  })\n})\n\n/**\n * DELETE - 删除单个角色形象\n * Query params: characterId, appearanceId\n */\nexport const DELETE = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const { searchParams } = new URL(request.url)\n  const characterId = searchParams.get('characterId')\n  const appearanceId = searchParams.get('appearanceId')\n\n  if (!characterId || !appearanceId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取形象记录\n  const appearance = await prisma.characterAppearance.findUnique({\n    where: { id: appearanceId },\n    include: { character: true }\n  })\n\n  if (!appearance) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  if (appearance.characterId !== characterId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 检查是否是最后一个形象\n  const appearanceCount = await prisma.characterAppearance.count({\n    where: { characterId }\n  })\n\n  if (appearanceCount <= 1) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 删除 COS 中的图片\n  const deletedImages: string[] = []\n\n  // 删除主图片\n  if (appearance.imageUrl) {\n    const key = await resolveStorageKeyFromMediaValue(appearance.imageUrl)\n    if (key) {\n      try {\n        await deleteObject(key)\n        deletedImages.push(key)\n      } catch {\n        _ulogWarn('Failed to delete COS image:', key)\n      }\n    }\n  }\n\n  // 删除图片数组中的所有图片\n  try {\n    const urls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls')\n    for (const url of urls) {\n      if (url) {\n        const key = await resolveStorageKeyFromMediaValue(url)\n        if (key && !deletedImages.includes(key)) {\n          try {\n            await deleteObject(key)\n            deletedImages.push(key)\n          } catch {\n            _ulogWarn('Failed to delete COS image:', key)\n          }\n        }\n      }\n    }\n  } catch {\n    // contract violation is surfaced by migration/validation scripts; keep delete idempotent\n  }\n\n  // 删除数据库记录\n  await prisma.characterAppearance.delete({\n    where: { id: appearanceId }\n  })\n\n  // 重新排序剩余形象的 appearanceIndex\n  const remainingAppearances = await prisma.characterAppearance.findMany({\n    where: { characterId },\n    orderBy: { appearanceIndex: 'asc' }\n  })\n\n  for (let i = 0; i < remainingAppearances.length; i++) {\n    if (remainingAppearances[i].appearanceIndex !== i) {\n      await prisma.characterAppearance.update({\n        where: { id: remainingAppearances[i].id },\n        data: { appearanceIndex: i }\n      })\n    }\n  }\n\n  _ulogInfo(`✓ 删除形象: ${appearance.character.name} - ${appearance.changeReason || '形象' + appearance.appearanceIndex}`)\n  _ulogInfo(`✓ 删除了 ${deletedImages.length} 张 COS 图片`)\n\n  return NextResponse.json({\n    success: true,\n    deletedImages: deletedImages.length\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/character/confirm-selection/route.ts",
    "content": "import { logInfo as _ulogInfo, logWarn as _ulogWarn } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { deleteObject } from '@/lib/storage'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST - 确认选择并删除未选中的候选图片\n * Body: { characterId, appearanceId }\n * \n * 工作流程：\n * 1. 验证已经选择了一张图片（selectedIndex 不为 null）\n * 2. 删除 imageUrls 中未选中的图片（从 COS 和数据库）\n * 3. 将选中的图片设为唯一图片\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { characterId, appearanceId } = body\n\n  if (!characterId || !appearanceId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取形象记录 - 使用 UUID 直接查询\n  const appearance = await prisma.characterAppearance.findUnique({\n    where: { id: appearanceId },\n    include: { character: true }\n  })\n\n  if (!appearance) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 检查是否已选择\n  if (appearance.selectedIndex === null || appearance.selectedIndex === undefined) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 解析图片数组\n  const imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls')\n\n  if (imageUrls.length <= 1) {\n    // 已经只有一张图片，无需操作\n    return NextResponse.json({\n      success: true,\n      message: '已确认选择',\n      deletedCount: 0\n    })\n  }\n\n  const selectedIndex = appearance.selectedIndex\n  const selectedImageUrl = imageUrls[selectedIndex]\n\n  if (!selectedImageUrl) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 删除未选中的图片\n  const deletedImages: string[] = []\n  for (let i = 0; i < imageUrls.length; i++) {\n    if (i !== selectedIndex && imageUrls[i]) {\n      const key = await resolveStorageKeyFromMediaValue(imageUrls[i]!)\n      if (key) {\n        try {\n          await deleteObject(key)\n          deletedImages.push(key)\n        } catch {\n          _ulogWarn('Failed to delete COS image:', key)\n        }\n      }\n    }\n  }\n\n  // 同样处理 descriptions，只保留选中的描述\n  let descriptions: string[] = []\n  if (appearance.descriptions) {\n    try {\n      descriptions = JSON.parse(appearance.descriptions)\n    } catch { }\n  }\n  const selectedDescription = descriptions[selectedIndex] || appearance.description || ''\n\n  // 更新数据库：只保留选中的图片\n  await prisma.characterAppearance.update({\n    where: { id: appearance.id },\n    data: {\n      imageUrl: selectedImageUrl,\n      imageUrls: encodeImageUrls([selectedImageUrl]),  // 只保留选中的图片\n      selectedIndex: 0,  // 现在只有一张，索引为0\n      description: selectedDescription,\n      descriptions: JSON.stringify([selectedDescription])\n    }\n  })\n\n  _ulogInfo(`✓ 确认选择: ${appearance.character.name} - ${appearance.changeReason}`)\n  _ulogInfo(`✓ 删除了 ${deletedImages.length} 张未选中的图片`)\n\n  return NextResponse.json({\n    success: true,\n    message: '已确认选择，其他候选图片已删除',\n    deletedCount: deletedImages.length\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/character/route.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuth, requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { PRIMARY_APPEARANCE_INDEX, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'\nimport { resolveTaskLocale } from '@/lib/task/resolve-locale'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\nimport {\n  collectBailianManagedVoiceIds,\n  cleanupUnreferencedBailianVoices,\n} from '@/lib/providers/bailian'\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction normalizeString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\n// 更新角色信息（名字或介绍）\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { characterId, name, introduction } = body\n\n  if (!characterId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (!name && introduction === undefined) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 构建更新数据\n  const updateData: { name?: string; introduction?: string } = {}\n  if (name) updateData.name = name.trim()\n  if (introduction !== undefined) updateData.introduction = introduction.trim()\n\n  // 更新角色\n  const character = await prisma.novelPromotionCharacter.update({\n    where: { id: characterId },\n    data: updateData\n  })\n\n  return NextResponse.json({ success: true, character })\n})\n\n// 删除角色（级联删除关联的形象记录）\nexport const DELETE = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const { searchParams } = new URL(request.url)\n  const characterId = searchParams.get('id')\n\n  if (!characterId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const character = await prisma.novelPromotionCharacter.findFirst({\n    where: {\n      id: characterId,\n      novelPromotionProject: { projectId },\n    },\n    select: {\n      id: true,\n      voiceId: true,\n      voiceType: true,\n    },\n  })\n  if (!character) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const candidateVoiceIds = collectBailianManagedVoiceIds([\n    {\n      voiceId: character.voiceId,\n      voiceType: character.voiceType,\n    },\n  ])\n  await cleanupUnreferencedBailianVoices({\n    voiceIds: candidateVoiceIds,\n    scope: {\n      userId: session.user.id,\n      excludeNovelCharacterId: character.id,\n    },\n  })\n\n  // 删除角色（CharacterAppearance 会级联删除）\n  await prisma.novelPromotionCharacter.delete({\n    where: { id: characterId }\n  })\n\n  return NextResponse.json({ success: true })\n})\n\n// 新增角色\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { novelData } = authResult\n\n  const rawBody = await request.json().catch(() => ({}))\n  const body = toObject(rawBody)\n  const taskLocale = resolveTaskLocale(request, body)\n  const bodyMeta = toObject(body.meta)\n  const acceptLanguage = request.headers.get('accept-language') || ''\n  const name = normalizeString(body.name)\n  const description = normalizeString(body.description)\n  const referenceImageUrl = normalizeString(body.referenceImageUrl)\n  const generateFromReference = body.generateFromReference === true\n  const customDescription = normalizeString(body.customDescription)\n  const count = generateFromReference\n    ? normalizeImageGenerationCount('reference-to-character', body.count)\n    : normalizeImageGenerationCount('character', body.count)\n  let artStyle: ArtStyleValue | undefined\n  if (Object.prototype.hasOwnProperty.call(body, 'artStyle')) {\n    const parsedArtStyle = normalizeString(body.artStyle)\n    if (!isArtStyleValue(parsedArtStyle)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'INVALID_ART_STYLE',\n        message: 'artStyle must be a supported value',\n      })\n    }\n    artStyle = parsedArtStyle\n  }\n  const resolvedArtStyle: ArtStyleValue = artStyle ?? 'american-comic'\n  const referenceImageUrls = Array.isArray(body.referenceImageUrls)\n    ? body.referenceImageUrls.map((item) => normalizeString(item)).filter(Boolean)\n    : []\n\n  if (!name) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 🔥 支持多张参考图（最多 5 张），兼容单张旧格式\n  let allReferenceImages: string[] = []\n  if (referenceImageUrls.length > 0) {\n    allReferenceImages = referenceImageUrls.slice(0, 5)\n  } else if (referenceImageUrl) {\n    allReferenceImages = [referenceImageUrl]\n  }\n\n  // 创建角色\n  const character = await prisma.novelPromotionCharacter.create({\n    data: {\n      novelPromotionProjectId: novelData.id,\n      name,\n      aliases: null\n    }\n  })\n\n  // 创建初始形象（独立表）\n  const descText = description || `${name} 的角色设定`\n  const appearance = await prisma.characterAppearance.create({\n    data: {\n      characterId: character.id,\n      appearanceIndex: PRIMARY_APPEARANCE_INDEX,\n      changeReason: '初始形象',\n      description: descText,\n      descriptions: JSON.stringify([descText]),\n      imageUrls: encodeImageUrls([]),\n      previousImageUrls: encodeImageUrls([])}\n  })\n\n  if (generateFromReference && allReferenceImages.length > 0) {\n    const { getBaseUrl } = await import('@/lib/env')\n    const baseUrl = getBaseUrl()\n    fetch(`${baseUrl}/api/novel-promotion/${projectId}/reference-to-character`, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'Cookie': request.headers.get('cookie') || '',\n        ...(acceptLanguage ? { 'Accept-Language': acceptLanguage } : {})\n      },\n      body: JSON.stringify({\n        referenceImageUrls: allReferenceImages,\n        characterName: name,\n        characterId: character.id,\n        appearanceId: appearance.id,\n        count,\n        isBackgroundJob: true,\n        artStyle: resolvedArtStyle,\n        customDescription: customDescription || undefined,  // 🔥 传递自定义描述（文生图模式）\n        locale: taskLocale || undefined,\n        meta: {\n          ...bodyMeta,\n          locale: taskLocale || bodyMeta.locale || undefined,\n        },\n      })\n    }).catch(err => {\n      _ulogError('[Character API] 参考图后台生成任务触发失败:', err)\n    })\n  }\n\n  // 返回包含形象的角色数据\n  const characterWithAppearances = await prisma.novelPromotionCharacter.findUnique({\n    where: { id: character.id },\n    include: { appearances: true }\n  })\n\n  return NextResponse.json({ success: true, character: characterWithAppearances })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/character-profile/batch-confirm/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\n/**\n * 批量确认未确认角色档案\n * POST /api/novel-promotion/[projectId]/character-profile/batch-confirm\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json().catch(() => ({}))\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    type: TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM,\n    targetType: 'NovelPromotionProject',\n    targetId: projectId,\n    routePath: `/api/novel-promotion/${projectId}/character-profile/batch-confirm`,\n    body,\n    dedupeKey: `character_profile_batch_confirm:${projectId}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/character-profile/confirm/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\n/**\n * 确认角色档案并生成视觉描述\n * POST /api/novel-promotion/[projectId]/character-profile/confirm\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const body = await request.json().catch(() => ({}))\n  const characterId = typeof body?.characterId === 'string' ? body.characterId.trim() : ''\n\n  if (!characterId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    type: TASK_TYPE.CHARACTER_PROFILE_CONFIRM,\n    targetType: 'NovelPromotionCharacter',\n    targetId: characterId,\n    routePath: `/api/novel-promotion/${projectId}/character-profile/confirm`,\n    body,\n    dedupeKey: `character_profile_confirm:${characterId}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/character-voice/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { uploadObject, generateUniqueKey, getSignedUrl } from '@/lib/storage'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * PATCH /api/novel-promotion/[projectId]/character-voice\n * 更新角色的配音音色设置\n * Body: { characterId, voiceType, voiceId, customVoiceUrl }\n */\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { characterId, voiceType, voiceId, customVoiceUrl } = body\n\n  if (!characterId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 更新角色音色设置\n  const character = await prisma.novelPromotionCharacter.update({\n    where: { id: characterId },\n    data: {\n      voiceType: voiceType || null,\n      voiceId: voiceId || null,\n      customVoiceUrl: customVoiceUrl || null\n    }\n  })\n\n  return NextResponse.json({ success: true, character })\n})\n\n/**\n * POST /api/novel-promotion/[projectId]/character-voice\n * 上传自定义音色音频 或 保存 AI 设计的声音\n * FormData: { characterId, file } - 文件上传\n * JSON: { characterId, voiceDesign: { voiceId, audioBase64 } } - AI 声音设计\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const contentType = request.headers.get('content-type') || ''\n\n  // 处理 JSON 请求（AI 声音设计）\n  if (contentType.includes('application/json')) {\n    const body = await request.json()\n    const { characterId, voiceDesign } = body\n\n    if (!characterId || !voiceDesign) {\n      throw new ApiError('INVALID_PARAMS')\n    }\n\n    const { voiceId, audioBase64 } = voiceDesign\n    if (!voiceId || !audioBase64) {\n      throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 解码 base64 音频\n    const audioBuffer = Buffer.from(audioBase64, 'base64')\n\n    // 上传到COS\n    const key = generateUniqueKey(`voice/custom/${projectId}/${characterId}`, 'wav')\n    const cosUrl = await uploadObject(audioBuffer, key)\n\n    // 更新角色音色设置\n    const character = await prisma.novelPromotionCharacter.update({\n      where: { id: characterId },\n      data: {\n        voiceType: 'qwen-designed',\n        voiceId: voiceId,  // 保存 AI 生成的 voice ID\n        customVoiceUrl: cosUrl\n      }\n    })\n\n    _ulogInfo(`Character ${characterId} AI-designed voice saved: ${cosUrl}, voiceId: ${voiceId}`)\n\n    // 返回签名URL\n    const signedAudioUrl = getSignedUrl(cosUrl, 7200)\n\n    return NextResponse.json({\n      success: true,\n      audioUrl: signedAudioUrl,\n      character: {\n        ...character,\n        customVoiceUrl: signedAudioUrl\n      }\n    })\n  }\n\n  // 处理 FormData 请求（文件上传）\n  const formData = await request.formData()\n  const file = formData.get('file') as File\n  const characterId = formData.get('characterId') as string\n\n  if (!file || !characterId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 验证文件类型\n  const allowedTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a']\n  if (!allowedTypes.includes(file.type) && !file.name.match(/\\.(mp3|wav|ogg|m4a)$/i)) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 读取文件\n  const arrayBuffer = await file.arrayBuffer()\n  const buffer = Buffer.from(arrayBuffer)\n\n  // 获取文件扩展名\n  const ext = file.name.split('.').pop()?.toLowerCase() || 'mp3'\n\n  // 上传到COS\n  const key = generateUniqueKey(`voice/custom/${projectId}/${characterId}`, ext)\n  const audioUrl = await uploadObject(buffer, key)\n\n  // 更新角色音色设置为自定义\n  const character = await prisma.novelPromotionCharacter.update({\n    where: { id: characterId },\n    data: {\n      voiceType: 'uploaded',\n      voiceId: null,\n      customVoiceUrl: audioUrl\n    }\n  })\n\n  _ulogInfo(`Character ${characterId} voice uploaded: ${audioUrl}`)\n\n  // 返回签名URL，以便前端可以立即播放\n  const signedAudioUrl = getSignedUrl(audioUrl, 7200)\n\n  return NextResponse.json({\n    success: true,\n    audioUrl: signedAudioUrl,\n    character: {\n      ...character,\n      customVoiceUrl: signedAudioUrl // 返回签名URL给前端\n    }\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/cleanup-unselected-images/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { deleteObject } from '@/lib/storage'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\n\n/**\n * POST - 清理未选中的图片\n * 在用户确认资产进入下一步时调用\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { novelData } = authResult\n\n  let deletedCount = 0\n\n  // 1. 清理角色形象的未选中图片\n  const appearances = await prisma.characterAppearance.findMany({\n    where: { character: { novelPromotionProjectId: novelData.id } },\n    include: { character: true }\n  })\n\n  for (const appearance of appearances) {\n    if (appearance.selectedIndex === null) continue\n\n    try {\n      const imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls')\n      if (imageUrls.length <= 1) continue\n\n      // 删除未选中的图片\n      for (let i = 0; i < imageUrls.length; i++) {\n        if (i !== appearance.selectedIndex && imageUrls[i]) {\n          try {\n            const key = await resolveStorageKeyFromMediaValue(imageUrls[i]!)\n            if (key) {\n              await deleteObject(key)\n              _ulogInfo(`✓ Deleted: ${key}`)\n              deletedCount++\n            }\n          } catch { }\n        }\n      }\n\n      // 只保留选中的图片\n      const selectedUrl = imageUrls[appearance.selectedIndex]\n      if (!selectedUrl) continue\n      await prisma.characterAppearance.update({\n        where: { id: appearance.id },\n        data: {\n          imageUrls: encodeImageUrls([selectedUrl]),\n          selectedIndex: 0\n        }\n      })\n    } catch { }\n  }\n\n  // 2. 清理场景的未选中图片\n  const locations = await prisma.novelPromotionLocation.findMany({\n    where: { novelPromotionProjectId: novelData.id },\n    include: { images: true }\n  })\n\n  for (const location of locations) {\n    const selectedImage = location.selectedImageId\n      ? location.images.find(img => img.id === location.selectedImageId)\n      : location.images.find(img => img.isSelected)\n    if (!selectedImage) continue\n\n    // 删除未选中的图片\n    for (const img of location.images) {\n      if (!img.isSelected && img.imageUrl) {\n        try {\n          const key = await resolveStorageKeyFromMediaValue(img.imageUrl)\n          if (key) {\n            await deleteObject(key)\n            _ulogInfo(`✓ Deleted: ${key}`)\n            deletedCount++\n          }\n        } catch { }\n\n        // 删除图片记录\n        await prisma.locationImage.delete({ where: { id: img.id } })\n      }\n    }\n\n    // 重置选中图片的索引为0\n    await prisma.locationImage.update({\n      where: { id: selectedImage.id },\n      data: { imageIndex: 0 }\n    })\n\n    await prisma.novelPromotionLocation.update({\n      where: { id: location.id },\n      data: { selectedImageId: selectedImage.id }\n    })\n  }\n\n  return NextResponse.json({ success: true, deletedCount })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/clips/[clipId]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\n\n/**\n * PATCH /api/novel-promotion/[projectId]/clips/[clipId]\n * 更新单个 Clip 的信息\n * 支持更新：characters, location, content, screenplay\n */\nexport const PATCH = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string; clipId: string }> }\n) => {\n    const { projectId, clipId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    const body = await request.json()\n    const { characters, location, content, screenplay } = body\n\n    // 验证 Clip 是否存在且属于该项目（间接验证）\n    // 这里简化处理，直接通过 ID 更新，Prisma 会处理是否存在\n    // 严谨做法是先查 Clip -> Episode -> Project 确认归属，但考虑到 projectId 主要是路由参数校验，且用户只能删改自己的数据\n\n    const updateData: {\n        characters?: string | null\n        location?: string | null\n        content?: string\n        screenplay?: string | null\n    } = {}\n    if (characters !== undefined) updateData.characters = characters // JSON string\n    if (location !== undefined) updateData.location = location\n    if (content !== undefined) updateData.content = content\n    if (screenplay !== undefined) updateData.screenplay = screenplay // JSON string\n\n    const clip = await prisma.novelPromotionClip.update({\n        where: { id: clipId },\n        data: updateData\n    })\n\n    return NextResponse.json({ success: true, clip })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/clips/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\n/**\n * POST /api/novel-promotion/[projectId]/clips\n * 生成 clips（第二步：片段切分）\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const body = await request.json().catch(() => ({}))\n  const episodeId = typeof body?.episodeId === 'string' ? body.episodeId.trim() : ''\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const authResult = await requireProjectAuth(projectId, {\n    include: { characters: true, locations: true },\n  })\n  if (isErrorResponse(authResult)) return authResult\n  const { session, project } = authResult\n\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    episodeId,\n    type: TASK_TYPE.CLIPS_BUILD,\n    targetType: 'NovelPromotionEpisode',\n    targetId: episodeId,\n    routePath: `/api/novel-promotion/${projectId}/clips`,\n    body: {\n      ...body,\n      displayMode: 'detail',\n    },\n    dedupeKey: `clips_build:${episodeId}`,\n    priority: 1,\n  })\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/copy-from-global/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { updateCharacterAppearanceLabels, updateLocationImageLabels } from '@/lib/image-label'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface GlobalCharacterAppearanceSource {\n    appearanceIndex: number\n    changeReason: string\n    description: string | null\n    descriptions: string | null\n    imageUrl: string | null\n    imageUrls: string | null\n    selectedIndex: number | null\n}\n\ninterface GlobalCharacterSource {\n    name: string\n    voiceId: string | null\n    voiceType: string | null\n    customVoiceUrl: string | null\n    appearances: GlobalCharacterAppearanceSource[]\n}\n\ninterface GlobalLocationImageSource {\n    imageIndex: number\n    description: string | null\n    imageUrl: string | null\n    isSelected: boolean\n}\n\ninterface GlobalLocationSource {\n    name: string\n    summary: string | null\n    images: GlobalLocationImageSource[]\n}\n\ninterface GlobalVoiceSource {\n    name: string\n    voiceId: string | null\n    voiceType: string | null\n    customVoiceUrl: string | null\n}\n\ninterface CopyFromGlobalDb {\n    globalCharacter: {\n        findFirst(args: Record<string, unknown>): Promise<GlobalCharacterSource | null>\n    }\n    globalLocation: {\n        findFirst(args: Record<string, unknown>): Promise<GlobalLocationSource | null>\n    }\n    globalVoice: {\n        findFirst(args: Record<string, unknown>): Promise<GlobalVoiceSource | null>\n    }\n}\n\n/**\n * POST /api/novel-promotion/[projectId]/copy-from-global\n * 从资产中心复制角色/场景的形象数据到项目资产\n * \n * 复制而非引用：即使全局资产被删除，项目资产也不受影响\n */\nexport const POST = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n    const db = prisma as unknown as CopyFromGlobalDb\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n    const session = authResult.session\n\n    const body = await request.json()\n    const { type, targetId, globalAssetId } = body\n\n    if (!type || !targetId || !globalAssetId) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    if (type === 'character') {\n        return await copyCharacterFromGlobal(db, session.user.id, targetId, globalAssetId)\n    } else if (type === 'location') {\n        return await copyLocationFromGlobal(db, session.user.id, targetId, globalAssetId)\n    } else if (type === 'voice') {\n        return await copyVoiceFromGlobal(db, session.user.id, targetId, globalAssetId)\n    } else {\n        throw new ApiError('INVALID_PARAMS')\n    }\n})\n\n/**\n * 复制全局角色的形象到项目角色\n */\nasync function copyCharacterFromGlobal(db: CopyFromGlobalDb, userId: string, targetId: string, globalCharacterId: string) {\n    _ulogInfo(`[Copy from Global] 复制角色: global=${globalCharacterId} -> project=${targetId}`)\n\n    // 1. 获取全局角色及其形象\n    const globalCharacter = await db.globalCharacter.findFirst({\n        where: { id: globalCharacterId, userId },\n        include: { appearances: true }\n    })\n\n    if (!globalCharacter) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 2. 获取项目角色\n    const projectCharacter = await prisma.novelPromotionCharacter.findUnique({\n        where: { id: targetId },\n        include: { appearances: true }\n    })\n\n    if (!projectCharacter) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 3. 删除项目角色的旧形象\n    if (projectCharacter.appearances.length > 0) {\n        await prisma.characterAppearance.deleteMany({\n            where: { characterId: targetId }\n        })\n        _ulogInfo(`[Copy from Global] 删除了 ${projectCharacter.appearances.length} 个旧形象`)\n    }\n\n    // 4. 🔥 更新黑边标签：使用项目角色名替换资产中心的角色名\n    _ulogInfo(`[Copy from Global] 更新黑边标签: ${globalCharacter.name} -> ${projectCharacter.name}`)\n    const updatedLabels = await updateCharacterAppearanceLabels(\n        globalCharacter.appearances.map((app) => ({\n            imageUrl: app.imageUrl,\n            imageUrls: encodeImageUrls(decodeImageUrlsFromDb(app.imageUrls, 'globalCharacterAppearance.imageUrls')),\n            changeReason: app.changeReason\n        })),\n        projectCharacter.name\n    )\n\n    // 5. 复制全局形象到项目（使用更新后的图片URL）\n    const copiedAppearances = []\n    for (let i = 0; i < globalCharacter.appearances.length; i++) {\n        const app = globalCharacter.appearances[i]\n        const labelUpdate = updatedLabels[i]\n        const originalImageUrls = decodeImageUrlsFromDb(app.imageUrls, 'globalCharacterAppearance.imageUrls')\n\n        const newAppearance = await prisma.characterAppearance.create({\n            data: {\n                characterId: targetId,\n                appearanceIndex: app.appearanceIndex,\n                changeReason: app.changeReason,\n                description: app.description,\n                descriptions: app.descriptions,\n                // 🔥 使用更新了标签的新图片URL\n                imageUrl: labelUpdate?.imageUrl || app.imageUrl,\n                imageUrls: labelUpdate?.imageUrls || encodeImageUrls(originalImageUrls),\n                previousImageUrls: encodeImageUrls([]),\n                selectedIndex: app.selectedIndex\n            }\n        })\n        copiedAppearances.push(newAppearance)\n    }\n    _ulogInfo(`[Copy from Global] 复制了 ${copiedAppearances.length} 个形象（已更新标签）`)\n\n    // 6. 更新项目角色：记录来源ID，并标记档案已确认\n    const updatedCharacter = await prisma.novelPromotionCharacter.update({\n        where: { id: targetId },\n        data: {\n            sourceGlobalCharacterId: globalCharacterId,\n            // 使用已有形象相当于确认了角色档案\n            profileConfirmed: true,\n            // 可选：复制语音设置\n            voiceId: globalCharacter.voiceId,\n            voiceType: globalCharacter.voiceType,\n            customVoiceUrl: globalCharacter.customVoiceUrl\n        },\n        include: { appearances: true }\n    })\n\n    _ulogInfo(`[Copy from Global] 角色复制完成: ${projectCharacter.name}`)\n\n    return NextResponse.json({\n        success: true,\n        character: updatedCharacter,\n        copiedAppearancesCount: copiedAppearances.length\n    })\n}\n\n/**\n * 复制全局场景的图片到项目场景\n */\nasync function copyLocationFromGlobal(db: CopyFromGlobalDb, userId: string, targetId: string, globalLocationId: string) {\n    _ulogInfo(`[Copy from Global] 复制场景: global=${globalLocationId} -> project=${targetId}`)\n\n    // 1. 获取全局场景及其图片\n    const globalLocation = await db.globalLocation.findFirst({\n        where: { id: globalLocationId, userId },\n        include: { images: true }\n    })\n\n    if (!globalLocation) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 2. 获取项目场景\n    const projectLocation = await prisma.novelPromotionLocation.findUnique({\n        where: { id: targetId },\n        include: { images: true }\n    })\n\n    if (!projectLocation) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 3. 删除项目场景的旧图片\n    if (projectLocation.images.length > 0) {\n        await prisma.locationImage.deleteMany({\n            where: { locationId: targetId }\n        })\n        _ulogInfo(`[Copy from Global] 删除了 ${projectLocation.images.length} 个旧图片`)\n    }\n\n    // 4. 🔥 更新黑边标签：使用项目场景名替换资产中心的场景名\n    _ulogInfo(`[Copy from Global] 更新黑边标签: ${globalLocation.name} -> ${projectLocation.name}`)\n    const updatedLabels = await updateLocationImageLabels(\n        globalLocation.images.map((img) => ({\n            imageUrl: img.imageUrl\n        })),\n        projectLocation.name\n    )\n\n    // 5. 复制全局图片到项目（使用更新后的图片URL）\n    const copiedImages: Array<{ id: string; imageIndex: number; imageUrl: string | null }> = []\n    for (let i = 0; i < globalLocation.images.length; i++) {\n        const img = globalLocation.images[i]\n        const labelUpdate = updatedLabels[i]\n\n        const newImage = await prisma.locationImage.create({\n            data: {\n                locationId: targetId,\n                imageIndex: img.imageIndex,\n                description: img.description,\n                // 🔥 使用更新了标签的新图片URL\n                imageUrl: labelUpdate?.imageUrl || img.imageUrl,\n                isSelected: img.isSelected\n            }\n        })\n        copiedImages.push(newImage)\n    }\n    _ulogInfo(`[Copy from Global] 复制了 ${copiedImages.length} 个图片（已更新标签）`)\n\n    const selectedFromGlobal = globalLocation.images.find((img) => img.isSelected)\n    const selectedImageId = selectedFromGlobal\n        ? copiedImages.find(i => i.imageIndex === selectedFromGlobal.imageIndex)?.id\n        : copiedImages.find(i => i.imageUrl)?.id || null\n    await prisma.novelPromotionLocation.update({\n        where: { id: targetId },\n        data: { selectedImageId }\n    })\n\n    // 6. 更新项目场景：记录来源ID 和 summary\n    const updatedLocation = await prisma.novelPromotionLocation.update({\n        where: { id: targetId },\n        data: {\n            sourceGlobalLocationId: globalLocationId,\n            summary: globalLocation.summary\n        },\n        include: { images: true }\n    })\n\n    _ulogInfo(`[Copy from Global] 场景复制完成: ${projectLocation.name}`)\n\n    return NextResponse.json({\n        success: true,\n        location: updatedLocation,\n        copiedImagesCount: copiedImages.length\n    })\n}\n\n/**\n * 复制全局音色到项目角色\n */\nasync function copyVoiceFromGlobal(db: CopyFromGlobalDb, userId: string, targetCharacterId: string, globalVoiceId: string) {\n    _ulogInfo(`[Copy from Global] 复制音色: global=${globalVoiceId} -> project character=${targetCharacterId}`)\n\n    // 1. 获取全局音色\n    const globalVoice = await db.globalVoice.findFirst({\n        where: { id: globalVoiceId, userId }\n    })\n\n    if (!globalVoice) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 2. 获取项目角色\n    const projectCharacter = await prisma.novelPromotionCharacter.findUnique({\n        where: { id: targetCharacterId }\n    })\n\n    if (!projectCharacter) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 3. 更新项目角色的音色设置\n    const updatedCharacter = await prisma.novelPromotionCharacter.update({\n        where: { id: targetCharacterId },\n        data: {\n            voiceId: globalVoice.voiceId,\n            voiceType: globalVoice.voiceType,  // 'qwen-designed' | 'custom'\n            customVoiceUrl: globalVoice.customVoiceUrl\n        }\n    })\n\n    _ulogInfo(`[Copy from Global] 音色复制完成: ${projectCharacter.name} <- ${globalVoice.name}`)\n\n    return NextResponse.json({\n        success: true,\n        character: updatedCharacter,\n        voiceName: globalVoice.name\n    })\n}\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/download-images/route.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport archiver from 'archiver'\nimport { getObjectBuffer, toFetchableUrl } from '@/lib/storage'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface PanelData {\n  panelIndex: number | null\n  description: string | null\n  imageUrl: string | null\n}\n\ninterface StoryboardData {\n  clipId: string\n  panels?: PanelData[]\n}\n\ninterface ClipData {\n  id: string\n}\n\ninterface EpisodeData {\n  storyboards?: StoryboardData[]\n  clips?: ClipData[]\n}\n\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n  const { searchParams } = new URL(request.url)\n  const episodeId = searchParams.get('episodeId')\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { project } = authResult\n\n  // 根据是否指定 episodeId 来获取数据\n  let episodes: EpisodeData[] = []\n\n  if (episodeId) {\n    // 只获取指定剧集的数据\n    const episode = await prisma.novelPromotionEpisode.findUnique({\n      where: { id: episodeId },\n      include: {\n        storyboards: {\n          include: {\n            panels: { orderBy: { panelIndex: 'asc' } }\n          },\n          orderBy: { createdAt: 'asc' }\n        },\n        clips: {\n          orderBy: { createdAt: 'asc' }\n        }\n      }\n    })\n    if (episode) {\n      episodes = [episode]\n    }\n  } else {\n    // 获取所有剧集的数据\n    const npData = await prisma.novelPromotionProject.findFirst({\n      where: { projectId },\n      include: {\n        episodes: {\n          include: {\n            storyboards: {\n              include: {\n                panels: { orderBy: { panelIndex: 'asc' } }\n              },\n              orderBy: { createdAt: 'asc' }\n            },\n            clips: {\n              orderBy: { createdAt: 'asc' }\n            }\n          }\n        }\n      }\n    })\n    episodes = npData?.episodes || []\n  }\n\n  if (episodes.length === 0) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 收集所有有图片的 panel\n  interface ImageItem {\n    description: string\n    imageUrl: string\n    clipIndex: number\n    panelIndex: number\n  }\n  const images: ImageItem[] = []\n\n  // 从 episodes 中获取所有 storyboards 和 clips\n  const allStoryboards: StoryboardData[] = []\n  const allClips: ClipData[] = []\n  for (const episode of episodes) {\n    allStoryboards.push(...(episode.storyboards || []))\n    allClips.push(...(episode.clips || []))\n  }\n\n  // 遍历所有 storyboard 和 panel\n  for (const storyboard of allStoryboards) {\n    // 使用 clip 在 clips 数组中的索引来排序\n    const clipIndex = allClips.findIndex((clip) => clip.id === storyboard.clipId)\n\n    // 使用独立的 Panel 记录\n    const panels = storyboard.panels || []\n    for (const panel of panels) {\n      if (panel.imageUrl) {\n        images.push({\n          description: panel.description || `镜头`,\n          imageUrl: panel.imageUrl,\n          clipIndex: clipIndex >= 0 ? clipIndex : 999,\n          panelIndex: panel.panelIndex || 0\n        })\n      }\n    }\n  }\n\n  // 按 clipIndex 和 panelIndex 排序\n  images.sort((a, b) => {\n    if (a.clipIndex !== b.clipIndex) {\n      return a.clipIndex - b.clipIndex\n    }\n    return a.panelIndex - b.panelIndex\n  })\n\n  // 重新分配连续的全局索引\n  const indexedImages = images.map((v, idx) => ({\n    ...v,\n    index: idx + 1\n  }))\n\n  if (indexedImages.length === 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  _ulogInfo(`Preparing to download ${indexedImages.length} images for project ${projectId}`)\n\n  const archive = archiver('zip', { zlib: { level: 9 } })\n\n  const stream = new ReadableStream({\n    start(controller) {\n      archive.on('data', (chunk) => controller.enqueue(chunk))\n      archive.on('end', () => controller.close())\n      archive.on('error', (err) => controller.error(err))\n      processImages()\n    }\n  })\n\n  async function processImages() {\n    for (const image of indexedImages) {\n      try {\n        _ulogInfo(`Downloading image ${image.index}: ${image.imageUrl}`)\n\n        let imageData: Buffer\n        let extension = 'png'\n        const storageKey = await resolveStorageKeyFromMediaValue(image.imageUrl)\n\n        if (image.imageUrl.startsWith('http://') || image.imageUrl.startsWith('https://')) {\n          // 外部 URL，直接下载\n          const response = await fetch(toFetchableUrl(image.imageUrl))\n          if (!response.ok) {\n            throw new Error(`Failed to fetch: ${response.statusText}`)\n          }\n          const arrayBuffer = await response.arrayBuffer()\n          imageData = Buffer.from(arrayBuffer)\n          const contentType = response.headers.get('content-type')\n          if (contentType?.includes('jpeg') || contentType?.includes('jpg')) {\n            extension = 'jpg'\n          } else if (contentType?.includes('webp')) {\n            extension = 'webp'\n          }\n        } else if (storageKey) {\n          imageData = await getObjectBuffer(storageKey)\n\n          const keyExt = storageKey.split('.').pop()?.toLowerCase()\n          if (keyExt && ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(keyExt)) {\n            extension = keyExt === 'jpeg' ? 'jpg' : keyExt\n          }\n        } else {\n          const response = await fetch(toFetchableUrl(image.imageUrl))\n          if (!response.ok) {\n            throw new Error(`Failed to fetch: ${response.statusText}`)\n          }\n          const arrayBuffer = await response.arrayBuffer()\n          imageData = Buffer.from(arrayBuffer)\n        }\n\n        // 文件名使用描述，清理非法字符\n        const safeDesc = image.description.slice(0, 50).replace(/[\\\\/:*?\"<>|]/g, '_')\n        const fileName = `${String(image.index).padStart(3, '0')}_${safeDesc}.${extension}`\n        archive.append(imageData, { name: fileName })\n        _ulogInfo(`Added ${fileName} to archive`)\n      } catch (error) {\n        _ulogError(`Failed to download image ${image.index}:`, error)\n      }\n    }\n\n    await archive.finalize()\n    _ulogInfo('Archive finalized')\n  }\n\n  return new Response(stream, {\n    headers: {\n      'Content-Type': 'application/zip',\n      'Content-Disposition': `attachment; filename=\"${encodeURIComponent(project.name)}_images.zip\"`\n    }\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/download-videos/route.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport archiver from 'archiver'\nimport { getObjectBuffer, toFetchableUrl } from '@/lib/storage'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface PanelData {\n  panelIndex: number | null\n  description: string | null\n  videoUrl: string | null\n  lipSyncVideoUrl: string | null\n}\n\ninterface StoryboardData {\n  id: string\n  clipId: string\n  panels?: PanelData[]\n}\n\ninterface ClipData {\n  id: string\n}\n\ninterface EpisodeData {\n  storyboards?: StoryboardData[]\n  clips?: ClipData[]\n}\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 解析请求体\n  const body = await request.json()\n  const { episodeId, panelPreferences } = body as {\n    episodeId?: string\n    panelPreferences?: Record<string, boolean>  // key: panelKey, value: true=口型同步, false=原始\n  }\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { project } = authResult\n\n  // 根据是否指定 episodeId 来获取数据\n  let episodes: EpisodeData[] = []\n\n  if (episodeId) {\n    // 只获取指定剧集的数据\n    const episode = await prisma.novelPromotionEpisode.findUnique({\n      where: { id: episodeId },\n      include: {\n        storyboards: {\n          include: {\n            panels: { orderBy: { panelIndex: 'asc' } }\n          },\n          orderBy: { createdAt: 'asc' }\n        },\n        clips: {\n          orderBy: { createdAt: 'asc' }\n        }\n      }\n    })\n    if (episode) {\n      episodes = [episode]\n    }\n  } else {\n    // 获取所有剧集的数据\n    const npData = await prisma.novelPromotionProject.findFirst({\n      where: { projectId },\n      include: {\n        episodes: {\n          include: {\n            storyboards: {\n              include: {\n                panels: { orderBy: { panelIndex: 'asc' } }\n              },\n              orderBy: { createdAt: 'asc' }\n            },\n            clips: {\n              orderBy: { createdAt: 'asc' }\n            }\n          }\n        }\n      }\n    })\n    episodes = npData?.episodes || []\n  }\n\n  if (episodes.length === 0) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 收集所有有视频的 panel\n  interface VideoItem {\n    description: string\n    videoUrl: string\n    clipIndex: number  // 使用 clip 在数组中的索引\n    panelIndex: number\n    isLipSync?: boolean  // 是否为口型同步视频\n  }\n  const videos: VideoItem[] = []\n\n  // 从 episodes 中获取所有 storyboards 和 clips\n  const allStoryboards: StoryboardData[] = []\n  const allClips: ClipData[] = []\n  for (const episode of episodes) {\n    allStoryboards.push(...(episode.storyboards || []))\n    allClips.push(...(episode.clips || []))\n  }\n\n  // 遍历所有 storyboard 和 panel\n  for (const storyboard of allStoryboards) {\n    // 使用 clip 在 clips 数组中的索引来排序（兼容 Agent 模式）\n    const clipIndex = allClips.findIndex((clip) => clip.id === storyboard.clipId)\n\n    // 使用独立的 Panel 记录\n    const panels = storyboard.panels || []\n    for (const panel of panels) {\n      // 构建 panelKey 用于查找偏好\n      const panelKey = `${storyboard.id}-${panel.panelIndex || 0}`\n      // 获取该 panel 的偏好，默认 true（口型同步优先）\n      const preferLipSync = panelPreferences?.[panelKey] ?? true\n\n      // 根据用户偏好选择视频类型\n      let videoUrl: string | null = null\n      let isLipSync = false\n\n      if (preferLipSync) {\n        // 优先口型同步视频，其次原始视频\n        videoUrl = panel.lipSyncVideoUrl || panel.videoUrl\n        isLipSync = !!panel.lipSyncVideoUrl\n      } else {\n        // 优先原始视频，其次口型同步视频（如果只有口型同步视频也下载）\n        videoUrl = panel.videoUrl || panel.lipSyncVideoUrl\n        isLipSync = !panel.videoUrl && !!panel.lipSyncVideoUrl\n      }\n\n      if (videoUrl) {\n        videos.push({\n          description: panel.description || `镜头`,\n          videoUrl: videoUrl,\n          clipIndex: clipIndex >= 0 ? clipIndex : 999,  // 找不到时排最后\n          panelIndex: panel.panelIndex || 0,\n          isLipSync\n        })\n      }\n    }\n  }\n\n  // 按 clipIndex 和 panelIndex 排序\n  videos.sort((a, b) => {\n    if (a.clipIndex !== b.clipIndex) {\n      return a.clipIndex - b.clipIndex\n    }\n    return a.panelIndex - b.panelIndex\n  })\n\n  // 重新分配连续的全局索引\n  const indexedVideos = videos.map((v, idx) => ({\n    ...v,\n    index: idx + 1\n  }))\n\n  if (indexedVideos.length === 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  _ulogInfo(`Preparing to download ${indexedVideos.length} videos for project ${projectId}`)\n\n  const archive = archiver('zip', { zlib: { level: 9 } })\n\n  // 创建一个 Promise 来追踪归档完成状态\n  const archiveFinished = new Promise<void>((resolve, reject) => {\n    archive.on('end', () => resolve())\n    archive.on('error', (err) => {\n      reject(err)\n    })\n  })\n\n  // 使用 PassThrough 流来收集数据\n  const chunks: Uint8Array[] = []\n  archive.on('data', (chunk) => {\n    chunks.push(chunk)\n  })\n\n  // 处理视频并打包\n  for (const video of indexedVideos) {\n    try {\n      _ulogInfo(`Downloading video ${video.index}: ${video.videoUrl}`)\n\n      let videoData: Buffer\n      const storageKey = await resolveStorageKeyFromMediaValue(video.videoUrl)\n\n      if (video.videoUrl.startsWith('http://') || video.videoUrl.startsWith('https://')) {\n        const response = await fetch(toFetchableUrl(video.videoUrl))\n        if (!response.ok) {\n          throw new Error(`Failed to fetch: ${response.statusText}`)\n        }\n        const arrayBuffer = await response.arrayBuffer()\n        videoData = Buffer.from(arrayBuffer)\n      } else if (storageKey) {\n        videoData = await getObjectBuffer(storageKey)\n      } else {\n        const response = await fetch(toFetchableUrl(video.videoUrl))\n        if (!response.ok) {\n          throw new Error(`Failed to fetch: ${response.statusText}`)\n        }\n        const arrayBuffer = await response.arrayBuffer()\n        videoData = Buffer.from(arrayBuffer)\n      }\n\n      // 文件名使用描述，清理非法字符\n      const safeDesc = video.description.slice(0, 50).replace(/[\\\\/:*?\"<>|]/g, '_')\n      const fileName = `${String(video.index).padStart(3, '0')}_${safeDesc}.mp4`\n      archive.append(videoData, { name: fileName })\n      _ulogInfo(`Added ${fileName} to archive`)\n    } catch (error) {\n      _ulogError(`Failed to download video ${video.index}:`, error)\n    }\n  }\n\n  // 完成归档\n  await archive.finalize()\n  _ulogInfo('Archive finalized')\n\n  // 等待归档完成\n  await archiveFinished\n\n  // 合并所有数据块\n  const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)\n  const result = new Uint8Array(totalLength)\n  let offset = 0\n  for (const chunk of chunks) {\n    result.set(chunk, offset)\n    offset += chunk.length\n  }\n\n  return new Response(result, {\n    headers: {\n      'Content-Type': 'application/zip',\n      'Content-Disposition': `attachment; filename=\"${encodeURIComponent(project.name)}_videos.zip\"`\n    }\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/download-voices/route.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport archiver from 'archiver'\nimport { getObjectBuffer, toFetchableUrl } from '@/lib/storage'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n  const { searchParams } = new URL(request.url)\n  const episodeId = searchParams.get('episodeId')\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { project } = authResult\n\n  // 获取配音台词\n  const whereClause: Record<string, unknown> = {\n    audioUrl: { not: null }\n  }\n\n  if (episodeId) {\n    whereClause.episodeId = episodeId\n  } else {\n    // 如果没有指定 episodeId，获取该项目所有剧集的配音\n    const npData = await prisma.novelPromotionProject.findFirst({\n      where: { projectId },\n      include: { episodes: { select: { id: true } } }\n    })\n    if (npData?.episodes) {\n      whereClause.episodeId = { in: npData.episodes.map(e => e.id) }\n    }\n  }\n\n  const voiceLines = await prisma.novelPromotionVoiceLine.findMany({\n    where: whereClause,\n    orderBy: [\n      { lineIndex: 'asc' }  // 按台词序号排序（绝对顺序）\n    ]\n  })\n\n  if (voiceLines.length === 0) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  _ulogInfo(`Preparing to download ${voiceLines.length} voice lines for project ${projectId}`)\n\n  const archive = archiver('zip', { zlib: { level: 9 } })\n\n  const stream = new ReadableStream({\n    start(controller) {\n      archive.on('data', (chunk) => controller.enqueue(chunk))\n      archive.on('end', () => controller.close())\n      archive.on('error', (err) => controller.error(err))\n      processVoices()\n    }\n  })\n\n  async function processVoices() {\n    for (const line of voiceLines) {\n      try {\n        if (!line.audioUrl) continue\n\n        _ulogInfo(`Downloading voice ${line.lineIndex}: ${line.audioUrl}`)\n\n        let audioData: Buffer\n        const storageKey = await resolveStorageKeyFromMediaValue(line.audioUrl)\n\n        if (line.audioUrl.startsWith('http://') || line.audioUrl.startsWith('https://')) {\n          const response = await fetch(toFetchableUrl(line.audioUrl))\n          if (!response.ok) {\n            throw new Error(`Failed to fetch: ${response.statusText}`)\n          }\n          const arrayBuffer = await response.arrayBuffer()\n          audioData = Buffer.from(arrayBuffer)\n        } else if (storageKey) {\n          audioData = await getObjectBuffer(storageKey)\n        } else {\n          const response = await fetch(toFetchableUrl(line.audioUrl))\n          if (!response.ok) {\n            throw new Error(`Failed to fetch: ${response.statusText}`)\n          }\n          const arrayBuffer = await response.arrayBuffer()\n          audioData = Buffer.from(arrayBuffer)\n        }\n\n        // 清理发言人名称中的非法字符\n        const safeSpeaker = line.speaker.replace(/[\\\\/:*?\"<>|]/g, '_')\n\n        // 截取台词内容前15字作为文件名的一部分\n        const safeContent = line.content.slice(0, 15).replace(/[\\\\/:*?\"<>|]/g, '_').replace(/\\s+/g, '_')\n\n        // 确定文件扩展名\n        const extSource = storageKey || line.audioUrl\n        const ext = extSource.endsWith('.wav') ? 'wav' : 'mp3'\n\n        // 文件名格式: 序号_名字_语音内容.mp3（按绝对顺序排列，不按发言人分文件夹）\n        const fileName = `${String(line.lineIndex).padStart(3, '0')}_${safeSpeaker}_${safeContent}.${ext}`\n\n        archive.append(audioData, { name: fileName })\n        _ulogInfo(`Added ${fileName} to archive`)\n      } catch (error) {\n        _ulogError(`Failed to download voice line ${line.lineIndex}:`, error)\n      }\n    }\n\n    await archive.finalize()\n    _ulogInfo('Archive finalized')\n  }\n\n  return new Response(stream, {\n    headers: {\n      'Content-Type': 'application/zip',\n      'Content-Disposition': `attachment; filename=\"${encodeURIComponent(project.name)}_voices.zip\"`\n    }\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/editor/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * GET /api/novel-promotion/[projectId]/editor\n * 获取剧集的编辑器项目数据\n */\nexport const GET = apiHandler(async (\n    request: NextRequest,\n    { params }: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    const episodeId = request.nextUrl.searchParams.get('episodeId')\n\n    if (!episodeId) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 查找编辑器项目\n    const editorProject = await prisma.videoEditorProject.findUnique({\n        where: { episodeId }\n    })\n\n    if (!editorProject) {\n        return NextResponse.json({ projectData: null }, { status: 200 })\n    }\n\n    return NextResponse.json({\n        id: editorProject.id,\n        episodeId: editorProject.episodeId,\n        projectData: JSON.parse(editorProject.projectData),\n        renderStatus: editorProject.renderStatus,\n        outputUrl: editorProject.outputUrl,\n        updatedAt: editorProject.updatedAt\n    })\n})\n\n/**\n * PUT /api/novel-promotion/[projectId]/editor\n * 保存编辑器项目数据\n */\nexport const PUT = apiHandler(async (\n    request: NextRequest,\n    { params }: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    const body = await request.json()\n    const { episodeId, projectData } = body\n\n    if (!episodeId || !projectData) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 验证剧集存在\n    const episode = await prisma.novelPromotionEpisode.findFirst({\n        where: {\n            id: episodeId,\n            novelPromotionProject: { projectId }\n        }\n    })\n\n    if (!episode) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 保存或更新编辑器项目\n    const editorProject = await prisma.videoEditorProject.upsert({\n        where: { episodeId },\n        create: {\n            episodeId,\n            projectData: JSON.stringify(projectData)\n        },\n        update: {\n            projectData: JSON.stringify(projectData),\n            updatedAt: new Date()\n        }\n    })\n\n    return NextResponse.json({\n        success: true,\n        id: editorProject.id,\n        updatedAt: editorProject.updatedAt\n    })\n})\n\n/**\n * DELETE /api/novel-promotion/[projectId]/editor\n * 删除编辑器项目\n */\nexport const DELETE = apiHandler(async (\n    request: NextRequest,\n    { params }: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    const episodeId = request.nextUrl.searchParams.get('episodeId')\n\n    if (!episodeId) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    await prisma.videoEditorProject.delete({\n        where: { episodeId }\n    })\n\n    return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/episodes/[episodeId]/route.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { Prisma } from '@prisma/client'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { attachMediaFieldsToProject } from '@/lib/media/attach'\nimport { resolveMediaRefFromLegacyValue } from '@/lib/media/service'\n\n/**\n * GET - 获取单个剧集的完整数据\n */\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string; episodeId: string }> }\n) => {\n  const { projectId, episodeId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  // 获取剧集及其关联数据\n  const episode = await prisma.novelPromotionEpisode.findUnique({\n    where: { id: episodeId },\n    include: {\n      clips: {\n        orderBy: { createdAt: 'asc' }\n      },\n      storyboards: {\n        include: {\n          clip: true,\n          panels: { orderBy: { panelIndex: 'asc' } }\n        },\n        orderBy: { createdAt: 'asc' }\n      },\n      shots: {\n        orderBy: { shotId: 'asc' }\n      },\n      voiceLines: {\n        orderBy: { lineIndex: 'asc' }\n      }\n    }\n  })\n\n  if (!episode) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 更新最后编辑的剧集ID（异步，不阻塞响应）\n  prisma.novelPromotionProject.update({\n    where: { projectId },\n    data: { lastEpisodeId: episodeId }\n  }).catch(err => _ulogError('更新 lastEpisodeId 失败:', err))\n\n  // 转换为稳定媒体 URL（并保留兼容字段）\n  const episodeWithSignedUrls = await attachMediaFieldsToProject(episode)\n\n  return NextResponse.json({ episode: episodeWithSignedUrls })\n})\n\n/**\n * PATCH - 更新剧集信息\n */\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string; episodeId: string }> }\n) => {\n  const { projectId, episodeId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { name, description, novelText, audioUrl, srtContent } = body\n\n  const updateData: Prisma.NovelPromotionEpisodeUncheckedUpdateInput = {}\n  if (name !== undefined) updateData.name = name.trim()\n  if (description !== undefined) updateData.description = description?.trim() || null\n  if (novelText !== undefined) updateData.novelText = novelText\n  if (audioUrl !== undefined) {\n    updateData.audioUrl = audioUrl\n    const media = await resolveMediaRefFromLegacyValue(audioUrl)\n    updateData.audioMediaId = media?.id || null\n  }\n  if (srtContent !== undefined) updateData.srtContent = srtContent\n\n  const episode = await prisma.novelPromotionEpisode.update({\n    where: { id: episodeId },\n    data: updateData\n  })\n\n  return NextResponse.json({ episode })\n})\n\n/**\n * DELETE - 删除剧集\n */\nexport const DELETE = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string; episodeId: string }> }\n) => {\n  const { projectId, episodeId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  // 删除剧集（关联数据会级联删除）\n  await prisma.novelPromotionEpisode.delete({\n    where: { id: episodeId }\n  })\n\n  // 如果删除的是最后编辑的剧集，更新 lastEpisodeId\n  const novelPromotionProject = await prisma.novelPromotionProject.findUnique({\n    where: { projectId }\n  })\n\n  if (novelPromotionProject?.lastEpisodeId === episodeId) {\n    // 找到另一个剧集作为默认\n    const anotherEpisode = await prisma.novelPromotionEpisode.findFirst({\n      where: { novelPromotionProjectId: novelPromotionProject.id },\n      orderBy: { episodeNumber: 'asc' }\n    })\n\n    await prisma.novelPromotionProject.update({\n      where: { id: novelPromotionProject.id },\n      data: { lastEpisodeId: anotherEpisode?.id || null }\n    })\n  }\n\n  return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/episodes/batch/route.ts",
    "content": "/**\n * 批量创建剧集 API\n */\n\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface BatchEpisode {\n    name: string\n    description?: string\n    novelText: string\n}\n\nexport const POST = apiHandler(async (\n    request: NextRequest,\n    { params }: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n    const { episodes, clearExisting = false, importStatus } = await request.json()\n\n    if (!episodes || !Array.isArray(episodes)) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 验证项目存在\n    const project = await prisma.novelPromotionProject.findFirst({\n        where: { projectId }\n    })\n\n    if (!project) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 如果需要清空现有剧集\n    if (clearExisting) {\n        await prisma.novelPromotionEpisode.deleteMany({\n            where: { novelPromotionProjectId: project.id }\n        })\n    }\n\n    // 如果剧集数组为空，只更新 importStatus\n    if (episodes.length === 0) {\n        if (importStatus) {\n            await prisma.novelPromotionProject.update({\n                where: { id: project.id },\n                data: { importStatus }\n            })\n        }\n        return NextResponse.json({\n            success: true,\n            episodes: [],\n            message: '已清空剧集'\n        })\n    }\n\n    // 获取当前最大剧集编号\n    const lastEpisode = await prisma.novelPromotionEpisode.findFirst({\n        where: { novelPromotionProjectId: project.id },\n        orderBy: { episodeNumber: 'desc' }\n    })\n\n    const startNumber = clearExisting ? 1 : (lastEpisode?.episodeNumber || 0) + 1\n\n    // 批量创建剧集\n    const createdEpisodes = await prisma.$transaction(\n        (episodes as BatchEpisode[]).map((ep, idx) =>\n            prisma.novelPromotionEpisode.create({\n                data: {\n                    novelPromotionProjectId: project.id,\n                    episodeNumber: startNumber + idx,\n                    name: ep.name,\n                    description: ep.description || null,\n                    novelText: ep.novelText\n                }\n            })\n        )\n    )\n\n    // 更新项目的 lastEpisodeId 和 importStatus\n    const updateData: { lastEpisodeId: string; importStatus?: string } = { lastEpisodeId: createdEpisodes[0].id }\n    if (importStatus) {\n        updateData.importStatus = importStatus\n    }\n\n    await prisma.novelPromotionProject.update({\n        where: { id: project.id },\n        data: updateData\n    })\n\n    return NextResponse.json({\n        success: true,\n        episodes: createdEpisodes.map(ep => ({\n            id: ep.id,\n            episodeNumber: ep.episodeNumber,\n            name: ep.name\n        }))\n    })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/episodes/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * GET - 获取项目的所有剧集\n */\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { novelData } = authResult\n\n  const episodes = await prisma.novelPromotionEpisode.findMany({\n    where: { novelPromotionProjectId: novelData.id },\n    orderBy: { episodeNumber: 'asc' }\n  })\n\n  return NextResponse.json({ episodes })\n})\n\n/**\n * POST - 创建新剧集\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { novelData } = authResult\n\n  const body = await request.json()\n  const { name, description } = body\n\n  if (!name || name.trim().length === 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取下一个剧集编号\n  const lastEpisode = await prisma.novelPromotionEpisode.findFirst({\n    where: { novelPromotionProjectId: novelData.id },\n    orderBy: { episodeNumber: 'desc' }\n  })\n  const nextEpisodeNumber = (lastEpisode?.episodeNumber || 0) + 1\n\n  // 创建剧集\n  const episode = await prisma.novelPromotionEpisode.create({\n    data: {\n      novelPromotionProjectId: novelData.id,\n      episodeNumber: nextEpisodeNumber,\n      name: name.trim(),\n      description: description?.trim() || null\n    }\n  })\n\n  // 更新最后编辑的剧集ID\n  await prisma.novelPromotionProject.update({\n    where: { id: novelData.id },\n    data: { lastEpisodeId: episode.id }\n  })\n\n  return NextResponse.json({ episode }, { status: 201 })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/episodes/split/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\n/**\n * AI 分集 API（任务化）\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  { params }: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await params\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session, project } = authResult\n\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const body = await request.json().catch(() => ({}))\n  const content = typeof body?.content === 'string' ? body.content : ''\n\n  if (!content) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (content.length < 100) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    type: TASK_TYPE.EPISODE_SPLIT_LLM,\n    targetType: 'NovelPromotionProject',\n    targetId: projectId,\n    routePath: `/api/novel-promotion/${projectId}/episodes/split`,\n    body: { content },\n    dedupeKey: `episode_split_llm:${projectId}:${content.length}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/episodes/split-by-markers/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\n/**\n * 标识符分集 API\n * 根据检测到的分集标记直接切割文本，不调用 AI\n */\n\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { logUserAction } from '@/lib/logging/semantic'\nimport { detectEpisodeMarkers, splitByMarkers } from '@/lib/episode-marker-detector'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\nexport const POST = apiHandler(async (\n    request: NextRequest,\n    { params }: { params: Promise<{ projectId: string }> }\n) => {\n    _ulogInfo('[Split-By-Markers API] ========== 开始处理请求 ==========')\n\n    const { projectId } = await params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n    const session = authResult.session\n\n    const userId = session.user.id\n    const username = session.user.name || session.user.email || 'unknown'\n    const { content } = await request.json()\n\n    if (!content || typeof content !== 'string') {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    if (content.length < 100) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 验证项目存在\n    const project = await prisma.novelPromotionProject.findFirst({\n        where: { projectId },\n        include: { project: true }\n    })\n\n    if (!project) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    const projectName = project.project?.name || projectId\n\n    // 执行分集标记检测\n    const markerResult = detectEpisodeMarkers(content)\n\n    if (!markerResult.hasMarkers || markerResult.matches.length < 2) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 根据标记分割内容\n    const episodes = splitByMarkers(content, markerResult)\n\n    // 记录日志\n    logUserAction(\n        'EPISODE_SPLIT_BY_MARKERS',\n        userId,\n        username,\n        `标识符分集完成 - ${episodes.length} 集，标记类型: ${markerResult.markerType}`,\n        {\n            markerType: markerResult.markerType,\n            confidence: markerResult.confidence,\n            episodeCount: episodes.length,\n            totalWords: episodes.reduce((sum, ep) => sum + ep.wordCount, 0)\n        },\n        projectId,\n        projectName\n    )\n\n    return NextResponse.json({\n        success: true,\n        method: 'markers',\n        markerType: markerResult.markerType,\n        episodes\n    })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/generate-character-image/route.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { resolveTaskLocale } from '@/lib/task/resolve-locale'\nimport { isArtStyleValue, type ArtStyleValue } from '@/lib/constants'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\n\nfunction toObject(value: unknown): Record<string, unknown> {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n    return value as Record<string, unknown>\n}\n\nfunction normalizeString(value: unknown): string {\n    return typeof value === 'string' ? value.trim() : ''\n}\n\n/**\n * POST /api/novel-promotion/[projectId]/generate-character-image\n * 专门用于后台触发角色图片生成的简化 API\n * 内部调用 generate-image API\n */\nexport const POST = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    const rawBody = await request.json().catch(() => ({}))\n    const body = toObject(rawBody)\n    const taskLocale = resolveTaskLocale(request, body)\n    const bodyMeta = toObject(body.meta)\n    const acceptLanguage = request.headers.get('accept-language') || ''\n    const characterId = normalizeString(body.characterId)\n    const appearanceId = normalizeString(body.appearanceId)\n    const count = normalizeImageGenerationCount('character', body.count)\n    let artStyle: ArtStyleValue | undefined\n    if (Object.prototype.hasOwnProperty.call(body, 'artStyle')) {\n        const parsedArtStyle = normalizeString(body.artStyle)\n        if (!isArtStyleValue(parsedArtStyle)) {\n            throw new ApiError('INVALID_PARAMS', {\n                code: 'INVALID_ART_STYLE',\n                message: 'artStyle must be a supported value',\n            })\n        }\n        artStyle = parsedArtStyle\n    }\n\n    if (!characterId) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 如果没有传 appearanceId，获取第一个 appearance 的 id\n    let targetAppearanceId = appearanceId\n    if (!targetAppearanceId) {\n        const character = await prisma.novelPromotionCharacter.findUnique({\n            where: { id: characterId },\n            include: { appearances: { orderBy: { appearanceIndex: 'asc' } } }\n        })\n        if (!character) {\n            throw new ApiError('NOT_FOUND')\n        }\n        const firstAppearance = character.appearances?.[0]\n        if (!firstAppearance) {\n            throw new ApiError('NOT_FOUND')\n        }\n        targetAppearanceId = firstAppearance.id\n    }\n\n    // 调用 generate-image API\n    const { getBaseUrl } = await import('@/lib/env')\n    const baseUrl = getBaseUrl()\n    const generateRes = await fetch(`${baseUrl}/api/novel-promotion/${projectId}/generate-image`, {\n        method: 'POST',\n        headers: {\n            'Content-Type': 'application/json',\n            'Cookie': request.headers.get('cookie') || '',\n            ...(acceptLanguage ? { 'Accept-Language': acceptLanguage } : {})\n        },\n        body: JSON.stringify({\n            type: 'character',\n            id: characterId,\n            appearanceId: targetAppearanceId,  // 使用真正的 UUID\n            count,\n            artStyle,\n            locale: taskLocale || undefined,\n            meta: {\n                ...bodyMeta,\n                locale: taskLocale || bodyMeta.locale || undefined,\n            },\n        })\n    })\n\n    const result = await generateRes.json()\n\n    if (!generateRes.ok) {\n        _ulogError('[Generate Character Image] 失败:', result.error)\n        throw new ApiError('GENERATION_FAILED')\n    }\n\n    return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/generate-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service'\nimport { isArtStyleValue, type ArtStyleValue } from '@/lib/constants'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\nimport { ensureProjectLocationImageSlots } from '@/lib/image-generation/location-slots'\nimport { prisma } from '@/lib/prisma'\nimport {\n  hasCharacterAppearanceOutput,\n  hasLocationImageOutput\n} from '@/lib/task/has-output'\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction toNumber(value: unknown) {\n  const parsed = Number(value)\n  return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction normalizeString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction resolveArtStyle(body: Record<string, unknown>): ArtStyleValue | undefined {\n  if (!Object.prototype.hasOwnProperty.call(body, 'artStyle')) return undefined\n  const artStyle = normalizeString(body.artStyle)\n  if (!isArtStyleValue(artStyle)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'INVALID_ART_STYLE',\n      message: 'artStyle must be a supported value',\n    })\n  }\n  return artStyle\n}\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const rawBody = await request.json().catch(() => ({}))\n  const body = toObject(rawBody)\n  const locale = resolveRequiredTaskLocale(request, body)\n  const type = normalizeString(body.type)\n  const id = normalizeString(body.id)\n  const appearanceId = normalizeString(body.appearanceId)\n  const artStyle = resolveArtStyle(body)\n  const count = type === 'character'\n    ? normalizeImageGenerationCount('character', body.count)\n    : normalizeImageGenerationCount('location', body.count)\n\n  if (!type || !id) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (type !== 'character' && type !== 'location') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const taskType = type === 'character' ? TASK_TYPE.IMAGE_CHARACTER : TASK_TYPE.IMAGE_LOCATION\n  const targetType = type === 'character' ? 'CharacterAppearance' : 'LocationImage'\n  const targetId = type === 'character' ? (appearanceId || id) : id\n\n  if (!targetId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  const imageIndex = toNumber(body?.imageIndex)\n  if (type === 'location' && imageIndex === null) {\n    const location = await prisma.novelPromotionLocation.findUnique({\n      where: { id },\n      select: { name: true, summary: true },\n    })\n    if (!location) {\n      throw new ApiError('NOT_FOUND')\n    }\n    await ensureProjectLocationImageSlots({\n      locationId: id,\n      count,\n      fallbackDescription: location.summary || location.name,\n    })\n  }\n  const hasOutputAtStart = type === 'character'\n    ? await hasCharacterAppearanceOutput({\n      appearanceId: targetId,\n      characterId: id,\n      appearanceIndex: toNumber(body?.appearanceIndex)\n    })\n    : await hasLocationImageOutput({\n      locationId: id,\n      imageIndex\n    })\n\n  const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)\n  const imageModel = type === 'character'\n    ? projectModelConfig.characterModel\n    : projectModelConfig.locationModel\n  const payloadBase = artStyle ? { ...body, artStyle, count } : { ...body, count }\n\n  let billingPayload: Record<string, unknown>\n  try {\n    billingPayload = await buildImageBillingPayload({\n      projectId,\n      userId: session.user.id,\n      imageModel,\n      basePayload: payloadBase,\n    })\n  } catch (err) {\n    const message = err instanceof Error ? err.message : 'Image model capability not configured'\n    throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })\n  }\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: taskType,\n    targetType,\n    targetId,\n    payload: withTaskUiPayload(billingPayload, { hasOutputAtStart }),\n    dedupeKey: `${taskType}:${targetId}:${imageIndex === null ? count : `single:${imageIndex}`}`,\n    billingInfo: buildDefaultTaskBillingInfo(taskType, billingPayload)\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/generate-video/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { BillingOperationError } from '@/lib/billing/errors'\nimport { hasPanelVideoOutput } from '@/lib/task/has-output'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { parseModelKeyStrict, type CapabilityValue } from '@/lib/model-config-contract'\nimport {\n  resolveBuiltinCapabilitiesByModelKey,\n} from '@/lib/model-capabilities/lookup'\nimport { resolveBuiltinPricing } from '@/lib/model-pricing/lookup'\nimport { resolveProjectModelCapabilityGenerationOptions } from '@/lib/config-service'\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction toVideoRuntimeSelections(value: unknown): Record<string, CapabilityValue> {\n  if (!isRecord(value)) return {}\n  const selections: Record<string, CapabilityValue> = {}\n  for (const [field, raw] of Object.entries(value)) {\n    if (field === 'aspectRatio') continue\n    if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {\n      selections[field] = raw\n    }\n  }\n  return selections\n}\n\nfunction resolveVideoGenerationMode(payload: unknown): 'normal' | 'firstlastframe' {\n  if (!isRecord(payload)) return 'normal'\n  return isRecord(payload.firstLastFrame) ? 'firstlastframe' : 'normal'\n}\n\nfunction resolveVideoModelKeyFromPayload(payload: Record<string, unknown>): string | null {\n  const firstLast = isRecord(payload.firstLastFrame) ? payload.firstLastFrame : null\n  if (firstLast && typeof firstLast.flModel === 'string' && parseModelKeyStrict(firstLast.flModel)) {\n    return firstLast.flModel\n  }\n  if (typeof payload.videoModel === 'string' && parseModelKeyStrict(payload.videoModel)) {\n    return payload.videoModel\n  }\n  return null\n}\n\nfunction requireVideoModelKeyFromPayload(payload: unknown): string {\n  if (!isRecord(payload) || typeof payload.videoModel !== 'string' || !parseModelKeyStrict(payload.videoModel)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'VIDEO_MODEL_REQUIRED',\n      field: 'videoModel',\n    })\n  }\n  return payload.videoModel\n}\n\nfunction validateFirstLastFrameModel(input: unknown) {\n  if (input === undefined || input === null) return\n  if (!isRecord(input)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'FIRSTLASTFRAME_PAYLOAD_INVALID',\n      field: 'firstLastFrame',\n    })\n  }\n\n  const flModel = input.flModel\n  if (typeof flModel !== 'string' || !parseModelKeyStrict(flModel)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'FIRSTLASTFRAME_MODEL_INVALID',\n      field: 'firstLastFrame.flModel',\n    })\n  }\n\n  const capabilities = resolveBuiltinCapabilitiesByModelKey('video', flModel)\n  if (capabilities?.video?.firstlastframe !== true) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'FIRSTLASTFRAME_MODEL_UNSUPPORTED',\n      field: 'firstLastFrame.flModel',\n    })\n  }\n}\n\nasync function validateVideoCapabilityCombination(input: {\n  payload: unknown\n  projectId: string\n  userId: string\n}) {\n  const payload = input.payload\n  if (!isRecord(payload)) return\n  const modelKey = resolveVideoModelKeyFromPayload(payload)\n  if (!modelKey) return\n\n  // Skip validation for models not in the built-in capability catalog\n  const builtinCaps = resolveBuiltinCapabilitiesByModelKey('video', modelKey)\n  if (!builtinCaps) return\n\n  const runtimeSelections = toVideoRuntimeSelections(payload.generationOptions)\n  runtimeSelections.generationMode = resolveVideoGenerationMode(payload)\n\n  let resolvedOptions: Record<string, CapabilityValue>\n  try {\n    resolvedOptions = await resolveProjectModelCapabilityGenerationOptions({\n      projectId: input.projectId,\n      userId: input.userId,\n      modelType: 'video',\n      modelKey,\n      runtimeSelections,\n    })\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'VIDEO_CAPABILITY_COMBINATION_UNSUPPORTED',\n      field: 'generationOptions',\n      details: {\n        model: modelKey,\n        selections: runtimeSelections,\n        message,\n      },\n    })\n  }\n\n  const resolution = resolveBuiltinPricing({\n    apiType: 'video',\n    model: modelKey,\n    selections: resolvedOptions,\n  })\n  if (resolution.status === 'missing_capability_match') {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'VIDEO_CAPABILITY_COMBINATION_UNSUPPORTED',\n      field: 'generationOptions',\n      details: {\n        model: modelKey,\n        selections: resolvedOptions,\n      },\n    })\n  }\n}\n\nfunction buildVideoPanelBillingInfoOrThrow(payload: unknown) {\n  try {\n    return buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, isRecord(payload) ? payload : null)\n  } catch (error) {\n    if (\n      error instanceof BillingOperationError\n      && (\n        error.code === 'BILLING_UNKNOWN_VIDEO_CAPABILITY_COMBINATION'\n        || error.code === 'BILLING_UNKNOWN_VIDEO_RESOLUTION'\n      )\n    ) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'VIDEO_CAPABILITY_COMBINATION_UNSUPPORTED',\n        field: 'generationOptions',\n      })\n    }\n    // Model not in built-in pricing catalog — allow task to proceed;\n    // actual billing will be resolved downstream where billing mode is checked.\n    if (\n      error instanceof BillingOperationError\n      && error.code === 'BILLING_UNKNOWN_MODEL'\n    ) {\n      return null\n    }\n    throw error\n  }\n}\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  requireVideoModelKeyFromPayload(body)\n  const locale = resolveRequiredTaskLocale(request, body)\n  const isBatch = body?.all === true\n\n  validateFirstLastFrameModel(body?.firstLastFrame)\n  await validateVideoCapabilityCombination({\n    payload: body,\n    projectId,\n    userId: session.user.id,\n  })\n\n  if (isBatch) {\n    const episodeId = body?.episodeId\n    if (!episodeId) {\n      throw new ApiError('INVALID_PARAMS')\n    }\n\n    const panels = await prisma.novelPromotionPanel.findMany({\n      where: {\n        storyboard: { episodeId },\n        imageUrl: { not: null },\n        OR: [\n          { videoUrl: null },\n          { videoUrl: '' },\n        ],\n      },\n      select: { id: true },\n    })\n\n    if (panels.length === 0) {\n      return NextResponse.json({ tasks: [], total: 0 })\n    }\n\n    const results = await Promise.all(\n      panels.map(async (panel) =>\n        submitTask({\n          userId: session.user.id,\n          locale,\n          requestId: getRequestId(request),\n          projectId,\n          episodeId,\n          type: TASK_TYPE.VIDEO_PANEL,\n          targetType: 'NovelPromotionPanel',\n          targetId: panel.id,\n          payload: withTaskUiPayload(body, {\n            hasOutputAtStart: await hasPanelVideoOutput(panel.id),\n          }),\n          dedupeKey: `video_panel:${panel.id}`,\n          billingInfo: buildVideoPanelBillingInfoOrThrow(body),\n        }),\n      ),\n    )\n\n    return NextResponse.json({ tasks: results, total: panels.length })\n  }\n\n  const storyboardId = body?.storyboardId\n  const panelIndex = body?.panelIndex\n  if (!storyboardId || panelIndex === undefined) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const panel = await prisma.novelPromotionPanel.findFirst({\n    where: { storyboardId, panelIndex: Number(panelIndex) },\n    select: { id: true },\n  })\n\n  if (!panel) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: TASK_TYPE.VIDEO_PANEL,\n    targetType: 'NovelPromotionPanel',\n    targetId: panel.id,\n    payload: withTaskUiPayload(body, {\n      hasOutputAtStart: await hasPanelVideoOutput(panel.id),\n    }),\n    dedupeKey: `video_panel:${panel.id}`,\n    billingInfo: buildVideoPanelBillingInfoOrThrow(body),\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/insert-panel/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { getProjectModelConfig } from '@/lib/config-service'\nimport { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const storyboardId = body?.storyboardId\n  const insertAfterPanelId = body?.insertAfterPanelId\n  const userInput = resolveInsertPanelUserInput((body || {}) as Record<string, unknown>, locale)\n\n  if (!storyboardId || !insertAfterPanelId) {\n    throw new ApiError('INVALID_PARAMS', {\n    })\n  }\n\n  const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)\n  const billingPayload = {\n    ...body,\n    userInput,\n    ...(projectModelConfig.analysisModel ? { analysisModel: projectModelConfig.analysisModel } : {}),\n  }\n\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: TASK_TYPE.INSERT_PANEL,\n    targetType: 'NovelPromotionStoryboard',\n    targetId: storyboardId,\n    payload: billingPayload,\n    dedupeKey: `insert_panel:${storyboardId}:${insertAfterPanelId}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.INSERT_PANEL, billingPayload),\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/lip-sync/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { hasPanelLipSyncOutput } from '@/lib/task/has-output'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { composeModelKey, parseModelKeyStrict } from '@/lib/model-config-contract'\n\nconst DEFAULT_LIPSYNC_MODEL_KEY = composeModelKey('fal', 'fal-ai/kling-video/lipsync/audio-to-video')\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const storyboardId = body?.storyboardId\n  const panelIndex = body?.panelIndex\n  const voiceLineId = body?.voiceLineId\n  const requestedLipSyncModel = typeof body?.lipSyncModel === 'string' ? body.lipSyncModel.trim() : ''\n\n  if (!storyboardId || panelIndex === undefined || !voiceLineId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (requestedLipSyncModel && !parseModelKeyStrict(requestedLipSyncModel)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_INVALID',\n      field: 'lipSyncModel',\n    })\n  }\n\n  const pref = await prisma.userPreference.findUnique({\n    where: { userId: session.user.id },\n    select: { lipSyncModel: true },\n  })\n  const preferredLipSyncModel = typeof pref?.lipSyncModel === 'string' ? pref.lipSyncModel.trim() : ''\n  const resolvedLipSyncModel = requestedLipSyncModel || preferredLipSyncModel || DEFAULT_LIPSYNC_MODEL_KEY\n  if (!parseModelKeyStrict(resolvedLipSyncModel)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_INVALID',\n      field: 'lipSyncModel',\n    })\n  }\n\n  const panel = await prisma.novelPromotionPanel.findFirst({\n    where: { storyboardId, panelIndex: Number(panelIndex) },\n    select: { id: true },\n  })\n\n  if (!panel) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const payload = {\n    ...body,\n    lipSyncModel: resolvedLipSyncModel,\n  }\n\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: TASK_TYPE.LIP_SYNC,\n    targetType: 'NovelPromotionPanel',\n    targetId: panel.id,\n    payload: withTaskUiPayload(payload, {\n      hasOutputAtStart: await hasPanelLipSyncOutput(panel.id),\n    }),\n    dedupeKey: `lip_sync:${panel.id}:${voiceLineId}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.LIP_SYNC, payload),\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/location/confirm-selection/route.ts",
    "content": "import { logInfo as _ulogInfo, logWarn as _ulogWarn } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { deleteObject } from '@/lib/storage'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST - 确认场景选择并删除未选中的候选图片\n * Body: { locationId }\n * \n * 工作流程：\n * 1. 验证已经选择了一张图片（有 isSelected 的图片）\n * 2. 删除其他未选中的图片（从 COS 和数据库）\n * 3. 将选中的图片设为唯一图片\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { locationId } = body\n\n  if (!locationId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取场景及其图片\n  const location = await prisma.novelPromotionLocation.findUnique({\n    where: { id: locationId },\n    include: { images: { orderBy: { imageIndex: 'asc' } } }\n  })\n\n  if (!location) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const images = location.images || []\n\n  if (images.length <= 1) {\n    // 已经只有一张图片，无需操作\n    return NextResponse.json({\n      success: true,\n      message: '已确认选择',\n      deletedCount: 0\n    })\n  }\n\n  // 找到选中的图片\n  const selectedImage = location.selectedImageId\n    ? images.find((img) => img.id === location.selectedImageId)\n    : images.find((img) => img.isSelected)\n  if (!selectedImage) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 删除未选中的图片\n  const deletedImages: string[] = []\n  const imagesToDelete = images.filter((img) => img.id !== selectedImage.id)\n\n  for (const img of imagesToDelete) {\n    if (img.imageUrl) {\n      const key = await resolveStorageKeyFromMediaValue(img.imageUrl)\n      if (key) {\n        try {\n          await deleteObject(key)\n          deletedImages.push(key)\n        } catch {\n          _ulogWarn('Failed to delete COS image:', key)\n        }\n      }\n    }\n  }\n\n  // 在事务中更新数据库\n  await prisma.$transaction(async (tx) => {\n    // 删除未选中的图片记录（排除选中的图片 ID）\n    await tx.locationImage.deleteMany({\n      where: {\n        locationId,\n        id: { not: selectedImage.id }\n      }\n    })\n\n    // 更新选中图片的索引为 0\n    await tx.locationImage.update({\n      where: { id: selectedImage.id },\n      data: { imageIndex: 0 }\n    })\n\n    await tx.novelPromotionLocation.update({\n      where: { id: locationId },\n      data: { selectedImageId: selectedImage.id }\n    })\n  })\n\n  _ulogInfo(`✓ 场景确认选择: ${location.name}`)\n  _ulogInfo(`✓ 删除了 ${deletedImages.length} 张未选中的图片`)\n\n  return NextResponse.json({\n    success: true,\n    message: '已确认选择，其他候选图片已删除',\n    deletedCount: deletedImages.length\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/location/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { removeLocationPromptSuffix, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'\nimport { requireProjectAuth, requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction normalizeString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\n// 删除场景（级联删除关联的图片记录）\nexport const DELETE = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const { searchParams } = new URL(request.url)\n  const locationId = searchParams.get('id')\n\n  if (!locationId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 删除场景（LocationImage 会级联删除）\n  await prisma.novelPromotionLocation.delete({\n    where: { id: locationId }\n  })\n\n  return NextResponse.json({ success: true })\n})\n\n// 新增场景\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { novelData } = authResult\n\n  const rawBody = await request.json().catch(() => ({}))\n  const body = toObject(rawBody)\n  const name = normalizeString(body.name)\n  const description = normalizeString(body.description)\n  const summary = normalizeString(body.summary)\n  const count = Object.prototype.hasOwnProperty.call(body, 'count')\n    ? normalizeImageGenerationCount('location', body.count)\n    : 1\n  let artStyle: ArtStyleValue | undefined\n  if (Object.prototype.hasOwnProperty.call(body, 'artStyle')) {\n    const parsedArtStyle = normalizeString(body.artStyle)\n    if (!isArtStyleValue(parsedArtStyle)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'INVALID_ART_STYLE',\n        message: 'artStyle must be a supported value',\n      })\n    }\n    artStyle = parsedArtStyle\n  }\n\n  if (!name || !description) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 创建场景\n  const cleanDescription = removeLocationPromptSuffix(description.trim())\n  const location = await prisma.novelPromotionLocation.create({\n    data: {\n      novelPromotionProjectId: novelData.id,\n      name: name.trim(),\n      summary: summary || null\n    }\n  })\n\n  // 创建初始图片记录\n  await prisma.locationImage.createMany({\n    data: Array.from({ length: count }, (_value, imageIndex) => ({\n      locationId: location.id,\n      imageIndex,\n      description: cleanDescription,\n    })),\n  })\n\n  // 返回包含图片的场景数据\n  const locationWithImages = await prisma.novelPromotionLocation.findUnique({\n    where: { id: location.id },\n    include: { images: true }\n  })\n\n  return NextResponse.json({ success: true, location: locationWithImages })\n})\n\n// 更新场景（名字或图片描述）\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { locationId, imageIndex, description, name } = body\n\n  if (!locationId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 如果提供了 name 或 summary，更新场景信息\n  if (name !== undefined || body.summary !== undefined) {\n    const updateData: { name?: string; summary?: string | null } = {}\n    if (name !== undefined) updateData.name = name.trim()\n    if (body.summary !== undefined) updateData.summary = body.summary?.trim() || null\n\n    const location = await prisma.novelPromotionLocation.update({\n      where: { id: locationId },\n      data: updateData\n    })\n    return NextResponse.json({ success: true, location })\n  }\n\n  // 如果提供了 description 和 imageIndex，更新图片描述\n  if (imageIndex !== undefined && description) {\n    const cleanDescription = removeLocationPromptSuffix(description.trim())\n    const image = await prisma.locationImage.update({\n      where: {\n        locationId_imageIndex: { locationId, imageIndex }\n      },\n      data: { description: cleanDescription }\n    })\n    return NextResponse.json({ success: true, image })\n  }\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service'\nimport { sanitizeImageInputsForTaskPayload } from '@/lib/media/outbound-image'\nimport {\n  hasCharacterAppearanceOutput,\n  hasLocationImageOutput\n} from '@/lib/task/has-output'\n\nfunction toNumber(value: unknown) {\n  const parsed = Number(value)\n  return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const type = body?.type\n  const modifyPrompt = body?.modifyPrompt\n\n  if (!type || !modifyPrompt) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (type !== 'character' && type !== 'location') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const targetType = type === 'character' ? 'CharacterAppearance' : 'LocationImage'\n  const targetId = type === 'character'\n    ? (body?.appearanceId || body?.characterId)\n    : (body?.locationImageId || body?.locationId)\n\n  if (!targetId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const hasOutputAtStart = type === 'character'\n    ? await hasCharacterAppearanceOutput({\n      appearanceId: body?.appearanceId || null,\n      characterId: body?.characterId || null,\n      appearanceIndex: toNumber(body?.appearanceIndex)\n    })\n    : await hasLocationImageOutput({\n      imageId: body?.locationImageId || null,\n      locationId: body?.locationId || null,\n      imageIndex: toNumber(body?.imageIndex)\n    })\n\n  const extraImageAudit = sanitizeImageInputsForTaskPayload(\n    Array.isArray(body?.extraImageUrls) ? body.extraImageUrls : [],\n  )\n  const rejectedRelativePathCount = extraImageAudit.issues.filter(\n    (issue) => issue.reason === 'relative_path_rejected',\n  ).length\n  if (rejectedRelativePathCount > 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const baseMeta = toObject(body?.meta)\n  const payload = {\n    ...body,\n    extraImageUrls: extraImageAudit.normalized,\n    meta: {\n      ...baseMeta,\n      outboundImageInputAudit: {\n        extraImageUrls: extraImageAudit.issues\n      }\n    }\n  }\n\n  const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)\n  const imageModel = projectModelConfig.editModel\n\n  let billingPayload: Record<string, unknown>\n  try {\n    billingPayload = await buildImageBillingPayload({\n      projectId,\n      userId: session.user.id,\n      imageModel,\n      basePayload: payload,\n    })\n  } catch (err) {\n    const message = err instanceof Error ? err.message : 'Image model capability not configured'\n    throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })\n  }\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: TASK_TYPE.MODIFY_ASSET_IMAGE,\n    targetType,\n    targetId,\n    payload: withTaskUiPayload(billingPayload, {\n      intent: 'modify',\n      hasOutputAtStart\n    }),\n    dedupeKey: `modify_asset_image:${targetType}:${targetId}:${body?.imageIndex ?? 'na'}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.MODIFY_ASSET_IMAGE, billingPayload)\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/modify-storyboard-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { prisma } from '@/lib/prisma'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { hasPanelImageOutput } from '@/lib/task/has-output'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service'\nimport { sanitizeImageInputsForTaskPayload } from '@/lib/media/outbound-image'\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const storyboardId = typeof body?.storyboardId === 'string' ? body.storyboardId : ''\n  const panelIndex = Number(body?.panelIndex)\n  const modifyPrompt = typeof body?.modifyPrompt === 'string' ? body.modifyPrompt.trim() : ''\n\n  if (!storyboardId || !Number.isFinite(panelIndex) || !modifyPrompt) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const panel = await prisma.novelPromotionPanel.findFirst({\n    where: {\n      storyboardId,\n      panelIndex\n    },\n    select: {\n      id: true\n    }\n  })\n  if (!panel) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const extraImageAudit = sanitizeImageInputsForTaskPayload(\n    Array.isArray(body?.extraImageUrls) ? body.extraImageUrls : [],\n  )\n  const selectedAssetsRaw = Array.isArray(body?.selectedAssets) ? body.selectedAssets : []\n  const selectedAssetIssues: Array<Record<string, unknown>> = []\n  const normalizedSelectedAssets = selectedAssetsRaw.map((asset: unknown, assetIndex: number) => {\n    if (!asset || typeof asset !== 'object') return asset\n    const imageUrl = (asset as Record<string, unknown>).imageUrl\n    const audit = sanitizeImageInputsForTaskPayload([imageUrl])\n    for (const issue of audit.issues) {\n      selectedAssetIssues.push({\n        assetIndex,\n        ...issue\n      })\n    }\n    const normalizedUrl = audit.normalized[0]\n    if (!normalizedUrl) return asset\n    return {\n      ...toObject(asset),\n      imageUrl: normalizedUrl\n    }\n  })\n\n  const rejectedRelativePathCount = [\n    ...extraImageAudit.issues,\n    ...selectedAssetIssues,\n  ].filter((issue) => issue.reason === 'relative_path_rejected').length\n  if (rejectedRelativePathCount > 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const payload = {\n    ...body,\n    type: 'storyboard',\n    panelId: panel.id,\n    panelIndex,\n    extraImageUrls: extraImageAudit.normalized,\n    selectedAssets: normalizedSelectedAssets,\n    meta: {\n      ...toObject(body?.meta),\n      outboundImageInputAudit: {\n        extraImageUrls: extraImageAudit.issues,\n        selectedAssets: selectedAssetIssues\n      }\n    }\n  }\n  const hasOutputAtStart = await hasPanelImageOutput(panel.id)\n\n  const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)\n  const imageModel = projectModelConfig.editModel\n\n  let billingPayload: Record<string, unknown>\n  try {\n    billingPayload = await buildImageBillingPayload({\n      projectId,\n      userId: session.user.id,\n      imageModel,\n      basePayload: payload,\n    })\n  } catch (err) {\n    const message = err instanceof Error ? err.message : 'Image model capability not configured'\n    throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })\n  }\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: TASK_TYPE.MODIFY_ASSET_IMAGE,\n    targetType: 'NovelPromotionPanel',\n    targetId: panel.id,\n    payload: withTaskUiPayload(billingPayload, {\n      intent: 'modify',\n      hasOutputAtStart\n    }),\n    dedupeKey: `modify_storyboard_image:${panel.id}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.MODIFY_ASSET_IMAGE, billingPayload)\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/panel/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { serializeStructuredJsonField } from '@/lib/novel-promotion/panel-ai-data-sync'\n\nfunction parseNullableNumberField(value: unknown): number | null {\n  if (value === null || value === '') return null\n  if (typeof value === 'number' && Number.isFinite(value)) return value\n  if (typeof value === 'string') {\n    const parsed = Number(value)\n    if (Number.isFinite(parsed)) return parsed\n  }\n  throw new ApiError('INVALID_PARAMS')\n}\n\nfunction toStructuredJsonField(value: unknown, fieldName: string): string | null {\n  try {\n    return serializeStructuredJsonField(value, fieldName)\n  } catch (error) {\n    const message = error instanceof Error ? error.message : `${fieldName} must be valid JSON`\n    throw new ApiError('INVALID_PARAMS', { message })\n  }\n}\n\n/**\n * POST /api/novel-promotion/[projectId]/panel\n * 新增一个 Panel\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const {\n    storyboardId,\n    shotType,\n    cameraMove,\n    description,\n    location,\n    characters,\n    srtStart,\n    srtEnd,\n    duration,\n    videoPrompt,\n    firstLastFramePrompt,\n  } = body\n\n  if (!storyboardId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 验证 storyboard 存在，并获取现有 panels 以计算正确的 panelIndex\n  const storyboard = await prisma.novelPromotionStoryboard.findUnique({\n    where: { id: storyboardId },\n    include: {\n      panels: {\n        orderBy: { panelIndex: 'desc' },\n        take: 1\n      }\n    }\n  })\n\n  if (!storyboard) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 自动计算正确的 panelIndex（取最大值 + 1，避免唯一约束冲突）\n  const maxPanelIndex = storyboard.panels.length > 0 ? storyboard.panels[0].panelIndex : -1\n  const newPanelIndex = maxPanelIndex + 1\n  const newPanelNumber = newPanelIndex + 1\n\n  // 创建新的 Panel 记录\n  const newPanel = await prisma.novelPromotionPanel.create({\n    data: {\n      storyboardId,\n      panelIndex: newPanelIndex,\n      panelNumber: newPanelNumber,\n      shotType: shotType ?? null,\n      cameraMove: cameraMove ?? null,\n      description: description ?? null,\n      location: location ?? null,\n      characters: characters ?? null,\n      srtStart: srtStart ?? null,\n      srtEnd: srtEnd ?? null,\n      duration: duration ?? null,\n      videoPrompt: videoPrompt ?? null,\n      firstLastFramePrompt: firstLastFramePrompt ?? null,\n    }\n  })\n\n  // 更新 panelCount\n  const panelCount = await prisma.novelPromotionPanel.count({\n    where: { storyboardId }\n  })\n\n  await prisma.novelPromotionStoryboard.update({\n    where: { id: storyboardId },\n    data: { panelCount }\n  })\n\n  return NextResponse.json({ success: true, panel: newPanel })\n})\n\n/**\n * DELETE /api/novel-promotion/[projectId]/panel\n * 删除一个 Panel\n */\nexport const DELETE = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const { searchParams } = new URL(request.url)\n  const panelId = searchParams.get('panelId')\n\n  if (!panelId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取要删除的 Panel 信息\n  const panel = await prisma.novelPromotionPanel.findUnique({\n    where: { id: panelId }\n  })\n\n  if (!panel) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const storyboardId = panel.storyboardId\n\n  // 使用事务确保删除和重新排序的原子性\n  // 采用原始 SQL 批量更新以避免循环导致的性能问题\n  await prisma.$transaction(async (tx) => {\n    // 1. 删除 Panel\n    await tx.novelPromotionPanel.delete({\n      where: { id: panelId }\n    })\n\n    // 2. 使用原始 SQL 批量重新排序所有 panels\n    // 先获取已删除 panel 的原始索引，用于确定需要更新的范围\n    const deletedPanelIndex = panel.panelIndex\n\n    // 使用 Prisma 批量更新，采用两阶段偏移避免唯一约束冲突\n    const maxPanel = await tx.novelPromotionPanel.findFirst({\n      where: { storyboardId },\n      orderBy: { panelIndex: 'desc' },\n      select: { panelIndex: true }\n    })\n    const maxPanelIndex = maxPanel?.panelIndex ?? -1\n    const offset = maxPanelIndex + 1000\n\n    // 阶段1：整体上移，避免与原索引冲突\n    await tx.novelPromotionPanel.updateMany({\n      where: {\n        storyboardId,\n        panelIndex: { gt: deletedPanelIndex }\n      },\n      data: {\n        panelIndex: { increment: offset },\n        panelNumber: { increment: offset }\n      }\n    })\n\n    // 阶段2：回落到正确位置（整体 -offset -1）\n    await tx.novelPromotionPanel.updateMany({\n      where: {\n        storyboardId,\n        panelIndex: { gt: deletedPanelIndex + offset }\n      },\n      data: {\n        panelIndex: { decrement: offset + 1 },\n        panelNumber: { decrement: offset + 1 }\n      }\n    })\n\n    // 3. 获取更新后的 panel 总数\n    const panelCount = await tx.novelPromotionPanel.count({\n      where: { storyboardId }\n    })\n\n    // 4. 更新 storyboard 的 panelCount\n    await tx.novelPromotionStoryboard.update({\n      where: { id: storyboardId },\n      data: { panelCount }\n    })\n  }, {\n    maxWait: 15000, // 等待事务开始的最长时间：15 秒\n    timeout: 30000  // 事务执行超时：30 秒 (针对大量 panels 的批量更新)\n  })\n\n  return NextResponse.json({ success: true })\n})\n\n/**\n * PATCH /api/novel-promotion/[projectId]/panel\n * 更新单个 Panel 的属性（视频提示词等）\n * 支持两种更新方式：\n * 1. 通过 panelId 直接更新（推荐，用于清除错误等操作）\n * 2. 通过 storyboardId + panelIndex 更新（兼容旧接口）\n */\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { panelId, storyboardId, panelIndex, videoPrompt, firstLastFramePrompt } = body\n\n  // 🔥 方式1：通过 panelId 直接更新（优先）\n  if (panelId) {\n    const panel = await prisma.novelPromotionPanel.findUnique({\n      where: { id: panelId }\n    })\n\n    if (!panel) {\n      throw new ApiError('NOT_FOUND')\n    }\n\n    // 构建更新数据\n    const updateData: {\n      videoPrompt?: string | null\n      firstLastFramePrompt?: string | null\n    } = {}\n    if (videoPrompt !== undefined) updateData.videoPrompt = videoPrompt\n    if (firstLastFramePrompt !== undefined) updateData.firstLastFramePrompt = firstLastFramePrompt\n\n    await prisma.novelPromotionPanel.update({\n      where: { id: panelId },\n      data: updateData\n    })\n\n    return NextResponse.json({ success: true })\n  }\n\n  // 🔥 方式2：通过 storyboardId + panelIndex 更新（兼容旧接口）\n  if (!storyboardId || panelIndex === undefined) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 验证 storyboard 存在\n  const storyboard = await prisma.novelPromotionStoryboard.findUnique({\n    where: { id: storyboardId }\n  })\n\n  if (!storyboard) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 构建更新数据\n  const updateData: {\n    videoPrompt?: string | null\n    firstLastFramePrompt?: string | null\n  } = {}\n  if (videoPrompt !== undefined) {\n    updateData.videoPrompt = videoPrompt\n  }\n  if (firstLastFramePrompt !== undefined) {\n    updateData.firstLastFramePrompt = firstLastFramePrompt\n  }\n\n  // 尝试更新 Panel\n  const updatedPanel = await prisma.novelPromotionPanel.updateMany({\n    where: {\n      storyboardId,\n      panelIndex\n    },\n    data: updateData\n  })\n\n  // 如果 Panel 不存在，创建它（Panel 表是唯一数据源）\n  if (updatedPanel.count === 0) {\n    // 创建新的 Panel 记录\n    await prisma.novelPromotionPanel.create({\n      data: {\n        storyboardId,\n        panelIndex,\n        panelNumber: panelIndex + 1,\n        imageUrl: null,\n        videoPrompt: videoPrompt ?? null,\n        firstLastFramePrompt: firstLastFramePrompt ?? null,\n      }\n    })\n  }\n\n  return NextResponse.json({ success: true })\n})\n\n/**\n * PUT /api/novel-promotion/[projectId]/panel\n * 完整更新单个 Panel 的所有属性（用于文字分镜编辑）\n */\nexport const PUT = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const {\n    storyboardId,\n    panelIndex,\n    panelNumber,\n    shotType,\n    cameraMove,\n    description,\n    location,\n    characters,\n    srtStart,\n    srtEnd,\n    duration,\n    videoPrompt,\n    firstLastFramePrompt,\n    actingNotes,  // 演技指导数据\n    photographyRules,  // 单镜头摄影规则\n  } = body\n\n  if (!storyboardId || panelIndex === undefined) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 验证 storyboard 存在\n  const storyboard = await prisma.novelPromotionStoryboard.findUnique({\n    where: { id: storyboardId }\n  })\n\n  if (!storyboard) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 构建更新数据 - 包含所有可编辑字段\n  const updateData: {\n    panelNumber?: number | null\n    shotType?: string | null\n    cameraMove?: string | null\n    description?: string | null\n    location?: string | null\n    characters?: string | null\n    srtStart?: number | null\n    srtEnd?: number | null\n    duration?: number | null\n    videoPrompt?: string | null\n    firstLastFramePrompt?: string | null\n    actingNotes?: string | null\n    photographyRules?: string | null\n  } = {}\n  if (panelNumber !== undefined) updateData.panelNumber = panelNumber\n  if (shotType !== undefined) updateData.shotType = shotType\n  if (cameraMove !== undefined) updateData.cameraMove = cameraMove\n  if (description !== undefined) updateData.description = description\n  if (location !== undefined) updateData.location = location\n  if (characters !== undefined) updateData.characters = characters\n  if (srtStart !== undefined) updateData.srtStart = parseNullableNumberField(srtStart)\n  if (srtEnd !== undefined) updateData.srtEnd = parseNullableNumberField(srtEnd)\n  if (duration !== undefined) updateData.duration = parseNullableNumberField(duration)\n  if (videoPrompt !== undefined) updateData.videoPrompt = videoPrompt\n  if (firstLastFramePrompt !== undefined) updateData.firstLastFramePrompt = firstLastFramePrompt\n  // JSON 字段存为规范化 JSON 字符串\n  if (actingNotes !== undefined) {\n    updateData.actingNotes = toStructuredJsonField(actingNotes, 'actingNotes')\n  }\n  if (photographyRules !== undefined) {\n    updateData.photographyRules = toStructuredJsonField(photographyRules, 'photographyRules')\n  }\n\n  // 查找现有 Panel\n  const existingPanel = await prisma.novelPromotionPanel.findUnique({\n    where: {\n      storyboardId_panelIndex: {\n        storyboardId,\n        panelIndex\n      }\n    }\n  })\n\n  if (existingPanel) {\n    // 更新现有 Panel\n    await prisma.novelPromotionPanel.update({\n      where: { id: existingPanel.id },\n      data: updateData\n    })\n  } else {\n    // 创建新的 Panel 记录\n    await prisma.novelPromotionPanel.create({\n      data: {\n        storyboardId,\n        panelIndex,\n        panelNumber: panelNumber ?? panelIndex + 1,\n        shotType: shotType ?? null,\n        cameraMove: cameraMove ?? null,\n        description: description ?? null,\n        location: location ?? null,\n        characters: characters ?? null,\n        srtStart: srtStart ?? null,\n        srtEnd: srtEnd ?? null,\n        duration: duration ?? null,\n        videoPrompt: videoPrompt ?? null,\n        firstLastFramePrompt: firstLastFramePrompt ?? null,\n        actingNotes: actingNotes !== undefined ? toStructuredJsonField(actingNotes, 'actingNotes') : null,\n        photographyRules: photographyRules !== undefined ? toStructuredJsonField(photographyRules, 'photographyRules') : null,\n      }\n    })\n  }\n\n  // Panel 表是唯一数据源，不再同步到 storyboardTextJson\n  // 只更新 panelCount 用于快速查询\n  const panelCount = await prisma.novelPromotionPanel.count({\n    where: { storyboardId }\n  })\n\n  await prisma.novelPromotionStoryboard.update({\n    where: { id: storyboardId },\n    data: { panelCount }\n  })\n\n  return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/panel/select-candidate/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { getSignedUrl, generateUniqueKey, downloadAndUploadImage, toFetchableUrl } from '@/lib/storage'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface PanelHistoryEntry {\n  url: string\n  timestamp: string\n}\n\nfunction parseUnknownArray(jsonValue: string | null): unknown[] {\n  if (!jsonValue) return []\n  try {\n    const parsed = JSON.parse(jsonValue)\n    return Array.isArray(parsed) ? parsed : []\n  } catch {\n    return []\n  }\n}\n\nfunction parsePanelHistory(jsonValue: string | null): PanelHistoryEntry[] {\n  return parseUnknownArray(jsonValue).filter((entry): entry is PanelHistoryEntry => {\n    if (!entry || typeof entry !== 'object') return false\n    const candidate = entry as { url?: unknown; timestamp?: unknown }\n    return typeof candidate.url === 'string' && typeof candidate.timestamp === 'string'\n  })\n}\n\n/**\n * POST /api/novel-promotion/[projectId]/panel/select-candidate\n * 统一的候选图片操作 API\n * \n * action: 'select' - 选择候选图片作为最终图片\n * action: 'cancel' - 取消选择，清空候选列表\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { panelId, selectedImageUrl, action = 'select' } = body\n\n  if (!panelId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // === 取消操作 ===\n  if (action === 'cancel') {\n    await prisma.novelPromotionPanel.update({\n      where: { id: panelId },\n      data: { candidateImages: null }\n    })\n\n    return NextResponse.json({\n      success: true,\n      message: '已取消选择'\n    })\n  }\n\n  // === 选择操作 ===\n  if (!selectedImageUrl) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取 Panel\n  const panel = await prisma.novelPromotionPanel.findUnique({\n    where: { id: panelId }\n  })\n\n  if (!panel) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 验证选择的图片是否在候选列表中\n  const candidateImages = parseUnknownArray(panel.candidateImages)\n\n  const selectedCosKey = await resolveStorageKeyFromMediaValue(selectedImageUrl)\n  const candidateKeys = (await Promise.all(candidateImages.map((candidate: unknown) => resolveStorageKeyFromMediaValue(candidate))))\n    .filter((k): k is string => !!k)\n  const isValidCandidate = !!selectedCosKey && candidateKeys.includes(selectedCosKey)\n\n  if (!isValidCandidate) {\n    _ulogInfo(\n      `[select-candidate] 选择失败: selectedCosKey=${selectedCosKey}, candidateKeys=${JSON.stringify(candidateKeys)}, candidateImages=${JSON.stringify(candidateImages)}`,\n    )\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 保存当前图片到历史记录\n  const currentHistory = parsePanelHistory(panel.imageHistory)\n  if (panel.imageUrl) {\n    currentHistory.push({\n      url: panel.imageUrl,\n      timestamp: new Date().toISOString()\n    })\n  }\n\n  // 选择候选图时优先复用已存在的 COS key，避免重复下载上传（也避免 /m/* 相对URL被 Node fetch 解析失败）\n  let finalImageKey = selectedCosKey as string\n  const isReusableKey = !finalImageKey.startsWith('http://') && !finalImageKey.startsWith('https://') && !finalImageKey.startsWith('/')\n\n  if (!isReusableKey) {\n    const sourceUrl = toFetchableUrl(selectedImageUrl)\n    const cosKey = generateUniqueKey(`panel-${panelId}-selected`, 'png')\n    finalImageKey = await downloadAndUploadImage(sourceUrl, cosKey)\n  }\n\n  const signedUrl = getSignedUrl(finalImageKey, 7 * 24 * 3600)\n\n  // 更新 Panel：设置新图片，清空候选列表\n  await prisma.novelPromotionPanel.update({\n    where: { id: panelId },\n    data: {\n      imageUrl: finalImageKey,\n      imageHistory: JSON.stringify(currentHistory),\n      candidateImages: null\n    }\n  })\n\n  return NextResponse.json({\n    success: true,\n    imageUrl: signedUrl,\n    cosKey: finalImageKey,\n    message: '已选择图片'\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/panel-link/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n// POST - 更新 panel 的首尾帧链接状态\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { storyboardId, panelIndex, linked } = body\n\n  if (!storyboardId || panelIndex === undefined || linked === undefined) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 更新 panel 的链接状态\n  await prisma.novelPromotionPanel.update({\n    where: {\n      storyboardId_panelIndex: {\n        storyboardId,\n        panelIndex\n      }\n    },\n    data: {\n      linkedToNextPanel: linked\n    }\n  })\n\n  return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/panel-variant/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service'\nimport { prisma } from '@/lib/prisma'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\n\nfunction createPanelVariantId(): string {\n  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n    return crypto.randomUUID()\n  }\n  return `panel-variant-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`\n}\n\nasync function rollbackCreatedVariantPanel(params: {\n  panelId: string\n  storyboardId: string\n  panelIndex: number\n}) {\n  await prisma.$transaction(async (tx) => {\n    await tx.novelPromotionPanel.delete({\n      where: { id: params.panelId },\n    })\n\n    const maxPanel = await tx.novelPromotionPanel.findFirst({\n      where: { storyboardId: params.storyboardId },\n      orderBy: { panelIndex: 'desc' },\n      select: { panelIndex: true },\n    })\n    const maxPanelIndex = maxPanel?.panelIndex ?? -1\n    const offset = maxPanelIndex + 1000\n\n    await tx.novelPromotionPanel.updateMany({\n      where: {\n        storyboardId: params.storyboardId,\n        panelIndex: { gt: params.panelIndex },\n      },\n      data: {\n        panelIndex: { increment: offset },\n        panelNumber: { increment: offset },\n      },\n    })\n\n    await tx.novelPromotionPanel.updateMany({\n      where: {\n        storyboardId: params.storyboardId,\n        panelIndex: { gt: params.panelIndex + offset },\n      },\n      data: {\n        panelIndex: { decrement: offset + 1 },\n        panelNumber: { decrement: offset + 1 },\n      },\n    })\n\n    const panelCount = await tx.novelPromotionPanel.count({\n      where: { storyboardId: params.storyboardId },\n    })\n\n    await tx.novelPromotionStoryboard.update({\n      where: { id: params.storyboardId },\n      data: { panelCount },\n    })\n  })\n}\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const storyboardId = body?.storyboardId\n  const insertAfterPanelId = body?.insertAfterPanelId\n  const sourcePanelId = body?.sourcePanelId\n  const variant = body?.variant\n\n  if (!storyboardId || !insertAfterPanelId || !sourcePanelId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (!variant || !variant.video_prompt) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const storyboard = await prisma.novelPromotionStoryboard.findUnique({\n    where: { id: storyboardId },\n    select: {\n      id: true,\n      episode: {\n        select: {\n          novelPromotionProject: {\n            select: {\n              projectId: true,\n            },\n          },\n        },\n      },\n    },\n  })\n  if (!storyboard || storyboard.episode.novelPromotionProject.projectId !== projectId) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const sourcePanel = await prisma.novelPromotionPanel.findUnique({ where: { id: sourcePanelId } })\n  if (!sourcePanel || sourcePanel.storyboardId !== storyboardId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const insertAfter = await prisma.novelPromotionPanel.findUnique({ where: { id: insertAfterPanelId } })\n  if (!insertAfter || insertAfter.storyboardId !== storyboardId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)\n  const imageModel = projectModelConfig.storyboardModel\n  const createdPanelId = createPanelVariantId()\n\n  let billingPayload: Record<string, unknown>\n  try {\n    billingPayload = await buildImageBillingPayload({\n      projectId,\n      userId: session.user.id,\n      imageModel,\n      basePayload: { ...body, newPanelId: createdPanelId },\n    })\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Image model capability not configured'\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED',\n      message,\n    })\n  }\n\n  const createdPanel = await prisma.$transaction(async (tx) => {\n    const affectedPanels = await tx.novelPromotionPanel.findMany({\n      where: { storyboardId, panelIndex: { gt: insertAfter.panelIndex } },\n      select: { id: true, panelIndex: true },\n      orderBy: { panelIndex: 'asc' },\n    })\n\n    for (const panel of affectedPanels) {\n      await tx.novelPromotionPanel.update({\n        where: { id: panel.id },\n        data: { panelIndex: -(panel.panelIndex + 1) },\n      })\n    }\n\n    for (const panel of affectedPanels) {\n      await tx.novelPromotionPanel.update({\n        where: { id: panel.id },\n        data: { panelIndex: panel.panelIndex + 1 },\n      })\n    }\n\n    const created = await tx.novelPromotionPanel.create({\n      data: {\n        id: createdPanelId,\n        storyboardId,\n        panelIndex: insertAfter.panelIndex + 1,\n        panelNumber: insertAfter.panelIndex + 2,\n        shotType: variant.shot_type || sourcePanel.shotType,\n        cameraMove: variant.camera_move || sourcePanel.cameraMove,\n        description: variant.description || sourcePanel.description,\n        videoPrompt: variant.video_prompt || sourcePanel.videoPrompt,\n        location: variant.location || sourcePanel.location,\n        characters: variant.characters ? JSON.stringify(variant.characters) : sourcePanel.characters,\n        srtSegment: sourcePanel.srtSegment,\n        duration: sourcePanel.duration,\n      },\n    })\n\n    const panelCount = await tx.novelPromotionPanel.count({\n      where: { storyboardId },\n    })\n\n    await tx.novelPromotionStoryboard.update({\n      where: { id: storyboardId },\n      data: { panelCount },\n    })\n\n    return created\n  })\n\n  let result: Awaited<ReturnType<typeof submitTask>>\n  try {\n    result = await submitTask({\n      userId: session.user.id,\n      locale,\n      requestId: getRequestId(request),\n      projectId,\n      type: TASK_TYPE.PANEL_VARIANT,\n      targetType: 'NovelPromotionPanel',\n      targetId: createdPanel.id,\n      payload: billingPayload,\n      dedupeKey: `panel_variant:${storyboardId}:${insertAfterPanelId}:${sourcePanelId}`,\n      billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.PANEL_VARIANT, billingPayload),\n    })\n  } catch (error) {\n    await rollbackCreatedVariantPanel({\n      panelId: createdPanel.id,\n      storyboardId,\n      panelIndex: createdPanel.panelIndex,\n    })\n    throw error\n  }\n\n  return NextResponse.json({ ...result, panelId: createdPanel.id })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/photography-plan/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * PUT /api/novel-promotion/[projectId]/photography-plan\n * 更新分镜组的摄影方案\n */\nexport const PUT = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    const body = await request.json()\n    const { storyboardId, photographyPlan } = body\n\n    if (!storyboardId) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 验证 storyboard 存在\n    const storyboard = await prisma.novelPromotionStoryboard.findUnique({\n        where: { id: storyboardId }\n    })\n\n    if (!storyboard) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 更新摄影方案\n    const photographyPlanJson = photographyPlan ? JSON.stringify(photographyPlan) : null\n\n    await prisma.novelPromotionStoryboard.update({\n        where: { id: storyboardId },\n        data: { photographyPlan: photographyPlanJson }\n    })\n\n    _ulogInfo('[PUT /photography-plan] 更新成功, storyboardId:', storyboardId)\n\n    return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/reference-to-character/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\n\nfunction parseReferenceImages(body: Record<string, unknown>): string[] {\n  const list = Array.isArray(body.referenceImageUrls)\n    ? body.referenceImageUrls.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)\n    : []\n  if (list.length > 0) return list.slice(0, 5)\n  const single = typeof body.referenceImageUrl === 'string' ? body.referenceImageUrl.trim() : ''\n  return single ? [single] : []\n}\n\n/**\n * 项目级 - 参考图转角色（任务化）\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = (await request.json().catch(() => ({}))) as Record<string, unknown>\n  const referenceImages = parseReferenceImages(body)\n  if (referenceImages.length === 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  const count = normalizeImageGenerationCount('reference-to-character', body.count)\n  body.count = count\n\n  const isBackgroundJob = body.isBackgroundJob === true || body.isBackgroundJob === 1 || body.isBackgroundJob === '1'\n  const characterId = typeof body.characterId === 'string' ? body.characterId : ''\n  const appearanceId = typeof body.appearanceId === 'string' ? body.appearanceId : ''\n  if (isBackgroundJob && (!characterId || !appearanceId)) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    type: TASK_TYPE.REFERENCE_TO_CHARACTER,\n    targetType: appearanceId ? 'CharacterAppearance' : 'NovelPromotionProject',\n    targetId: appearanceId || characterId || projectId,\n    routePath: `/api/novel-promotion/${projectId}/reference-to-character`,\n    body,\n    dedupeKey: `reference_to_character:${appearanceId || characterId || projectId}:${count}`})\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/regenerate-group/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\nimport { ensureProjectLocationImageSlots } from '@/lib/image-generation/location-slots'\nimport { prisma } from '@/lib/prisma'\nimport {\n  hasCharacterAppearanceOutput,\n  hasLocationImageOutput\n} from '@/lib/task/has-output'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const type = body?.type\n  const id = body?.id\n  const appearanceId = body?.appearanceId\n  const count = type === 'character'\n    ? normalizeImageGenerationCount('character', body?.count)\n    : normalizeImageGenerationCount('location', body?.count)\n\n  if (!type || !id) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (type !== 'character' && type !== 'location') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (type === 'character' && !appearanceId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const targetType = type === 'character' ? 'CharacterAppearance' : 'LocationImage'\n  const targetId = type === 'character' ? appearanceId : id\n  if (type === 'location') {\n    const location = await prisma.novelPromotionLocation.findUnique({\n      where: { id },\n      select: { name: true, summary: true },\n    })\n    if (!location) {\n      throw new ApiError('NOT_FOUND')\n    }\n    await ensureProjectLocationImageSlots({\n      locationId: id,\n      count,\n      fallbackDescription: location.summary || location.name,\n    })\n  }\n  const hasOutputAtStart = type === 'character'\n    ? await hasCharacterAppearanceOutput({\n      appearanceId,\n      characterId: id\n    })\n    : await hasLocationImageOutput({\n      locationId: id\n    })\n\n  const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)\n  const imageModel = type === 'character'\n    ? projectModelConfig.characterModel\n    : projectModelConfig.locationModel\n\n  let billingPayload: Record<string, unknown>\n  try {\n    billingPayload = await buildImageBillingPayload({\n      projectId,\n      userId: session.user.id,\n      imageModel,\n      basePayload: { ...body, count },\n    })\n  } catch (err) {\n    const message = err instanceof Error ? err.message : 'Image model capability not configured'\n    throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })\n  }\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: TASK_TYPE.REGENERATE_GROUP,\n    targetType,\n    targetId,\n    payload: withTaskUiPayload(billingPayload, {\n      intent: 'regenerate',\n      hasOutputAtStart\n    }),\n    dedupeKey: `regenerate_group:${targetType}:${targetId}:${count}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.REGENERATE_GROUP, billingPayload)\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/regenerate-panel-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { hasPanelImageOutput } from '@/lib/task/has-output'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { getProjectModelConfig } from '@/lib/config-service'\nimport { resolveProjectModelCapabilityGenerationOptions } from '@/lib/config-service'\nimport { resolveModelSelection } from '@/lib/api-config'\n\nconst DEFAULT_CANDIDATE_COUNT = 1\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const panelId = body?.panelId\n  const count = body?.count\n  const candidateCount = Math.max(1, Math.min(4, Number(count ?? DEFAULT_CANDIDATE_COUNT)))\n\n  if (!panelId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)\n  if (!projectModelConfig.storyboardModel) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'STORYBOARD_MODEL_NOT_CONFIGURED'})\n  }\n  try {\n    await resolveModelSelection(session.user.id, projectModelConfig.storyboardModel, 'image')\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Storyboard image model is invalid'\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'STORYBOARD_MODEL_INVALID',\n      message})\n  }\n\n  const capabilityOptions = await resolveProjectModelCapabilityGenerationOptions({\n    projectId,\n    userId: session.user.id,\n    modelType: 'image',\n    modelKey: projectModelConfig.storyboardModel})\n  const billingPayload = {\n    ...body,\n    candidateCount,\n    imageModel: projectModelConfig.storyboardModel,\n    ...(Object.keys(capabilityOptions).length > 0 ? { generationOptions: capabilityOptions } : {})}\n\n  const hasOutputAtStart = await hasPanelImageOutput(panelId)\n\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: TASK_TYPE.IMAGE_PANEL,\n    targetType: 'NovelPromotionPanel',\n    targetId: panelId,\n    payload: withTaskUiPayload(billingPayload, {\n      intent: 'regenerate',\n      hasOutputAtStart}),\n    dedupeKey: `image_panel:${panelId}:${candidateCount}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, billingPayload)})\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/regenerate-single-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service'\nimport {\n  hasCharacterAppearanceOutput,\n  hasLocationImageOutput\n} from '@/lib/task/has-output'\n\nfunction toNumber(value: unknown) {\n  const parsed = Number(value)\n  return Number.isFinite(parsed) ? parsed : null\n}\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const type = body?.type\n  const id = body?.id\n  const appearanceId = body?.appearanceId\n  const imageIndex = body?.imageIndex\n\n  if (!type || !id || imageIndex === undefined) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (type !== 'character' && type !== 'location') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const taskType = type === 'character' ? TASK_TYPE.IMAGE_CHARACTER : TASK_TYPE.IMAGE_LOCATION\n  const targetType = type === 'character' ? 'CharacterAppearance' : 'LocationImage'\n  const targetId = type === 'character' ? (appearanceId || id) : id\n  const parsedImageIndex = toNumber(imageIndex)\n  const hasOutputAtStart = type === 'character'\n    ? await hasCharacterAppearanceOutput({\n      appearanceId: targetId,\n      characterId: id\n    })\n    : await hasLocationImageOutput({\n      locationId: id,\n      imageIndex: parsedImageIndex\n    })\n\n  const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)\n  const imageModel = type === 'character'\n    ? projectModelConfig.characterModel\n    : projectModelConfig.locationModel\n\n  let billingPayload: Record<string, unknown>\n  try {\n    billingPayload = await buildImageBillingPayload({\n      projectId,\n      userId: session.user.id,\n      imageModel,\n      basePayload: body,\n    })\n  } catch (err) {\n    const message = err instanceof Error ? err.message : 'Image model capability not configured'\n    throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })\n  }\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: taskType,\n    targetType,\n    targetId,\n    payload: withTaskUiPayload(billingPayload, {\n      intent: 'regenerate',\n      hasOutputAtStart\n    }),\n    dedupeKey: `${taskType}:${targetId}:single:${imageIndex}`,\n    billingInfo: buildDefaultTaskBillingInfo(taskType, billingPayload)\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/regenerate-storyboard-text/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { getProjectModelConfig } from '@/lib/config-service'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n  const locale = resolveRequiredTaskLocale(request, body)\n  const storyboardId = body?.storyboardId\n\n  if (!storyboardId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const projectModelConfig = await getProjectModelConfig(projectId, session.user.id)\n  const billingPayload = { ...body, ...(projectModelConfig.analysisModel ? { analysisModel: projectModelConfig.analysisModel } : {}) }\n\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: TASK_TYPE.REGENERATE_STORYBOARD_TEXT,\n    targetType: 'NovelPromotionStoryboard',\n    targetId: storyboardId,\n    payload: billingPayload,\n    dedupeKey: `regenerate_storyboard_text:${storyboardId}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.REGENERATE_STORYBOARD_TEXT, billingPayload)\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { logProjectAction } from '@/lib/logging/semantic'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { isArtStyleValue } from '@/lib/constants'\nimport { attachMediaFieldsToProject } from '@/lib/media/attach'\nimport {\n  parseModelKeyStrict,\n  type CapabilitySelections,\n  type UnifiedModelType} from '@/lib/model-config-contract'\nimport {\n  resolveBuiltinModelContext,\n  getCapabilityOptionFields,\n  validateCapabilitySelectionsPayload,\n  type CapabilityModelContext} from '@/lib/model-capabilities/lookup'\n\nconst MODEL_FIELDS = [\n  'analysisModel',\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n  'videoModel',\n  'audioModel',\n] as const\n\nconst MODEL_FIELD_TO_TYPE: Record<typeof MODEL_FIELDS[number], UnifiedModelType> = {\n  analysisModel: 'llm',\n  characterModel: 'image',\n  locationModel: 'image',\n  storyboardModel: 'image',\n  editModel: 'image',\n  videoModel: 'video',\n  audioModel: 'audio',\n}\n\nconst CAPABILITY_MODEL_TYPES: readonly UnifiedModelType[] = ['image', 'video', 'llm', 'audio', 'lipsync']\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction normalizeCapabilitySelectionsInput(\n  raw: unknown,\n  options?: { allowLegacyAspectRatio?: boolean },\n): CapabilitySelections {\n  if (raw === undefined || raw === null) return {}\n  if (!isRecord(raw)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'CAPABILITY_SELECTION_INVALID',\n      field: 'capabilityOverrides'})\n  }\n\n  const normalized: CapabilitySelections = {}\n  for (const [modelKey, rawSelection] of Object.entries(raw)) {\n    if (!isRecord(rawSelection)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'CAPABILITY_SELECTION_INVALID',\n        field: `capabilityOverrides.${modelKey}`})\n    }\n\n    const selection: Record<string, string | number | boolean> = {}\n    for (const [field, value] of Object.entries(rawSelection)) {\n      if (field === 'aspectRatio') {\n        if (options?.allowLegacyAspectRatio) continue\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'CAPABILITY_FIELD_INVALID',\n          field: `capabilityOverrides.${modelKey}.${field}`})\n      }\n      if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'CAPABILITY_SELECTION_INVALID',\n          field: `capabilityOverrides.${modelKey}.${field}`})\n      }\n      selection[field] = value\n    }\n\n    if (Object.keys(selection).length > 0) {\n      normalized[modelKey] = selection\n    }\n  }\n\n  return normalized\n}\n\nfunction parseStoredCapabilitySelections(raw: string | null | undefined): CapabilitySelections {\n  if (!raw) return {}\n  try {\n    return normalizeCapabilitySelectionsInput(JSON.parse(raw) as unknown, { allowLegacyAspectRatio: true })\n  } catch {\n    return {}\n  }\n}\n\nfunction serializeCapabilitySelections(selections: CapabilitySelections): string | null {\n  if (Object.keys(selections).length === 0) return null\n  return JSON.stringify(selections)\n}\n\nfunction validateModelKeyField(field: typeof MODEL_FIELDS[number], value: unknown) {\n  // Contract anchor: model key must be provider::modelId\n  if (value === null) return\n  if (typeof value !== 'string' || !value.trim()) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_INVALID',\n      field})\n  }\n  if (!parseModelKeyStrict(value)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_INVALID',\n      field})\n  }\n}\n\nfunction validateArtStyleField(value: unknown): string {\n  if (typeof value !== 'string') {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'INVALID_ART_STYLE',\n      field: 'artStyle',\n      message: 'artStyle must be a supported value',\n    })\n  }\n  const artStyle = value.trim()\n  if (!isArtStyleValue(artStyle)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'INVALID_ART_STYLE',\n      field: 'artStyle',\n      message: 'artStyle must be a supported value',\n    })\n  }\n  return artStyle\n}\n\nfunction getNextProjectModelMap(\n  current: {\n    analysisModel: string | null\n    characterModel: string | null\n    locationModel: string | null\n    storyboardModel: string | null\n    editModel: string | null\n    videoModel: string | null\n    audioModel: string | null\n  },\n  updates: Record<string, unknown>,\n): Record<string, CapabilityModelContext> {\n  const nextMap = new Map<string, CapabilityModelContext>()\n\n  for (const field of MODEL_FIELDS) {\n    const rawValue = updates[field] !== undefined\n      ? updates[field]\n      : current[field]\n    if (typeof rawValue !== 'string' || !rawValue.trim()) continue\n\n    const modelKey = rawValue.trim()\n    const context = resolveBuiltinModelContext(MODEL_FIELD_TO_TYPE[field], modelKey)\n    if (!context) continue\n    nextMap.set(modelKey, context)\n  }\n\n  return Object.fromEntries(nextMap)\n}\n\nfunction resolveCapabilityContext(\n  modelKey: string,\n  modelContextMap: Record<string, CapabilityModelContext>,\n): CapabilityModelContext | null {\n  const fromProjectModel = modelContextMap[modelKey]\n  if (fromProjectModel) return fromProjectModel\n  if (!parseModelKeyStrict(modelKey)) return null\n\n  for (const modelType of CAPABILITY_MODEL_TYPES) {\n    const context = resolveBuiltinModelContext(modelType, modelKey)\n    if (context) return context\n  }\n\n  return null\n}\n\nfunction sanitizeCapabilityOverrides(\n  overrides: CapabilitySelections,\n  modelContextMap: Record<string, CapabilityModelContext>,\n): CapabilitySelections {\n  const sanitized: CapabilitySelections = {}\n\n  for (const [modelKey, selection] of Object.entries(overrides)) {\n    const context = resolveCapabilityContext(modelKey, modelContextMap)\n    if (!context) continue\n\n    const optionFields = getCapabilityOptionFields(context.modelType, context.capabilities)\n    if (Object.keys(optionFields).length === 0) continue\n\n    const cleanedSelection: Record<string, string | number | boolean> = {}\n    for (const [field, value] of Object.entries(selection)) {\n      const allowedValues = optionFields[field]\n      if (!allowedValues) continue\n      if (!allowedValues.includes(value)) continue\n      cleanedSelection[field] = value\n    }\n\n    if (Object.keys(cleanedSelection).length > 0) {\n      sanitized[modelKey] = cleanedSelection\n    }\n  }\n\n  return sanitized\n}\n\nfunction validateCapabilityOverrides(\n  overrides: CapabilitySelections,\n  modelContextMap: Record<string, CapabilityModelContext>,\n) {\n  const issues = validateCapabilitySelectionsPayload(overrides, (modelKey) =>\n    resolveCapabilityContext(modelKey, modelContextMap))\n\n  if (issues.length > 0) {\n    const firstIssue = issues[0]\n    throw new ApiError('INVALID_PARAMS', {\n      code: firstIssue.code,\n      field: firstIssue.field,\n      allowedValues: firstIssue.allowedValues})\n  }\n}\n\nexport const GET = apiHandler(async (\n  _request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const projectData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    select: {\n      capabilityOverrides: true,\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n      audioModel: true,\n    }})\n\n  const storedOverrides = parseStoredCapabilitySelections(projectData?.capabilityOverrides)\n  const modelContextMap = projectData\n    ? getNextProjectModelMap({\n      analysisModel: projectData.analysisModel,\n      characterModel: projectData.characterModel,\n      locationModel: projectData.locationModel,\n      storyboardModel: projectData.storyboardModel,\n      editModel: projectData.editModel,\n      videoModel: projectData.videoModel,\n      audioModel: projectData.audioModel,\n    }, {})\n    : {}\n  const cleanedOverrides = sanitizeCapabilityOverrides(storedOverrides, modelContextMap)\n\n  return NextResponse.json({\n    capabilityOverrides: cleanedOverrides})\n})\n\n// PATCH - 更新小说推文项目配置\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const session = authResult.session\n  const project = authResult.project\n\n  const body = await request.json()\n\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const currentProjectConfig = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    select: {\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n      audioModel: true,\n    }})\n  if (!currentProjectConfig) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const allowedProjectFields = [\n    'analysisModel', 'characterModel', 'locationModel', 'storyboardModel',\n    'editModel', 'videoModel', 'audioModel', 'videoRatio', 'artStyle',\n    'ttsRate', 'lipSyncEnabled', 'lipSyncMode', 'capabilityOverrides',\n  ] as const\n\n  const updateData: Record<string, unknown> = {}\n  for (const field of allowedProjectFields) {\n    if (body[field] === undefined) continue\n\n    if ((MODEL_FIELDS as readonly string[]).includes(field)) {\n      validateModelKeyField(field as typeof MODEL_FIELDS[number], body[field])\n    }\n\n    if (field === 'artStyle') {\n      updateData[field] = validateArtStyleField(body[field])\n      continue\n    }\n\n    if (field === 'capabilityOverrides') {\n      const overrides = normalizeCapabilitySelectionsInput(body.capabilityOverrides)\n      const modelContextMap = getNextProjectModelMap(currentProjectConfig, body as Record<string, unknown>)\n      const cleanedOverrides = sanitizeCapabilityOverrides(overrides, modelContextMap)\n      validateCapabilityOverrides(cleanedOverrides, modelContextMap)\n      updateData.capabilityOverrides = serializeCapabilitySelections(cleanedOverrides)\n      continue\n    }\n\n    updateData[field] = body[field]\n  }\n\n  const updatedNovelPromotionData = await prisma.novelPromotionProject.update({\n    where: { projectId },\n    data: updateData})\n\n  const novelPromotionDataWithSignedUrls = await attachMediaFieldsToProject(updatedNovelPromotionData)\n\n  const fullProject = {\n    ...project,\n    novelPromotionData: novelPromotionDataWithSignedUrls}\n\n  logProjectAction(\n    'UPDATE_NOVEL_PROMOTION',\n    session.user.id,\n    session.user.name,\n    projectId,\n    project.name,\n    JSON.stringify({ changes: body }),\n  )\n\n  return NextResponse.json({ project: fullProject })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/screenplay-conversion/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\n/**\n * POST /api/novel-promotion/[projectId]/screenplay-conversion\n * 将 clips 转换为结构化剧本\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const body = await request.json().catch(() => ({}))\n  const episodeId = typeof body?.episodeId === 'string' ? body.episodeId.trim() : ''\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const authResult = await requireProjectAuth(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session, project } = authResult\n\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    episodeId,\n    type: TASK_TYPE.SCREENPLAY_CONVERT,\n    targetType: 'NovelPromotionEpisode',\n    targetId: episodeId,\n    routePath: `/api/novel-promotion/${projectId}/screenplay-conversion`,\n    body: {\n      ...body,\n      displayMode: 'detail',\n    },\n    dedupeKey: `screenplay_convert:${episodeId}`,\n    priority: 2,\n  })\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\nexport const runtime = 'nodejs'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const body = await request.json().catch(() => ({}))\n  const episodeId = typeof body?.episodeId === 'string' ? body.episodeId.trim() : ''\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const authResult = await requireProjectAuth(projectId, {\n    include: { characters: true, locations: true },\n  })\n  if (isErrorResponse(authResult)) return authResult\n  const { session, project } = authResult\n\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    episodeId,\n    type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n    targetType: 'NovelPromotionEpisode',\n    targetId: episodeId,\n    routePath: `/api/novel-promotion/${projectId}/script-to-storyboard-stream`,\n    body: {\n      ...body,\n      displayMode: 'detail',\n    },\n    dedupeKey: `script_to_storyboard_run:${episodeId}`,\n    priority: 2,\n  })\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/select-character-image/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { getSignedUrl } from '@/lib/storage'\nimport { decodeImageUrlsFromDb } from '@/lib/contracts/image-urls-contract'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST - 选择角色形象的图片\n * 直接更新独立的 CharacterAppearance 表\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const { characterId, appearanceId, selectedIndex } = await request.json()\n\n  if (!characterId || !appearanceId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 使用 UUID 直接查询\n  const appearance = await prisma.characterAppearance.findUnique({\n    where: { id: appearanceId },\n    include: { character: true }\n  })\n\n  if (!appearance) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 解析图片URLs\n  const imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls')\n\n  // 验证索引\n  if (selectedIndex !== null) {\n    if (selectedIndex < 0 || selectedIndex >= imageUrls.length || !imageUrls[selectedIndex]) {\n      throw new ApiError('INVALID_PARAMS')\n    }\n  }\n\n  const selectedImageKey = selectedIndex !== null ? imageUrls[selectedIndex] : null\n\n  // 直接更新独立记录（无并发风险）\n  await prisma.characterAppearance.update({\n    where: { id: appearance.id },\n    data: {\n      selectedIndex: selectedIndex,\n      imageUrl: selectedImageKey\n    }\n  })\n\n  if (selectedIndex !== null) {\n    _ulogInfo(`✓ 角色 ${appearance.character.name} 形象 ${appearanceId}: 选择了索引 ${selectedIndex}`)\n  } else {\n    _ulogInfo(`✓ 角色 ${appearance.character.name} 形象 ${appearanceId}: 取消选择`)\n  }\n\n  const signedUrl = selectedImageKey ? getSignedUrl(selectedImageKey, 7 * 24 * 3600) : null\n\n  return NextResponse.json({\n    success: true,\n    selectedIndex,\n    imageUrl: signedUrl\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/select-location-image/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { getSignedUrl } from '@/lib/storage'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST - 选择场景图片\n * 直接更新独立的 LocationImage 表\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const { locationId, selectedIndex } = await request.json()\n\n  if (!locationId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取场景和所有图片\n  const location = await prisma.novelPromotionLocation.findUnique({\n    where: { id: locationId },\n    include: { images: { orderBy: { imageIndex: 'asc' } } }\n  })\n\n  if (!location) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 验证索引\n  if (selectedIndex !== null) {\n    const targetImage = location.images.find(img => img.imageIndex === selectedIndex)\n    if (!targetImage || !targetImage.imageUrl) {\n      throw new ApiError('INVALID_PARAMS')\n    }\n  }\n\n  // 先取消所有选中状态（兼容旧字段）\n  await prisma.locationImage.updateMany({\n    where: { locationId },\n    data: { isSelected: false }\n  })\n\n  // 选中指定的图片\n  let signedUrl: string | null = null\n  if (selectedIndex !== null) {\n    const updated = await prisma.locationImage.update({\n      where: { locationId_imageIndex: { locationId, imageIndex: selectedIndex } },\n      data: { isSelected: true }\n    })\n    signedUrl = updated.imageUrl ? getSignedUrl(updated.imageUrl, 7 * 24 * 3600) : null\n    await prisma.novelPromotionLocation.update({\n      where: { id: locationId },\n      data: { selectedImageId: updated.id }\n    })\n    _ulogInfo(`✓ 场景 ${location.name}: 选择了索引 ${selectedIndex}`)\n  } else {\n    await prisma.novelPromotionLocation.update({\n      where: { id: locationId },\n      data: { selectedImageId: null }\n    })\n    _ulogInfo(`✓ 场景 ${location.name}: 取消选择`)\n  }\n\n  return NextResponse.json({\n    success: true,\n    selectedIndex,\n    imageUrl: signedUrl\n  })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/speaker-voice/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { getSignedUrl } from '@/lib/storage'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport {\n  parseSpeakerVoiceMap,\n  type SpeakerVoiceEntry,\n  type SpeakerVoiceMap,\n} from '@/lib/voice/provider-voice-binding'\n\nfunction readTrimmedString(input: unknown): string | null {\n  if (typeof input !== 'string') return null\n  const value = input.trim()\n  return value.length > 0 ? value : null\n}\n\nfunction signUrlIfNeeded(url: string): string {\n  if (url.startsWith('http')) return url\n  return getSignedUrl(url, 7200)\n}\n\n/**\n * GET /api/novel-promotion/[projectId]/speaker-voice?episodeId=xxx\n * 获取剧集的发言人音色配置\n */\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n  const { searchParams } = new URL(request.url)\n  const episodeId = searchParams.get('episodeId')\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const episode = await prisma.novelPromotionEpisode.findUnique({\n    where: { id: episodeId },\n  })\n\n  if (!episode) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const storedSpeakerVoices = parseSpeakerVoiceMap(episode.speakerVoices)\n  const speakerVoices: SpeakerVoiceMap = {}\n\n  for (const [speaker, voice] of Object.entries(storedSpeakerVoices)) {\n    if (voice.provider === 'fal') {\n      speakerVoices[speaker] = {\n        provider: 'fal',\n        voiceType: voice.voiceType,\n        audioUrl: signUrlIfNeeded(voice.audioUrl),\n      }\n      continue\n    }\n\n    const previewAudioUrl = voice.previewAudioUrl ? signUrlIfNeeded(voice.previewAudioUrl) : undefined\n    speakerVoices[speaker] = {\n      provider: 'bailian',\n      voiceType: voice.voiceType,\n      voiceId: voice.voiceId,\n      ...(previewAudioUrl ? { previewAudioUrl } : {}),\n    }\n  }\n\n  return NextResponse.json({ speakerVoices })\n})\n\n/**\n * PATCH /api/novel-promotion/[projectId]/speaker-voice\n * 为指定发言人直接设置音色（写入 episode.speakerVoices JSON）\n * 用于不在资产库中的角色在配音阶段内联绑定音色\n */\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json().catch(() => null)\n  const episodeId = readTrimmedString(body?.episodeId) ?? ''\n  const speaker = readTrimmedString(body?.speaker) ?? ''\n  const voiceType = readTrimmedString(body?.voiceType) ?? 'uploaded'\n  const providerRaw = readTrimmedString(body?.provider)?.toLowerCase() ?? null\n  if (!providerRaw || (providerRaw !== 'fal' && providerRaw !== 'bailian')) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  const provider = providerRaw\n  const audioUrl = readTrimmedString(body?.audioUrl)\n  const previewAudioUrl = readTrimmedString(body?.previewAudioUrl)\n  const voiceId = readTrimmedString(body?.voiceId)\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (!speaker) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (provider === 'fal' && !audioUrl) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (provider === 'bailian' && !voiceId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const projectData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    select: { id: true },\n  })\n  if (!projectData) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const episode = await prisma.novelPromotionEpisode.findFirst({\n    where: { id: episodeId, novelPromotionProjectId: projectData.id },\n    select: { id: true, speakerVoices: true },\n  })\n  if (!episode) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const speakerVoices = parseSpeakerVoiceMap(episode.speakerVoices)\n\n  let nextVoiceEntry: SpeakerVoiceEntry\n  if (provider === 'fal') {\n    const sourceAudioUrl = audioUrl!\n    const resolvedStorageKey = await resolveStorageKeyFromMediaValue(sourceAudioUrl)\n    const audioUrlToStore = resolvedStorageKey || sourceAudioUrl\n    nextVoiceEntry = {\n      provider: 'fal',\n      voiceType,\n      audioUrl: audioUrlToStore,\n    }\n  } else {\n    const previewCandidate = previewAudioUrl || audioUrl\n    const resolvedPreviewKey = previewCandidate\n      ? await resolveStorageKeyFromMediaValue(previewCandidate)\n      : null\n    const previewAudioUrlToStore = previewCandidate\n      ? (resolvedPreviewKey || previewCandidate)\n      : undefined\n\n    nextVoiceEntry = {\n      provider: 'bailian',\n      voiceType,\n      voiceId: voiceId!,\n      ...(previewAudioUrlToStore ? { previewAudioUrl: previewAudioUrlToStore } : {}),\n    }\n  }\n\n  speakerVoices[speaker] = nextVoiceEntry\n\n  await prisma.novelPromotionEpisode.update({\n    where: { id: episodeId },\n    data: { speakerVoices: JSON.stringify(speakerVoices) },\n  })\n\n  return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/story-to-script-stream/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\nexport const runtime = 'nodejs'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const body = await request.json().catch(() => ({}))\n  const episodeId = typeof body?.episodeId === 'string' ? body.episodeId.trim() : ''\n  const content = typeof body?.content === 'string' ? body.content.trim() : ''\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (!content) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const authResult = await requireProjectAuth(projectId, {\n    include: { characters: true, locations: true },\n  })\n  if (isErrorResponse(authResult)) return authResult\n  const { session, project } = authResult\n\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    episodeId,\n    type: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n    targetType: 'NovelPromotionEpisode',\n    targetId: episodeId,\n    routePath: `/api/novel-promotion/${projectId}/story-to-script-stream`,\n    body: {\n      ...body,\n      displayMode: 'detail',\n    },\n    dedupeKey: `story_to_script_run:${episodeId}`,\n    priority: 2,\n  })\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/storyboard-group/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST /api/novel-promotion/[projectId]/storyboard-group\n * 添加一组新的分镜（创建 Clip + Storyboard + 初始 Panel）\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { episodeId, insertIndex } = body\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取剧集和现有 clips\n  const episode = await prisma.novelPromotionEpisode.findUnique({\n    where: { id: episodeId },\n    include: {\n      clips: { orderBy: { createdAt: 'asc' } }\n    }\n  })\n\n  if (!episode) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const existingClips = episode.clips\n  const insertAt = insertIndex !== undefined ? insertIndex : existingClips.length\n\n  // 计算新 clip 的 createdAt 时间，用于排序\n  let newCreatedAt: Date\n\n  if (existingClips.length === 0) {\n    // 没有现有 clips，使用当前时间\n    newCreatedAt = new Date()\n  } else if (insertAt === 0) {\n    // 插入到开头，设置为第一个 clip 之前的时间\n    const firstClip = existingClips[0]\n    newCreatedAt = new Date(firstClip.createdAt.getTime() - 1000) // 减1秒\n  } else if (insertAt >= existingClips.length) {\n    // 插入到结尾，设置为最后一个 clip 之后的时间\n    const lastClip = existingClips[existingClips.length - 1]\n    newCreatedAt = new Date(lastClip.createdAt.getTime() + 1000) // 加1秒\n  } else {\n    // 插入到中间，设置为前后两个 clip 时间的中间值\n    const prevClip = existingClips[insertAt - 1]\n    const nextClip = existingClips[insertAt]\n    const midTime = (prevClip.createdAt.getTime() + nextClip.createdAt.getTime()) / 2\n    newCreatedAt = new Date(midTime)\n  }\n\n  // 使用事务创建 Clip + Storyboard + Panel\n  const result = await prisma.$transaction(async (tx) => {\n    // 1. 创建新的 Clip（手动添加类型）\n    const newClip = await tx.novelPromotionClip.create({\n      data: {\n        episodeId,\n        summary: '手动添加的分镜组',\n        content: '',\n        location: null,\n        characters: null,\n        createdAt: newCreatedAt\n      }\n    })\n\n    // 2. 创建关联的 Storyboard\n    const newStoryboard = await tx.novelPromotionStoryboard.create({\n      data: {\n        episodeId,\n        clipId: newClip.id,\n        panelCount: 1\n      }\n    })\n\n    // 3. 创建初始的 Panel\n    const newPanel = await tx.novelPromotionPanel.create({\n      data: {\n        storyboardId: newStoryboard.id,\n        panelIndex: 0,\n        panelNumber: 1,\n        shotType: '中景',\n        cameraMove: '固定',\n        description: '新镜头描述',\n        characters: '[]'\n      }\n    })\n\n    return { clip: newClip, storyboard: newStoryboard, panel: newPanel }\n  })\n\n  _ulogInfo(`[添加分镜组] episodeId=${episodeId}, clipId=${result.clip.id}, storyboardId=${result.storyboard.id}, insertAt=${insertAt}`)\n\n  return NextResponse.json({\n    success: true,\n    clip: result.clip,\n    storyboard: result.storyboard,\n    panel: result.panel\n  })\n})\n\n/**\n * PUT /api/novel-promotion/[projectId]/storyboard-group\n * 调整分镜组顺序（通过修改 clip 的 createdAt）\n */\nexport const PUT = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { episodeId, clipId, direction } = body // direction: 'up' | 'down'\n\n  if (!episodeId || !clipId || !direction) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取剧集和所有 clips（按 createdAt 排序）\n  const episode = await prisma.novelPromotionEpisode.findUnique({\n    where: { id: episodeId },\n    include: {\n      clips: { orderBy: { createdAt: 'asc' } }\n    }\n  })\n\n  if (!episode) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const clips = episode.clips\n  const currentIndex = clips.findIndex(c => c.id === clipId)\n\n  if (currentIndex === -1) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 计算目标位置\n  const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1\n\n  // 检查边界\n  if (targetIndex < 0 || targetIndex >= clips.length) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const currentClip = clips[currentIndex]\n  const targetClip = clips[targetIndex]\n\n  // 交换两个 clip 的 createdAt（加减小量时间避免冲突）\n  const tempTime = currentClip.createdAt.getTime()\n  const targetTime = targetClip.createdAt.getTime()\n\n  // 使用事务更新\n  await prisma.$transaction(async (tx) => {\n    // 先把当前 clip 移到一个临时时间\n    await tx.novelPromotionClip.update({\n      where: { id: currentClip.id },\n      data: { createdAt: new Date(0) } // 临时时间\n    })\n\n    // 更新目标 clip 的时间\n    await tx.novelPromotionClip.update({\n      where: { id: targetClip.id },\n      data: { createdAt: new Date(tempTime) }\n    })\n\n    // 更新当前 clip 到目标时间\n    await tx.novelPromotionClip.update({\n      where: { id: currentClip.id },\n      data: { createdAt: new Date(targetTime) }\n    })\n  })\n\n  _ulogInfo(`[移动分镜组] clipId=${clipId}, direction=${direction}, ${currentIndex} -> ${targetIndex}`)\n\n  return NextResponse.json({ success: true })\n})\n\n/**\n * DELETE /api/novel-promotion/[projectId]/storyboard-group\n * 删除整个分镜组（Clip + Storyboard + 所有 Panels）\n */\nexport const DELETE = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const { searchParams } = new URL(request.url)\n  const storyboardId = searchParams.get('storyboardId')\n\n  if (!storyboardId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取 storyboard 及其关联的 clip\n  const storyboard = await prisma.novelPromotionStoryboard.findUnique({\n    where: { id: storyboardId },\n    include: {\n      panels: true,\n      clip: true\n    }\n  })\n\n  if (!storyboard) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 使用事务删除（Prisma 的 cascade 会自动处理关联删除，但我们显式删除以确保一致性）\n  await prisma.$transaction(async (tx) => {\n    // 1. 删除所有关联的 Panels\n    await tx.novelPromotionPanel.deleteMany({\n      where: { storyboardId }\n    })\n\n    // 2. 删除 Storyboard\n    await tx.novelPromotionStoryboard.delete({\n      where: { id: storyboardId }\n    })\n\n    // 3. 删除关联的 Clip（如果存在）\n    if (storyboard.clipId) {\n      await tx.novelPromotionClip.delete({\n        where: { id: storyboard.clipId }\n      })\n    }\n  })\n\n  _ulogInfo(`[删除分镜组] storyboardId=${storyboardId}, clipId=${storyboard.clipId}, panelCount=${storyboard.panels.length}`)\n\n  return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/storyboards/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { attachMediaFieldsToProject } from '@/lib/media/attach'\n\n/**\n * GET /api/novel-promotion/[projectId]/storyboards\n * 获取剧集的分镜数据（用于测试页面）\n */\nexport const GET = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    const { searchParams } = new URL(request.url)\n    const episodeId = searchParams.get('episodeId')\n\n    if (!episodeId) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 获取剧集的分镜数据\n    const storyboards = await prisma.novelPromotionStoryboard.findMany({\n        where: { episodeId },\n        include: {\n            clip: true,\n            panels: { orderBy: { panelIndex: 'asc' } }\n        },\n        orderBy: { createdAt: 'asc' }\n    })\n\n    const withMedia = await attachMediaFieldsToProject({ storyboards })\n    const processedStoryboards = withMedia.storyboards || storyboards\n\n    return NextResponse.json({ storyboards: processedStoryboards })\n})\n\n/**\n * PATCH /api/novel-promotion/[projectId]/storyboards\n * 清除指定 storyboard 的 lastError\n */\nexport const PATCH = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    const body = await request.json().catch(() => ({}))\n    const storyboardId = typeof body?.storyboardId === 'string' ? body.storyboardId : ''\n    if (!storyboardId) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    await prisma.novelPromotionStoryboard.update({\n        where: { id: storyboardId },\n        data: { lastError: null }})\n\n    return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/undo-regenerate/route.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\n/**\n * 撤回重新生成的图片，恢复到上一版本\n * POST /api/novel-promotion/[projectId]/undo-regenerate\n */\n\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { deleteObject } from '@/lib/storage'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface CharacterAppearanceRecord {\n    id: string\n    imageUrl: string | null\n    imageUrls: string | null\n    previousImageUrl: string | null\n    previousImageUrls: string | null\n    description: string | null\n    descriptions: unknown\n    previousDescription: string | null\n    previousDescriptions: unknown\n}\n\ninterface LocationImageRecord {\n    id: string\n    imageUrl: string | null\n    previousImageUrl: string | null\n    description: string | null\n    previousDescription: string | null\n}\n\ninterface LocationRecord {\n    images?: LocationImageRecord[]\n}\n\ninterface PanelRecord {\n    id: string\n    imageUrl: string | null\n    previousImageUrl: string | null\n}\n\ninterface UndoRegenerateTx {\n    characterAppearance: {\n        update(args: Record<string, unknown>): Promise<unknown>\n    }\n    locationImage: {\n        update(args: Record<string, unknown>): Promise<unknown>\n    }\n}\n\ninterface UndoRegenerateDb extends UndoRegenerateTx {\n    characterAppearance: {\n        findUnique(args: Record<string, unknown>): Promise<CharacterAppearanceRecord | null>\n        update(args: Record<string, unknown>): Promise<unknown>\n    }\n    novelPromotionLocation: {\n        findUnique(args: Record<string, unknown>): Promise<LocationRecord | null>\n    }\n    novelPromotionPanel: {\n        findUnique(args: Record<string, unknown>): Promise<PanelRecord | null>\n        update(args: Record<string, unknown>): Promise<unknown>\n    }\n    $transaction<T>(fn: (tx: UndoRegenerateTx) => Promise<T>): Promise<T>\n}\n\nexport const POST = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n    const db = prisma as unknown as UndoRegenerateDb\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    const { type, id, appearanceId } = await request.json()\n\n    // 🔒 UUID 格式验证辅助函数\n    const isValidUUID = (str: unknown): boolean => {\n        if (typeof str !== 'string') return false\n        const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n        return uuidRegex.test(str)\n    }\n\n    if (!type || !id) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    if (type === 'character') {\n        // 🔒 验证 appearanceId 是有效的 UUID\n        if (!appearanceId || !isValidUUID(appearanceId)) {\n            _ulogError(`[undo-regenerate] 收到无效的 appearanceId: ${appearanceId} (类型: ${typeof appearanceId})`)\n            throw new ApiError('INVALID_PARAMS')\n        }\n        return await undoCharacterRegenerate(db, appearanceId)\n    } else if (type === 'location') {\n        return await undoLocationRegenerate(db, id)\n    } else if (type === 'panel') {\n        return await undoPanelRegenerate(db, id)\n    }\n\n    throw new ApiError('INVALID_PARAMS')\n})\n\nasync function undoCharacterRegenerate(db: UndoRegenerateDb, appearanceId: string) {\n    // 使用 UUID 直接查询形象\n    const appearance = await db.characterAppearance.findUnique({\n        where: { id: appearanceId },\n        include: { character: true }\n    })\n\n    if (!appearance) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    const previousImageUrls = decodeImageUrlsFromDb(appearance.previousImageUrls, 'characterAppearance.previousImageUrls')\n\n    // 检查是否有上一版本\n    if (!appearance.previousImageUrl && previousImageUrls.length === 0) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 删除当前图片\n    const currentImageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls')\n    for (const key of currentImageUrls) {\n        if (key) {\n            try {\n                const storageKey = await resolveStorageKeyFromMediaValue(key)\n                if (storageKey) await deleteObject(storageKey)\n            } catch { }\n        }\n    }\n\n    const restoredImageUrls = previousImageUrls.length > 0\n        ? previousImageUrls\n        : (appearance.previousImageUrl ? [appearance.previousImageUrl] : [])\n\n    await db.$transaction(async (tx) => {\n        await tx.characterAppearance.update({\n            where: { id: appearance.id },\n            data: {\n                imageUrl: appearance.previousImageUrl || restoredImageUrls[0] || null,\n                imageUrls: encodeImageUrls(restoredImageUrls),\n                previousImageUrl: null,\n                previousImageUrls: encodeImageUrls([]),\n                selectedIndex: null,\n                // 🔥 同时恢复描述词\n                description: appearance.previousDescription ?? appearance.description,\n                descriptions: appearance.previousDescriptions ?? appearance.descriptions,\n                previousDescription: null,\n                previousDescriptions: null\n            }\n        })\n    })\n\n    return NextResponse.json({\n        success: true,\n        message: '已撤回到上一版本（图片和描述词）'\n    })\n}\n\nasync function undoLocationRegenerate(db: UndoRegenerateDb, locationId: string) {\n    // 获取场景和图片\n    const location = await db.novelPromotionLocation.findUnique({\n        where: { id: locationId },\n        include: { images: { orderBy: { imageIndex: 'asc' } } }\n    })\n\n    if (!location) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 检查是否有上一版本\n    const hasPrevious = location.images?.some((img) => img.previousImageUrl)\n    if (!hasPrevious) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 删除当前图片并恢复上一版本\n    await db.$transaction(async (tx) => {\n        for (const img of location.images || []) {\n            if (img.previousImageUrl) {\n                // 删除当前图片\n                if (img.imageUrl) {\n                    try {\n                        const storageKey = await resolveStorageKeyFromMediaValue(img.imageUrl)\n                        if (storageKey) await deleteObject(storageKey)\n                    } catch { }\n                }\n                // 恢复上一版本（图片 + 描述词）\n                await tx.locationImage.update({\n                    where: { id: img.id },\n                    data: {\n                        imageUrl: img.previousImageUrl,\n                        previousImageUrl: null,\n                        // 🔥 同时恢复描述词\n                        description: img.previousDescription ?? img.description,\n                        previousDescription: null\n                    }\n                })\n            }\n        }\n    })\n\n    return NextResponse.json({\n        success: true,\n        message: '已撤回到上一版本（图片和描述词）'\n    })\n}\n\n/**\n * 撤回 Panel 镜头图片到上一版本\n */\nasync function undoPanelRegenerate(db: UndoRegenerateDb, panelId: string) {\n    // 获取镜头\n    const panel = await db.novelPromotionPanel.findUnique({\n        where: { id: panelId }\n    })\n\n    if (!panel) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 检查是否有上一版本\n    if (!panel.previousImageUrl) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 删除当前图片（如果存在）\n    if (panel.imageUrl) {\n        try {\n            const storageKey = await resolveStorageKeyFromMediaValue(panel.imageUrl)\n            if (storageKey) await deleteObject(storageKey)\n        } catch { }\n    }\n\n    // 恢复上一版本\n    await db.novelPromotionPanel.update({\n        where: { id: panelId },\n        data: {\n            imageUrl: panel.previousImageUrl,\n            previousImageUrl: null,\n            candidateImages: null  // 清空候选图片\n        }\n    })\n\n    return NextResponse.json({\n        success: true,\n        message: '镜头图片已撤回到上一版本'\n    })\n}\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/update-appearance/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { characterId, appearanceId, newDescription, descriptionIndex } = body\n\n  if (!characterId || !appearanceId || !newDescription) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 使用 UUID 直接查询\n  const appearance = await prisma.characterAppearance.findUnique({\n    where: { id: appearanceId }\n  })\n\n  if (!appearance) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const trimmedDescription = newDescription.trim()\n\n  // 解析 descriptions JSON\n  let descriptions: string[] = []\n  if (appearance.descriptions) {\n    try { descriptions = JSON.parse(appearance.descriptions) } catch { }\n  }\n  if (descriptions.length === 0) {\n    descriptions = [appearance.description || '']\n  }\n\n  // 更新指定索引的描述\n  if (descriptionIndex !== undefined && descriptionIndex !== null) {\n    descriptions[descriptionIndex] = trimmedDescription\n  } else {\n    descriptions[0] = trimmedDescription\n  }\n\n  // 直接更新独立表记录\n  await prisma.characterAppearance.update({\n    where: { id: appearance.id },\n    data: {\n      descriptions: JSON.stringify(descriptions),\n      description: descriptions[0]\n    }\n  })\n\n  return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/update-asset-label/route.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { uploadObject, getSignedUrl, toFetchableUrl, generateUniqueKey } from '@/lib/storage'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport sharp from 'sharp'\nimport { initializeFonts, createLabelSVG } from '@/lib/fonts'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * POST /api/novel-promotion/[projectId]/update-asset-label\n * 更新资产图片上的黑边标识符（修改名字后调用）\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 初始化字体（在 Vercel 环境中需要）\n  await initializeFonts()\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { type, id, newName, appearanceIndex } = body\n  // type: 'character' | 'location'\n  // id: characterId 或 locationId\n  // newName: 新名字\n  // appearanceIndex: 角色形象索引（仅角色需要）\n\n  if (!type || !id || !newName) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (type === 'character') {\n    // 获取角色的所有形象\n    const character = await prisma.novelPromotionCharacter.findUnique({\n      where: { id: id },\n      include: { appearances: true }\n    })\n\n    if (!character) {\n      throw new ApiError('NOT_FOUND')\n    }\n\n    // 更新每个形象的图片标签\n    const updatePromises = character.appearances.map(async (appearance) => {\n      // 如果指定了 appearanceIndex，只更新该形象\n      if (appearanceIndex !== undefined && appearance.appearanceIndex !== appearanceIndex) {\n        return null\n      }\n\n      // 获取图片 URLs\n      let imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls')\n      if (imageUrls.length === 0 && appearance.imageUrl) {\n        imageUrls = [appearance.imageUrl]\n      }\n\n      if (imageUrls.length === 0) return null\n\n      // 更新每张图片的标签\n      const newLabelText = `${newName} - ${appearance.changeReason}`\n      const newImageUrls: string[] = await Promise.all(\n        imageUrls.map(async (url, i) => {\n          if (!url) return ''\n          try {\n            return await updateImageLabel(url, newLabelText)\n          } catch (e) {\n            _ulogError(`Failed to update label for image ${i}:`, e)\n            return url // 保留原 URL\n          }\n        })\n      )\n\n      const firstUrl = newImageUrls.find((u) => !!u) || null\n\n      // 更新数据库\n      await prisma.characterAppearance.update({\n        where: { id: appearance.id },\n        data: {\n          imageUrls: encodeImageUrls(newImageUrls),\n          imageUrl: firstUrl\n        }\n      })\n\n      return { appearanceIndex: appearance.appearanceIndex, imageUrls: newImageUrls }\n    })\n\n    const results = await Promise.all(updatePromises)\n    return NextResponse.json({ success: true, results: results.filter(r => r !== null) })\n\n  } else if (type === 'location') {\n    // 获取场景\n    const location = await prisma.novelPromotionLocation.findUnique({\n      where: { id: id },\n      include: { images: true }\n    })\n\n    if (!location) {\n      throw new ApiError('NOT_FOUND')\n    }\n\n    // 更新每张图片的标签\n    const updatePromises = location.images.map(async (image) => {\n      if (!image.imageUrl) return null\n\n      const newLabelText = newName\n      try {\n        const newImageUrl = await updateImageLabel(\n          image.imageUrl,\n          newLabelText\n        )\n\n        // 更新数据库\n        await prisma.locationImage.update({\n          where: { id: image.id },\n          data: { imageUrl: newImageUrl }\n        })\n\n        return { imageIndex: image.imageIndex, imageUrl: newImageUrl }\n      } catch (e) {\n        _ulogError(`Failed to update label for location image ${image.imageIndex}:`, e)\n        return null\n      }\n    })\n\n    const results = await Promise.all(updatePromises)\n    return NextResponse.json({ success: true, results: results.filter(r => r !== null) })\n  }\n\n  throw new ApiError('INVALID_PARAMS')\n})\n\n/**\n * 更新图片的黑边标签\n * 🔥 生成新的 COS key 上传，使 URL 发生变化，浏览器缓存自动失效，前端能看到新标签\n */\nasync function updateImageLabel(imageUrl: string, newLabelText: string): Promise<string> {\n  const originalKey = await resolveStorageKeyFromMediaValue(imageUrl)\n  if (!originalKey) {\n    throw new Error(`无法归一化媒体 key: ${imageUrl}`)\n  }\n  const signedUrl = getSignedUrl(originalKey, 3600)\n\n  // 下载图片\n  const response = await fetch(toFetchableUrl(signedUrl))\n  if (!response.ok) {\n    throw new Error(`Failed to download image: ${response.status}`)\n  }\n  const buffer = Buffer.from(await response.arrayBuffer())\n\n  // 获取图片元数据\n  const meta = await sharp(buffer).metadata()\n  const w = meta.width || 2160\n  const h = meta.height || 2160\n\n  // 计算标签条高度（与生成时一致：高度的 4%）\n  const fontSize = Math.floor(h * 0.04)\n  const pad = Math.floor(fontSize * 0.5)\n  const barH = fontSize + pad * 2\n\n  // 裁剪掉顶部的旧标签条\n  const croppedBuffer = await sharp(buffer)\n    .extract({ left: 0, top: barH, width: w, height: h - barH })\n    .toBuffer()\n\n  // 创建新的 SVG 标签条\n  const svg = await createLabelSVG(w, barH, fontSize, pad, newLabelText)\n\n  // 添加新标签条到图片顶部\n  const processed = await sharp(croppedBuffer)\n    .extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })\n    .composite([{ input: svg, top: 0, left: 0 }])\n    .jpeg({ quality: 90, mozjpeg: true })\n    .toBuffer()\n\n  // 🔥 生成新 key 上传，使图片 URL 发生变化，强制浏览器绕过缓存，确保前端能看到新标签\n  const newKey = generateUniqueKey('labeled-rename', 'jpg')\n  await uploadObject(processed, newKey)\n  return newKey\n}\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/update-location/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { removeLocationPromptSuffix } from '@/lib/constants'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { locationId, imageIndex = 0, newDescription } = body\n\n  if (!locationId || !newDescription) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 更新场景描述（移除可能存在的系统后缀，后缀只在生成图片时添加）\n  const cleanDescription = removeLocationPromptSuffix(newDescription.trim())\n\n  // 更新 LocationImage 表中对应的记录\n  const locationImage = await prisma.locationImage.findFirst({\n    where: { locationId, imageIndex }\n  })\n\n  if (!locationImage) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  await prisma.locationImage.update({\n    where: { id: locationImage.id },\n    data: { description: cleanDescription }\n  })\n\n  return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/update-prompt/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const { shotId, field, value } = await request.json()\n\n  // 验证字段\n  if (field !== 'imagePrompt' && field !== 'videoPrompt') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 更新shot\n  const updatedShot = await prisma.novelPromotionShot.update({\n    where: { id: shotId },\n    data: { [field]: value }\n  })\n\n  return NextResponse.json({ success: true, shot: updatedShot })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/upload-asset-image/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { uploadObject, generateUniqueKey } from '@/lib/storage'\nimport sharp from 'sharp'\nimport { initializeFonts, createLabelSVG } from '@/lib/fonts'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface CharacterAppearanceRecord {\n  id: string\n  imageUrls: string | null\n  selectedIndex: number | null\n}\n\ninterface LocationImageRecord {\n  id: string\n  imageIndex: number\n}\n\ninterface LocationRecord {\n  selectedImageId: string | null\n  images?: LocationImageRecord[]\n}\n\ninterface UploadAssetImageDb {\n  characterAppearance: {\n    findUnique(args: Record<string, unknown>): Promise<CharacterAppearanceRecord | null>\n    update(args: Record<string, unknown>): Promise<unknown>\n  }\n  novelPromotionLocation: {\n    findUnique(args: Record<string, unknown>): Promise<LocationRecord | null>\n    update(args: Record<string, unknown>): Promise<unknown>\n  }\n  locationImage: {\n    update(args: Record<string, unknown>): Promise<{ id: string }>\n    create(args: Record<string, unknown>): Promise<{ id: string }>\n  }\n}\n\n/**\n * POST /api/novel-promotion/[projectId]/upload-asset-image\n * 上传用户自定义图片作为角色或场景资产\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n  const db = prisma as unknown as UploadAssetImageDb\n\n  // 初始化字体（在 Vercel 环境中需要）\n  await initializeFonts()\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  // 解析表单数据\n  const formData = await request.formData()\n  const file = formData.get('file') as File\n  const type = formData.get('type') as string // 'character' | 'location'\n  const id = formData.get('id') as string // characterId 或 locationId\n  const appearanceId = formData.get('appearanceId') as string | null  // UUID\n  const imageIndex = formData.get('imageIndex') as string | null\n  const labelText = formData.get('labelText') as string // 文字标识符\n\n  if (!file || !type || !id || !labelText) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 读取文件\n  const arrayBuffer = await file.arrayBuffer()\n  const buffer = Buffer.from(arrayBuffer)\n\n  // 添加文字标识符\n  const meta = await sharp(buffer).metadata()\n  const w = meta.width || 2160\n  const h = meta.height || 2160\n  const fontSize = Math.floor(h * 0.04)\n  const pad = Math.floor(fontSize * 0.5)\n  const barH = fontSize + pad * 2\n\n  // 创建SVG文字条\n  const svg = await createLabelSVG(w, barH, fontSize, pad, labelText)\n\n  // 添加文字条到图片顶部\n  const processed = await sharp(buffer)\n    .extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })\n    .composite([{ input: svg, top: 0, left: 0 }])\n    .jpeg({ quality: 90, mozjpeg: true })\n    .toBuffer()\n\n  // 生成唯一key并上传\n  const keyPrefix = type === 'character'\n    ? `char-${id}-${appearanceId}-upload`\n    : `loc-${id}-upload`\n  const key = generateUniqueKey(keyPrefix, 'jpg')\n  await uploadObject(processed, key)\n\n  // 更新数据库\n  if (type === 'character' && appearanceId !== null) {\n    // 更新角色形象图片 - 使用 UUID 直接查询\n    const appearance = await db.characterAppearance.findUnique({\n      where: { id: appearanceId }\n    })\n\n    if (!appearance) {\n      throw new ApiError('NOT_FOUND')\n    }\n\n    // 解析现有图片数组\n    const imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls')\n\n    // 如果指定了imageIndex，替换对应位置的图片\n    const targetIndex = imageIndex !== null ? parseInt(imageIndex) : imageUrls.length\n\n    // 确保数组足够大\n    while (imageUrls.length <= targetIndex) {\n      imageUrls.push('')\n    }\n\n    imageUrls[targetIndex] = key\n\n    // 计算是否需要同步更新 imageUrl\n    // 当上传的图片是选中的图片时，或者是第一张图片且没有选中任何图片时\n    const selectedIndex = appearance.selectedIndex\n    const shouldUpdateImageUrl =\n      selectedIndex === targetIndex ||  // 上传的是选中的图片\n      (selectedIndex === null && targetIndex === 0) ||  // 没有选中任何图片，上传的是第一张\n      imageUrls.filter(u => !!u).length === 1  // 只有一张有效图片\n\n    const updateData: Record<string, unknown> = {\n      imageUrls: encodeImageUrls(imageUrls)\n    }\n\n    if (shouldUpdateImageUrl) {\n      updateData.imageUrl = key\n    }\n\n    // 更新数据库\n    await db.characterAppearance.update({\n      where: { id: appearance.id },\n      data: updateData\n    })\n\n    return NextResponse.json({\n      success: true,\n      imageKey: key,\n      imageIndex: targetIndex\n    })\n\n  } else if (type === 'location') {\n    // 更新场景图片\n    const location = await db.novelPromotionLocation.findUnique({\n      where: { id },\n      include: { images: { orderBy: { imageIndex: 'asc' } } }\n    })\n\n    if (!location) {\n      throw new ApiError('NOT_FOUND')\n    }\n\n    // 如果指定了imageIndex，更新对应的图片记录\n    if (imageIndex !== null) {\n      const targetImageIndex = parseInt(imageIndex)\n      const existingImage = location.images?.find((img) => img.imageIndex === targetImageIndex)\n\n      if (existingImage) {\n        const updated = await db.locationImage.update({\n          where: { id: existingImage.id },\n          data: { imageUrl: key }\n        })\n        if (!location.selectedImageId) {\n          await prisma.novelPromotionLocation.update({\n            where: { id },\n            data: { selectedImageId: updated.id }\n          })\n        }\n      } else {\n        const created = await db.locationImage.create({\n          data: {\n            locationId: id,\n            imageIndex: targetImageIndex,\n            imageUrl: key,\n            description: labelText,\n            isSelected: targetImageIndex === 0\n          }\n        })\n        if (!location.selectedImageId) {\n          await prisma.novelPromotionLocation.update({\n            where: { id },\n            data: { selectedImageId: created.id }\n          })\n        }\n      }\n\n      return NextResponse.json({\n        success: true,\n        imageKey: key,\n        imageIndex: targetImageIndex\n      })\n    } else {\n      // 创建新的图片记录\n      const maxIndex = location.images?.length || 0\n      const created = await db.locationImage.create({\n        data: {\n          locationId: id,\n          imageIndex: maxIndex,\n          imageUrl: key,\n          description: labelText,\n          isSelected: maxIndex === 0\n        }\n      })\n      if (!location.selectedImageId) {\n        await prisma.novelPromotionLocation.update({\n          where: { id },\n          data: { selectedImageId: created.id }\n        })\n      }\n\n      return NextResponse.json({\n        success: true,\n        imageKey: key,\n        imageIndex: maxIndex\n      })\n    }\n  }\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/video-proxy/route.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { NextRequest } from 'next/server'\nimport { getSignedUrl, toFetchableUrl } from '@/lib/storage'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * 代理下载单个视频文件\n * 用于解决 COS 跨域下载问题\n */\nexport const GET = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n    const { searchParams } = new URL(request.url)\n    const videoKey = searchParams.get('key')\n\n    if (!videoKey) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n\n    // 生成签名 URL 并下载\n    let fetchUrl: string\n    if (videoKey.startsWith('http://') || videoKey.startsWith('https://')) {\n        fetchUrl = videoKey\n    } else {\n        fetchUrl = toFetchableUrl(getSignedUrl(videoKey, 3600))\n    }\n\n    _ulogInfo(`[视频代理] 下载: ${fetchUrl.substring(0, 100)}...`)\n\n    const response = await fetch(fetchUrl)\n    if (!response.ok) {\n        throw new Error(`Failed to fetch video: ${response.statusText}`)\n    }\n\n    // 获取内容类型和长度\n    const contentType = response.headers.get('content-type') || 'video/mp4'\n    const contentLength = response.headers.get('content-length')\n\n    // 流式返回视频数据\n    const headers: HeadersInit = {\n        'Content-Type': contentType,\n        'Cache-Control': 'no-cache'\n    }\n    if (contentLength) {\n        headers['Content-Length'] = contentLength\n    }\n\n    return new Response(response.body, { headers })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/video-urls/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\ninterface PanelData {\n    panelIndex: number | null\n    description: string | null\n    videoUrl: string | null\n    lipSyncVideoUrl: string | null\n}\n\ninterface StoryboardData {\n    id: string\n    clipId: string\n    panels?: PanelData[]\n}\n\ninterface ClipData {\n    id: string\n}\n\ninterface EpisodeData {\n    storyboards?: StoryboardData[]\n    clips?: ClipData[]\n}\n\n/**\n * 获取视频下载链接列表（不在服务端下载打包）\n * 适用于客户端直接下载场景，避免大文件传输问题\n */\nexport const POST = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n\n    // 解析请求体\n    const body = await request.json()\n    const { episodeId, panelPreferences } = body as {\n        episodeId?: string\n        panelPreferences?: Record<string, boolean>  // key: panelKey, value: true=口型同步, false=原始\n    }\n\n    // 🔐 统一权限验证\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n    const project = authResult.project\n\n    // 根据是否指定 episodeId 来获取数据\n    let episodes: EpisodeData[] = []\n\n    if (episodeId) {\n        // 只获取指定剧集的数据\n        const episode = await prisma.novelPromotionEpisode.findUnique({\n            where: { id: episodeId },\n            include: {\n                storyboards: {\n                    include: {\n                        panels: { orderBy: { panelIndex: 'asc' } }\n                    },\n                    orderBy: { createdAt: 'asc' }\n                },\n                clips: {\n                    orderBy: { createdAt: 'asc' }\n                }\n            }\n        })\n        if (episode) {\n            episodes = [episode]\n        }\n    } else {\n        // 获取所有剧集的数据\n        const npData = await prisma.novelPromotionProject.findFirst({\n            where: { projectId },\n            include: {\n                episodes: {\n                    include: {\n                        storyboards: {\n                            include: {\n                                panels: { orderBy: { panelIndex: 'asc' } }\n                            },\n                            orderBy: { createdAt: 'asc' }\n                        },\n                        clips: {\n                            orderBy: { createdAt: 'asc' }\n                        }\n                    }\n                }\n            }\n        })\n        episodes = npData?.episodes || []\n    }\n\n    if (episodes.length === 0) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 收集所有有视频的 panel\n    interface VideoItem {\n        fileName: string\n        videoUrl: string  // 签名后的完整URL\n        clipIndex: number\n        panelIndex: number\n    }\n\n    // 从 episodes 中获取所有 storyboards 和 clips\n    const allStoryboards: StoryboardData[] = []\n    const allClips: ClipData[] = []\n    for (const episode of episodes) {\n        allStoryboards.push(...(episode.storyboards || []))\n        allClips.push(...(episode.clips || []))\n    }\n\n    interface VideoCandidate extends VideoItem {\n        videoKey: string\n        desc: string\n    }\n    const videoCandidates: VideoCandidate[] = []\n\n    // 遍历所有 storyboard 和 panel\n    for (const storyboard of allStoryboards) {\n        const clipIndex = allClips.findIndex((clip) => clip.id === storyboard.clipId)\n\n        const panels = storyboard.panels || []\n        for (const panel of panels) {\n            // 构建 panelKey 用于查找偏好\n            const panelKey = `${storyboard.id}-${panel.panelIndex || 0}`\n            const preferLipSync = panelPreferences?.[panelKey] ?? true\n\n            // 根据用户偏好选择视频类型\n            let videoKey: string | null = null\n\n            if (preferLipSync) {\n                videoKey = panel.lipSyncVideoUrl || panel.videoUrl\n            } else {\n                videoKey = panel.videoUrl || panel.lipSyncVideoUrl\n            }\n\n            if (videoKey) {\n                // 文件名使用描述，清理非法字符\n                const safeDesc = (panel.description || '镜头').slice(0, 50).replace(/[\\\\/:*?\"<>|]/g, '_')\n\n                videoCandidates.push({\n                    fileName: '',\n                    videoUrl: '',\n                    clipIndex: clipIndex >= 0 ? clipIndex : 999,\n                    panelIndex: panel.panelIndex || 0,\n                    videoKey,\n                    desc: safeDesc})\n            }\n        }\n    }\n\n    // 按 clipIndex 和 panelIndex 排序\n    videoCandidates.sort((a, b) => {\n        if (a.clipIndex !== b.clipIndex) {\n            return a.clipIndex - b.clipIndex\n        }\n        return a.panelIndex - b.panelIndex\n    })\n\n    // 重新分配连续的全局索引并生成代理URL\n    const result = videoCandidates.map((video, idx) => {\n        const videoKey = video.videoKey\n        const safeDesc = video.desc\n        const index = idx + 1\n        const fileName = `${String(index).padStart(3, '0')}_${safeDesc}.mp4`\n\n        // 使用代理 URL，避免 CORS 问题\n        const proxyUrl = `/api/novel-promotion/${projectId}/video-proxy?key=${encodeURIComponent(videoKey)}`\n\n        return {\n            index,\n            fileName,\n            videoUrl: proxyUrl\n        }\n    })\n\n    if (result.length === 0) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    return NextResponse.json({\n        projectName: project.name,\n        videos: result\n    })\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/voice-analyze/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'\n\n/**\n * POST /api/novel-promotion/[projectId]/voice-analyze\n * 台词分析（任务化）\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n  const body = await request.json().catch(() => ({}))\n  const episodeId = typeof body?.episodeId === 'string' ? body.episodeId.trim() : ''\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session, project } = authResult\n\n  if (project.mode !== 'novel-promotion') {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const asyncTaskResponse = await maybeSubmitLLMTask({\n    request,\n    userId: session.user.id,\n    projectId,\n    episodeId,\n    type: TASK_TYPE.VOICE_ANALYZE,\n    targetType: 'NovelPromotionEpisode',\n    targetId: episodeId,\n    routePath: `/api/novel-promotion/${projectId}/voice-analyze`,\n    body: {\n      ...body,\n      displayMode: 'detail',\n    },\n    dedupeKey: `voice_analyze:${episodeId}`,\n    priority: 1,\n  })\n  if (asyncTaskResponse) return asyncTaskResponse\n\n  throw new ApiError('INVALID_PARAMS')\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/voice-design/route.ts",
    "content": "import { createHash } from 'crypto'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { validatePreviewText, validateVoicePrompt } from '@/lib/providers/bailian/voice-design'\n\n/**\n * 声音设计 API\n * POST /api/novel-promotion/[projectId]/voice-design\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = (await request.json().catch(() => ({}))) as Record<string, unknown>\n  const locale = resolveRequiredTaskLocale(request, body)\n  const voicePrompt = typeof body.voicePrompt === 'string' ? body.voicePrompt.trim() : ''\n  const previewText = typeof body.previewText === 'string' ? body.previewText.trim() : ''\n  const preferredName = typeof body.preferredName === 'string' && body.preferredName.trim()\n    ? body.preferredName.trim()\n    : 'custom_voice'\n  const language = body.language === 'en' ? 'en' : 'zh'\n\n  const promptValidation = validateVoicePrompt(voicePrompt)\n  if (!promptValidation.valid) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  const textValidation = validatePreviewText(previewText)\n  if (!textValidation.valid) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const digest = createHash('sha1')\n    .update(`${session.user.id}:${projectId}:${voicePrompt}:${previewText}:${preferredName}:${language}`)\n    .digest('hex')\n    .slice(0, 16)\n\n  const payload = {\n    voicePrompt,\n    previewText,\n    preferredName,\n    language,\n    displayMode: 'detail' as const}\n\n  const result = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId,\n    type: TASK_TYPE.VOICE_DESIGN,\n    targetType: 'NovelPromotionProject',\n    targetId: projectId,\n    payload,\n    dedupeKey: `${TASK_TYPE.VOICE_DESIGN}:${digest}`,\n    billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_DESIGN, payload)})\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/voice-generate/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing'\nimport { estimateVoiceLineMaxSeconds } from '@/lib/voice/generate-voice-line'\nimport { hasVoiceLineAudioOutput } from '@/lib/task/has-output'\nimport { withTaskUiPayload } from '@/lib/task/ui-payload'\nimport { parseModelKeyStrict } from '@/lib/model-config-contract'\nimport { getProviderKey, resolveModelSelectionOrSingle } from '@/lib/api-config'\nimport {\n  hasVoiceBindingForProvider,\n  parseSpeakerVoiceMap,\n  type CharacterVoiceFields,\n  type SpeakerVoiceMap,\n} from '@/lib/voice/provider-voice-binding'\n\ntype VoiceLineRow = {\n  id: string\n  speaker: string\n  content: string\n}\n\ntype CharacterRow = CharacterVoiceFields & {\n  name: string\n}\n\ntype VoiceBindingValidationResult =\n  | { ok: true }\n  | { ok: false; message: string }\n\nfunction matchCharacterBySpeaker(speaker: string, characters: CharacterRow[]) {\n  const normalizedSpeaker = speaker.trim().toLowerCase()\n  return characters.find((character) => character.name.trim().toLowerCase() === normalizedSpeaker) || null\n}\n\nfunction validateSpeakerVoiceForProvider(\n  speaker: string,\n  characters: CharacterRow[],\n  speakerVoices: SpeakerVoiceMap,\n  providerKey: string,\n): VoiceBindingValidationResult {\n  const character = matchCharacterBySpeaker(speaker, characters)\n  const speakerVoice = speakerVoices[speaker]\n\n  if (hasVoiceBindingForProvider({\n    providerKey,\n    character,\n    speakerVoice,\n  })) {\n    return { ok: true }\n  }\n\n  if (providerKey === 'bailian') {\n    const hasUploadedReference =\n      !!character?.customVoiceUrl ||\n      (speakerVoice?.provider === 'fal' && !!speakerVoice.audioUrl)\n    if (hasUploadedReference) {\n      return {\n        ok: false,\n        message: '无音色ID，QwenTTS 必须使用 AI 设计音色',\n      }\n    }\n    return {\n      ok: false,\n      message: '请先为该发言人绑定百炼音色',\n    }\n  }\n\n  return {\n    ok: false,\n    message: '请先为该发言人设置参考音频',\n  }\n}\n\nfunction hasSpeakerVoiceForProvider(\n  speaker: string,\n  characters: CharacterRow[],\n  speakerVoices: SpeakerVoiceMap,\n  providerKey: string,\n): boolean {\n  const character = matchCharacterBySpeaker(speaker, characters)\n  const speakerVoice = speakerVoices[speaker]\n  return hasVoiceBindingForProvider({\n    providerKey,\n    character,\n    speakerVoice,\n  })\n}\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> },\n) => {\n  const { projectId } = await context.params\n\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json().catch(() => null)\n  const locale = resolveRequiredTaskLocale(request, body)\n  const episodeId = typeof body?.episodeId === 'string' ? body.episodeId : ''\n  const lineId = typeof body?.lineId === 'string' ? body.lineId : ''\n  const requestedAudioModel = typeof body?.audioModel === 'string' ? body.audioModel.trim() : ''\n  const all = body?.all === true\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (!all && !lineId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (requestedAudioModel && !parseModelKeyStrict(requestedAudioModel)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_INVALID',\n      field: 'audioModel'})\n  }\n\n  const pref = await prisma.userPreference.findUnique({\n    where: { userId: session.user.id },\n    select: { audioModel: true },\n  })\n  const preferredAudioModel = typeof pref?.audioModel === 'string' ? pref.audioModel.trim() : ''\n  if (preferredAudioModel && !parseModelKeyStrict(preferredAudioModel)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_INVALID',\n      field: 'audioModel'})\n  }\n  const projectData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    select: {\n      id: true,\n      audioModel: true,\n      characters: {\n        select: {\n          name: true,\n          customVoiceUrl: true,\n          voiceId: true,\n        },\n      },\n    },\n  })\n  if (!projectData) {\n    throw new ApiError('NOT_FOUND')\n  }\n  const projectAudioModel = typeof projectData.audioModel === 'string' ? projectData.audioModel.trim() : ''\n  if (projectAudioModel && !parseModelKeyStrict(projectAudioModel)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_INVALID',\n      field: 'audioModel'})\n  }\n  const resolvedAudioModel = requestedAudioModel || projectAudioModel || preferredAudioModel\n  const selectedResolvedAudioModel = await resolveModelSelectionOrSingle(\n    session.user.id,\n    resolvedAudioModel || null,\n    'audio',\n  )\n  const selectedProviderKey = getProviderKey(selectedResolvedAudioModel.provider).toLowerCase()\n\n  const episode = await prisma.novelPromotionEpisode.findFirst({\n    where: {\n      id: episodeId,\n      novelPromotionProjectId: projectData.id},\n    select: {\n      id: true,\n      speakerVoices: true}})\n  if (!episode) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const speakerVoices = parseSpeakerVoiceMap(episode.speakerVoices)\n  const characters = projectData.characters || []\n\n  let voiceLines: VoiceLineRow[] = []\n  if (all) {\n    const allLines = await prisma.novelPromotionVoiceLine.findMany({\n      where: {\n        episodeId,\n        audioUrl: null},\n      orderBy: { lineIndex: 'asc' },\n      select: {\n        id: true,\n        speaker: true,\n        content: true}})\n    voiceLines = allLines.filter((line) =>\n      hasSpeakerVoiceForProvider(line.speaker, characters, speakerVoices, selectedProviderKey),\n    )\n  } else {\n    const line = await prisma.novelPromotionVoiceLine.findFirst({\n      where: {\n        id: lineId,\n        episodeId},\n      select: {\n        id: true,\n        speaker: true,\n        content: true}})\n    if (!line) {\n      throw new ApiError('NOT_FOUND')\n    }\n    const validation = validateSpeakerVoiceForProvider(\n      line.speaker,\n      characters,\n      speakerVoices,\n      selectedProviderKey,\n    )\n    if (!validation.ok) {\n      throw new ApiError('INVALID_PARAMS', {\n        message: validation.message,\n      })\n    }\n    voiceLines = [line]\n  }\n\n  if (voiceLines.length === 0) {\n    if (all) {\n      const firstLineWithoutBinding = await prisma.novelPromotionVoiceLine.findFirst({\n        where: {\n          episodeId,\n          audioUrl: null,\n        },\n        orderBy: { lineIndex: 'asc' },\n        select: {\n          speaker: true,\n        },\n      })\n      const validation = firstLineWithoutBinding\n        ? validateSpeakerVoiceForProvider(\n          firstLineWithoutBinding.speaker,\n          characters,\n          speakerVoices,\n          selectedProviderKey,\n        )\n        : { ok: false as const, message: '没有需要生成的台词' }\n      return NextResponse.json({\n        success: true,\n        async: true,\n        results: [],\n        taskIds: [],\n        total: 0,\n        ...(validation.ok ? {} : { error: validation.message }),\n      })\n    }\n    throw new ApiError('INVALID_PARAMS', {\n      message: '没有需要生成的台词',\n    })\n  }\n\n  const results = await Promise.all(\n    voiceLines.map(async (line) => {\n      const payload = {\n        episodeId,\n        lineId: line.id,\n        maxSeconds: estimateVoiceLineMaxSeconds(line.content),\n        audioModel: selectedResolvedAudioModel.modelKey}\n      const result = await submitTask({\n        userId: session.user.id,\n    locale,\n        requestId: getRequestId(request),\n        projectId,\n        episodeId,\n        type: TASK_TYPE.VOICE_LINE,\n        targetType: 'NovelPromotionVoiceLine',\n        targetId: line.id,\n        payload: withTaskUiPayload(payload, {\n          hasOutputAtStart: await hasVoiceLineAudioOutput(line.id)}),\n        dedupeKey: `voice_line:${line.id}`,\n        billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, payload)})\n\n      return {\n        lineId: line.id,\n        taskId: result.taskId}\n    }),\n  )\n\n  if (all) {\n    return NextResponse.json({\n      success: true,\n      async: true,\n      results,\n      taskIds: results.map((item) => item.taskId),\n      total: results.length})\n  }\n\n  return NextResponse.json({\n    success: true,\n    async: true,\n    taskId: results[0].taskId})\n})\n"
  },
  {
    "path": "src/app/api/novel-promotion/[projectId]/voice-lines/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { Prisma } from '@prisma/client'\nimport { prisma } from '@/lib/prisma'\nimport { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { resolveMediaRef, resolveMediaRefFromLegacyValue } from '@/lib/media/service'\n\nasync function resolveMatchedPanelData(\n  matchedPanelId: string | null | undefined,\n  expectedEpisodeId?: string\n) {\n  if (matchedPanelId === undefined) {\n    return null\n  }\n\n  if (matchedPanelId === null) {\n    return {\n      matchedPanelId: null,\n      matchedStoryboardId: null,\n      matchedPanelIndex: null\n    }\n  }\n\n  const panel = await prisma.novelPromotionPanel.findUnique({\n    where: { id: matchedPanelId },\n    select: {\n      id: true,\n      storyboardId: true,\n      panelIndex: true,\n      storyboard: {\n        select: {\n          episodeId: true\n        }\n      }\n    }\n  })\n\n  if (!panel) {\n    throw new ApiError('NOT_FOUND')\n  }\n  if (expectedEpisodeId && panel.storyboard.episodeId !== expectedEpisodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  return {\n    matchedPanelId: panel.id,\n    matchedStoryboardId: panel.storyboardId,\n    matchedPanelIndex: panel.panelIndex\n  }\n}\n\nasync function withVoiceLineMedia<T extends Record<string, unknown>>(line: T) {\n  const audioMedia = await resolveMediaRef(line.audioMediaId, line.audioUrl)\n  const matchedPanel = line.matchedPanel as\n    | {\n      storyboardId?: string | null\n      panelIndex?: number | null\n    }\n    | null\n    | undefined\n  return {\n    ...line,\n    media: audioMedia,\n    audioMedia,\n    audioUrl: audioMedia?.url || line.audioUrl || null,\n    updatedAt:\n      line.updatedAt instanceof Date\n        ? line.updatedAt.toISOString()\n        : typeof line.updatedAt === 'string'\n          ? line.updatedAt\n          : null,\n    matchedStoryboardId: matchedPanel?.storyboardId ?? line.matchedStoryboardId,\n    matchedPanelIndex: matchedPanel?.panelIndex ?? line.matchedPanelIndex}\n}\n\n/**\n * GET /api/novel-promotion/[projectId]/voice-lines?episodeId=xxx\n * 获取剧集的台词列表\n */\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const { searchParams } = new URL(request.url)\n  const episodeId = searchParams.get('episodeId')\n  const speakersOnly = searchParams.get('speakersOnly')\n\n  if (speakersOnly === '1') {\n    const novelProject = await prisma.novelPromotionProject.findUnique({\n      where: { projectId },\n      select: { id: true }\n    })\n    if (!novelProject) {\n      throw new ApiError('NOT_FOUND')\n    }\n\n    const speakerRows = await prisma.novelPromotionVoiceLine.findMany({\n      where: {\n        episode: {\n          novelPromotionProjectId: novelProject.id\n        }\n      },\n      select: { speaker: true },\n      distinct: ['speaker'],\n      orderBy: { speaker: 'asc' }\n    })\n\n    return NextResponse.json({\n      speakers: speakerRows.map(item => item.speaker).filter(Boolean)\n    })\n  }\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取台词列表（包含匹配的 Panel 信息）\n  const voiceLines = await prisma.novelPromotionVoiceLine.findMany({\n    where: { episodeId },\n    orderBy: { lineIndex: 'asc' },\n    include: {\n      matchedPanel: {\n        select: {\n          id: true,\n          storyboardId: true,\n          panelIndex: true\n        }\n      }\n    }\n  })\n\n  // 转换为稳定媒体 URL，并添加兼容字段\n  const voiceLinesWithUrls = await Promise.all(voiceLines.map(withVoiceLineMedia))\n\n  // 统计发言人\n  const speakerStats: Record<string, number> = {}\n  for (const line of voiceLines) {\n    speakerStats[line.speaker] = (speakerStats[line.speaker] || 0) + 1\n  }\n\n  return NextResponse.json({\n    voiceLines: voiceLinesWithUrls,\n    count: voiceLines.length,\n    speakerStats\n  })\n})\n\n/**\n * POST /api/novel-promotion/[projectId]/voice-lines\n * 新增单条台词\n * Body: { episodeId, content, speaker, matchedPanelId?: string | null }\n */\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const { episodeId, content, speaker, matchedPanelId } = body\n\n  if (!episodeId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (!content || !content.trim()) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  if (!speaker || !speaker.trim()) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const novelPromotionProject = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    select: { id: true }\n  })\n  if (!novelPromotionProject) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const episode = await prisma.novelPromotionEpisode.findFirst({\n    where: {\n      id: episodeId,\n      novelPromotionProjectId: novelPromotionProject.id\n    },\n    select: { id: true }\n  })\n  if (!episode) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const maxLine = await prisma.novelPromotionVoiceLine.findFirst({\n    where: { episodeId },\n    orderBy: { lineIndex: 'desc' },\n    select: { lineIndex: true }\n  })\n  const nextLineIndex = (maxLine?.lineIndex || 0) + 1\n\n  const matchedPanelData = await resolveMatchedPanelData(\n    matchedPanelId === undefined ? undefined : matchedPanelId,\n    episodeId\n  )\n\n  const created = await prisma.novelPromotionVoiceLine.create({\n    data: {\n      episodeId,\n      lineIndex: nextLineIndex,\n      content: content.trim(),\n      speaker: speaker.trim(),\n      ...(matchedPanelData || {})\n    },\n    include: {\n      matchedPanel: {\n        select: {\n          id: true,\n          storyboardId: true,\n          panelIndex: true\n        }\n      }\n    }\n  })\n\n  const voiceLine = await withVoiceLineMedia(created)\n\n  return NextResponse.json({\n    success: true,\n    voiceLine\n  })\n})\n\n/**\n * PATCH /api/novel-promotion/[projectId]/voice-lines\n * 更新台词设置（内容、发言人、情绪设置、音频URL）\n * Body: { lineId, content, speaker, emotionPrompt, emotionStrength, audioUrl } \n *    或 { speaker, episodeId, voicePresetId } (批量更新同一发言人的音色)\n */\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json()\n  const {\n    lineId,\n    speaker,\n    episodeId,\n    voicePresetId,\n    emotionPrompt,\n    emotionStrength,\n    content,\n    audioUrl,\n    matchedPanelId\n  } = body\n\n  // 单条更新\n  if (lineId) {\n    const updateData: Prisma.NovelPromotionVoiceLineUncheckedUpdateInput = {}\n    if (voicePresetId !== undefined) updateData.voicePresetId = voicePresetId\n    if (emotionPrompt !== undefined) updateData.emotionPrompt = emotionPrompt || null\n    if (emotionStrength !== undefined) updateData.emotionStrength = emotionStrength\n    if (content !== undefined) {\n      if (!content.trim()) {\n        throw new ApiError('INVALID_PARAMS')\n      }\n      updateData.content = content.trim()\n    }\n    if (speaker !== undefined) {\n      if (!speaker.trim()) {\n        throw new ApiError('INVALID_PARAMS')\n      }\n      updateData.speaker = speaker.trim()\n    }\n    if (audioUrl !== undefined) {\n      updateData.audioUrl = audioUrl // 支持清空音频 (传 null)\n      const media = await resolveMediaRefFromLegacyValue(audioUrl)\n      updateData.audioMediaId = media?.id || null\n    }\n    if (matchedPanelId !== undefined) {\n      const currentLine = await prisma.novelPromotionVoiceLine.findUnique({\n        where: { id: lineId },\n        select: { episodeId: true }\n      })\n      if (!currentLine) {\n        throw new ApiError('NOT_FOUND')\n      }\n\n      const matchedPanelData = await resolveMatchedPanelData(matchedPanelId, currentLine.episodeId)\n      if (matchedPanelData) {\n        updateData.matchedPanelId = matchedPanelData.matchedPanelId\n        updateData.matchedStoryboardId = matchedPanelData.matchedStoryboardId\n        updateData.matchedPanelIndex = matchedPanelData.matchedPanelIndex\n      }\n    }\n\n    const updated = await prisma.novelPromotionVoiceLine.update({\n      where: { id: lineId },\n      data: updateData,\n      include: {\n        matchedPanel: {\n          select: {\n            id: true,\n            storyboardId: true,\n            panelIndex: true\n          }\n        }\n      }\n    })\n    return NextResponse.json({\n      success: true,\n      voiceLine: await withVoiceLineMedia(updated)\n    })\n  }\n\n  // 批量更新同一发言人（仅支持更新音色）\n  if (speaker && episodeId) {\n    const result = await prisma.novelPromotionVoiceLine.updateMany({\n      where: {\n        episodeId,\n        speaker\n      },\n      data: { voicePresetId }\n    })\n    return NextResponse.json({\n      success: true,\n      updatedCount: result.count,\n      speaker,\n      voicePresetId\n    })\n  }\n\n  throw new ApiError('INVALID_PARAMS')\n})\n\n/**\n * DELETE /api/novel-promotion/[projectId]/voice-lines?lineId=xxx\n * 删除单条台词\n */\nexport const DELETE = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n\n  const { searchParams } = new URL(request.url)\n  const lineId = searchParams.get('lineId')\n\n  if (!lineId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取要删除的台词\n  const lineToDelete = await prisma.novelPromotionVoiceLine.findUnique({\n    where: { id: lineId }\n  })\n\n  if (!lineToDelete) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 删除台词\n  await prisma.novelPromotionVoiceLine.delete({\n    where: { id: lineId }\n  })\n\n  // 重新排序剩余台词的 lineIndex\n  const remainingLines = await prisma.novelPromotionVoiceLine.findMany({\n    where: { episodeId: lineToDelete.episodeId },\n    orderBy: { lineIndex: 'asc' }\n  })\n\n  // 更新每条台词的 lineIndex\n  for (let i = 0; i < remainingLines.length; i++) {\n    if (remainingLines[i].lineIndex !== i + 1) {\n      await prisma.novelPromotionVoiceLine.update({\n        where: { id: remainingLines[i].id },\n        data: { lineIndex: i + 1 }\n      })\n    }\n  }\n\n  return NextResponse.json({\n    success: true,\n    deletedId: lineId,\n    remainingCount: remainingLines.length\n  })\n})\n"
  },
  {
    "path": "src/app/api/projects/[projectId]/assets/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { attachMediaFieldsToProject } from '@/lib/media/attach'\n\n/**\n * ⚡ 延迟加载 API - 获取项目的 characters 和 locations 资产\n * 用于资产管理页面，避免首次加载时的性能开销\n */\nexport const GET = apiHandler(async (\n    request: NextRequest,\n    context: { params: Promise<{ projectId: string }> }\n) => {\n    const { projectId } = await context.params\n\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    // 验证项目所有权\n    const project = await prisma.project.findUnique({\n        where: { id: projectId },\n        select: { userId: true }\n    })\n\n    if (!project) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    if (project.userId !== session.user.id) {\n        throw new ApiError('FORBIDDEN')\n    }\n\n    // 获取 characters 和 locations（包含嵌套数据）\n    const novelPromotionData = await prisma.novelPromotionProject.findUnique({\n        where: { projectId },\n        include: {\n            characters: {\n                include: { appearances: { orderBy: { appearanceIndex: 'asc' } } },\n                orderBy: { createdAt: 'asc' }\n            },\n            locations: {\n                include: { images: { orderBy: { imageIndex: 'asc' } } },\n                orderBy: { createdAt: 'asc' }\n            }\n        }\n    })\n\n    if (!novelPromotionData) {\n        throw new ApiError('NOT_FOUND')\n    }\n\n    // 转换为稳定媒体 URL（并保留兼容字段）\n    const dataWithSignedUrls = await attachMediaFieldsToProject(novelPromotionData)\n\n    return NextResponse.json({\n        characters: dataWithSignedUrls.characters || [],\n        locations: dataWithSignedUrls.locations || []\n    })\n})\n"
  },
  {
    "path": "src/app/api/projects/[projectId]/costs/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { getProjectCostDetails } from '@/lib/billing'\nimport { BILLING_CURRENCY } from '@/lib/billing/currency'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\n\n/**\n * GET /api/projects/[projectId]/costs\n * 获取项目费用详情\n */\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const { projectId } = await context.params\n\n  // 验证项目归属\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: { userId: true, name: true }\n  })\n\n  if (!project) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  if (project.userId !== session.user.id) {\n    throw new ApiError('FORBIDDEN')\n  }\n\n  // 获取费用详情\n  const costDetails = await getProjectCostDetails(projectId)\n\n  return NextResponse.json({\n    projectId,\n    projectName: project.name,\n    currency: BILLING_CURRENCY,\n    ...costDetails\n  })\n})\n"
  },
  {
    "path": "src/app/api/projects/[projectId]/data/route.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { attachMediaFieldsToProject } from '@/lib/media/attach'\n\n/**\n * 统一的项目数据加载API\n * 返回项目基础信息、全局配置、全局资产和剧集列表\n */\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  // 获取基础项目信息\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    include: { user: true }\n  })\n\n  if (!project) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  if (project.userId !== session.user.id) {\n    throw new ApiError('FORBIDDEN')\n  }\n\n  // 🔥 更新最近访问时间（异步，不阻塞响应）\n  prisma.project.update({\n    where: { id: projectId },\n    data: { lastAccessedAt: new Date() }\n  }).catch(err => _ulogError('更新访问时间失败:', err))\n\n  // ⚡ 并行执行：加载 novel-promotion 数据\n  // 注意：characters/locations 延迟加载，首次只获取 episodes 列表\n  const novelPromotionData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      // 剧集列表（基础信息）- 首页必需\n      episodes: {\n        orderBy: { episodeNumber: 'asc' }\n      },\n      // ⚡ 角色和场景数据 - 资产显示必需\n      characters: {\n        include: {\n          appearances: true\n        },\n        orderBy: { createdAt: 'asc' }\n      },\n      locations: {\n        include: {\n          images: true\n        },\n        orderBy: { createdAt: 'asc' }\n      }\n    }\n  })\n\n  if (!novelPromotionData) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  // 转换为稳定媒体 URL（并保留兼容字段）\n  const novelPromotionDataWithSignedUrls = await attachMediaFieldsToProject(novelPromotionData)\n\n  const fullProject = {\n    ...project,\n    novelPromotionData: novelPromotionDataWithSignedUrls\n    // 🔥 不再用 userPreference 覆盖任何字段\n    // editModel 等配置应该直接使用 novelPromotionData 中的值\n  }\n\n  return NextResponse.json({ project: fullProject })\n})\n"
  },
  {
    "path": "src/app/api/projects/[projectId]/route.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { addSignedUrlsToProject, deleteObjects } from '@/lib/storage'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { logProjectAction } from '@/lib/logging/semantic'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport {\n  collectProjectBailianManagedVoiceIds,\n  cleanupUnreferencedBailianVoices,\n} from '@/lib/providers/bailian'\n\n// GET - 获取项目详情\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  // 只获取基础项目信息，不包含模式特定数据\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    include: {\n      user: true\n    }\n  })\n\n  if (!project) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  if (project.userId !== session.user.id) {\n    throw new ApiError('FORBIDDEN')\n  }\n\n  // 更新最近访问时间（异步，不阻塞响应）\n  prisma.project.update({\n    where: { id: projectId },\n    data: { lastAccessedAt: new Date() }\n  }).catch(err => _ulogError('更新访问时间失败:', err))\n\n  // 这个API只返回基础项目信息\n  // 模式特定的数据应该通过各自的API获取（如 /api/novel-promotion/[projectId]）\n  const projectWithSignedUrls = addSignedUrlsToProject(project)\n\n  return NextResponse.json({ project: projectWithSignedUrls })\n})\n\n// PATCH - 更新项目配置\nexport const PATCH = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const session = authResult.session\n  const body = await request.json()\n\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    include: { user: true }\n  })\n\n  if (!project) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  if (project.userId !== session.user.id) {\n    throw new ApiError('FORBIDDEN')\n  }\n\n  // 更新项目\n  const updatedProject = await prisma.project.update({\n    where: { id: projectId },\n    data: body\n  })\n\n  logProjectAction(\n    'UPDATE',\n    session.user.id,\n    session.user.name,\n    projectId,\n    updatedProject.name,\n    { changes: body }\n  )\n\n  return NextResponse.json({ project: updatedProject })\n})\n\n/**\n * 收集项目的所有COS文件Key\n */\nasync function collectProjectCOSKeys(projectId: string): Promise<string[]> {\n  const keys: string[] = []\n\n  // 获取 NovelPromotionProject\n  const novelPromotion = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      // 角色及其形象图片\n      characters: {\n        include: {\n          appearances: true\n        }\n      },\n      // 场景及其图片\n      locations: {\n        include: {\n          images: true\n        }\n      },\n      // 剧集（包含音频、分镜等）\n      episodes: {\n        include: {\n          storyboards: {\n            include: {\n              panels: true\n            }\n          }\n        }\n      }\n    }\n  })\n\n  if (!novelPromotion) return keys\n\n  // 1. 收集角色形象图片\n  for (const character of novelPromotion.characters) {\n    for (const appearance of character.appearances) {\n      const key = await resolveStorageKeyFromMediaValue(appearance.imageUrl)\n      if (key) keys.push(key)\n    }\n  }\n\n  // 2. 收集场景图片\n  for (const location of novelPromotion.locations) {\n    for (const image of location.images) {\n      const key = await resolveStorageKeyFromMediaValue(image.imageUrl)\n      if (key) keys.push(key)\n    }\n  }\n\n  // 3. 收集剧集相关文件\n  for (const episode of novelPromotion.episodes) {\n    // 音频文件\n    const audioKey = await resolveStorageKeyFromMediaValue(episode.audioUrl)\n    if (audioKey) keys.push(audioKey)\n\n    // 分镜图片\n    for (const storyboard of episode.storyboards) {\n      // 分镜整体图\n      const sbKey = await resolveStorageKeyFromMediaValue(storyboard.storyboardImageUrl)\n      if (sbKey) keys.push(sbKey)\n\n      // 候选图片（JSON数组）\n      if (storyboard.candidateImages) {\n        try {\n          const candidates = JSON.parse(storyboard.candidateImages)\n          for (const url of candidates) {\n            const key = await resolveStorageKeyFromMediaValue(url)\n            if (key) keys.push(key)\n          }\n        } catch { }\n      }\n\n      // Panel 表中的图片和视频\n      for (const panel of storyboard.panels) {\n        const imgKey = await resolveStorageKeyFromMediaValue(panel.imageUrl)\n        if (imgKey) keys.push(imgKey)\n\n        const videoKey = await resolveStorageKeyFromMediaValue(panel.videoUrl)\n        if (videoKey) keys.push(videoKey)\n      }\n    }\n  }\n\n  _ulogInfo(`[Project ${projectId}] 收集到 ${keys.length} 个 COS 文件待删除`)\n  return keys\n}\n\n// DELETE - 删除项目（同时清理COS文件）\nexport const DELETE = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ projectId: string }> }\n) => {\n  const { projectId } = await context.params\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const session = authResult.session\n\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    include: { user: true }\n  })\n\n  if (!project) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  if (project.userId !== session.user.id) {\n    throw new ApiError('FORBIDDEN')\n  }\n\n  // 1. 先收集所有 COS 文件 Key\n  _ulogInfo(`[DELETE] 开始删除项目: ${project.name} (${projectId})`)\n  const projectVoiceIds = await collectProjectBailianManagedVoiceIds(projectId)\n  const voiceCleanupResult = await cleanupUnreferencedBailianVoices({\n    voiceIds: projectVoiceIds,\n    scope: {\n      userId: session.user.id,\n      excludeProjectId: projectId,\n    },\n  })\n  const cosKeys = await collectProjectCOSKeys(projectId)\n\n  // 2. 批量删除 COS 文件\n  let cosResult = { success: 0, failed: 0 }\n  if (cosKeys.length > 0) {\n    _ulogInfo(`[DELETE] 正在删除 ${cosKeys.length} 个 COS 文件...`)\n    cosResult = await deleteObjects(cosKeys)\n  }\n\n  // 3. 删除数据库记录 (级联删除所有关联数据)\n  await prisma.project.delete({\n    where: { id: projectId }\n  })\n\n  logProjectAction(\n    'DELETE',\n    session.user.id,\n    session.user.name,\n    projectId,\n    project.name,\n    {\n      projectName: project.name,\n      cosFilesDeleted: cosResult.success,\n      cosFilesFailed: cosResult.failed,\n      bailianVoicesDeleted: voiceCleanupResult.deletedVoiceIds.length,\n      bailianVoicesSkippedReferenced: voiceCleanupResult.skippedReferencedVoiceIds.length,\n    }\n  )\n\n  _ulogInfo(`[DELETE] 项目删除完成: ${project.name}`)\n  _ulogInfo(`[DELETE] COS 文件: 成功 ${cosResult.success}, 失败 ${cosResult.failed}`)\n  _ulogInfo(`[DELETE] Bailian 音色: 删除 ${voiceCleanupResult.deletedVoiceIds.length}, 跳过(仍被引用) ${voiceCleanupResult.skippedReferencedVoiceIds.length}`)\n\n  return NextResponse.json({\n    success: true,\n    cosFilesDeleted: cosResult.success,\n    cosFilesFailed: cosResult.failed,\n    bailianVoicesDeleted: voiceCleanupResult.deletedVoiceIds.length,\n    bailianVoicesSkippedReferenced: voiceCleanupResult.skippedReferencedVoiceIds.length,\n  })\n})\n"
  },
  {
    "path": "src/app/api/projects/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { toMoneyNumber } from '@/lib/billing/money'\nimport { isArtStyleValue } from '@/lib/constants'\n\n// GET - 获取用户的项目（支持分页和搜索）\nexport const GET = apiHandler(async (request: NextRequest) => {\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  // 获取查询参数\n  const { searchParams } = new URL(request.url)\n  const page = parseInt(searchParams.get('page') || '1', 10)\n  const pageSize = parseInt(searchParams.get('pageSize') || '12', 10)\n  const search = searchParams.get('search') || ''\n\n  // 构建查询条件\n  const where: Record<string, unknown> = { userId: session.user.id }\n\n  // 如果有搜索关键词，搜索名称和描述\n  // 注意：SQLite 不支持 mode: 'insensitive'，但 SQLite 的 LIKE 默认即大小写不敏感（ASCII 范围）\n  if (search.trim()) {\n    where.OR = [\n      { name: { contains: search.trim() } },\n      { description: { contains: search.trim() } }\n    ]\n  }\n\n  // ⚡ 并行执行：获取总数 + 分页数据\n  // 排序优先级：最近访问时间（有值的优先） > 更新时间\n  const [total, allProjects] = await Promise.all([\n    prisma.project.count({ where }),\n    prisma.project.findMany({\n      where,\n      orderBy: { updatedAt: 'desc' },  // 先按更新时间排序获取所有匹配项目\n      skip: (page - 1) * pageSize,\n      take: pageSize\n    })\n  ])\n\n  // 在应用层重新排序：\n  // 1. 新创建但未访问过的项目（无 lastAccessedAt）按创建时间降序排在最前\n  // 2. 访问过的项目按访问时间降序\n  const projects = [...allProjects].sort((a, b) => {\n    // 两个都没有访问时间，按创建时间降序（新创建的排前面）\n    if (!a.lastAccessedAt && !b.lastAccessedAt) {\n      return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()\n    }\n    // 只有 a 没有访问时间（新创建），a 排前面\n    if (!a.lastAccessedAt && b.lastAccessedAt) return -1\n    // 只有 b 没有访问时间（新创建），b 排前面\n    if (a.lastAccessedAt && !b.lastAccessedAt) return 1\n    // 两个都有访问时间，按访问时间降序\n    return new Date(b.lastAccessedAt!).getTime() - new Date(a.lastAccessedAt!).getTime()\n  })\n\n  // 获取项目 ID 列表\n  const projectIds = projects.map(p => p.id)\n\n  // ⚡ 并行获取：费用 + 项目统计（章节数、图片数、视频数）\n  const [costsByProject, novelProjects] = await Promise.all([\n    // 一次性获取所有项目的费用（代替 N+1 查询）\n    prisma.usageCost.groupBy({\n      by: ['projectId'],\n      where: { projectId: { in: projectIds } },\n      _sum: { cost: true }\n    }),\n    // 一次性获取所有项目的统计数据\n    prisma.novelPromotionProject.findMany({\n      where: { projectId: { in: projectIds } },\n      select: {\n        projectId: true,\n        _count: {\n          select: {\n            episodes: true,\n            characters: true,\n            locations: true\n          }\n        },\n        episodes: {\n          orderBy: { episodeNumber: 'asc' },\n          select: {\n            episodeNumber: true,\n            novelText: true,\n            storyboards: {\n              select: {\n                _count: {\n                  select: { panels: true }\n                },\n                panels: {\n                  where: {\n                    OR: [\n                      { imageUrl: { not: null } },\n                      { videoUrl: { not: null } },\n                    ]\n                  },\n                  select: {\n                    imageUrl: true,\n                    videoUrl: true\n                  }\n                }\n              }\n            }\n          }\n        }\n      }\n    })\n  ])\n\n  // 构建费用映射表\n  const costMap = new Map(\n    costsByProject.map(item => [item.projectId, toMoneyNumber(item._sum.cost)])\n  )\n\n  // 构建统计映射表 + 第一集预览\n  const statsMap = new Map<string, { episodes: number; images: number; videos: number; panels: number; firstEpisodePreview: string | null }>(\n    novelProjects.map(np => {\n      let imageCount = 0\n      let videoCount = 0\n      let panelCount = 0\n      for (const ep of np.episodes) {\n        for (const sb of ep.storyboards) {\n          panelCount += sb._count.panels\n          for (const panel of sb.panels) {\n            if (panel.imageUrl) imageCount++\n            if (panel.videoUrl) videoCount++\n          }\n        }\n      }\n      // 取第一集的 novelText 前 100 字作为预览\n      const firstEp = np.episodes[0]\n      const preview = firstEp?.novelText ? firstEp.novelText.slice(0, 100) : null\n      return [np.projectId, {\n        episodes: np._count.episodes,\n        images: imageCount,\n        videos: videoCount,\n        panels: panelCount,\n        firstEpisodePreview: preview\n      }]\n    })\n  )\n\n  // 合并项目、费用与统计\n  const projectsWithStats = projects.map(project => ({\n    ...project,\n    totalCost: costMap.get(project.id) ?? 0,\n    stats: statsMap.get(project.id) ?? { episodes: 0, images: 0, videos: 0, panels: 0, firstEpisodePreview: null }\n  }))\n\n  return NextResponse.json({\n    projects: projectsWithStats,\n    pagination: {\n      page,\n      pageSize,\n      total,\n      totalPages: Math.ceil(total / pageSize)\n    }\n  })\n})\n\n// POST - 创建新项目\nexport const POST = apiHandler(async (request: NextRequest) => {\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const { name, description } = await request.json()\n\n  if (!name || name.trim().length === 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (name.length > 100) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (description && description.length > 500) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 获取用户偏好配置\n  const userPreference = await prisma.userPreference.findUnique({\n    where: { userId: session.user.id }\n  })\n\n  // 创建基础项目（mode 固定为 novel-promotion）\n  const project = await prisma.project.create({\n    data: {\n      name: name.trim(),\n      description: description?.trim() || null,\n      mode: 'novel-promotion',\n      userId: session.user.id\n    }\n  })\n\n  // 创建 novel-promotion 数据表，使用用户偏好作为默认值\n  // 注意：不再自动创建默认剧集，由用户在选择界面决定：\n  // - 手动创作 → 创建第一个空白剧集\n  // - 智能导入 → AI 分析后批量创建剧集\n  // 🔥 artStylePrompt 通过实时查询获取，不再存储到数据库\n  await prisma.novelPromotionProject.create({\n    data: {\n      projectId: project.id,\n      ...(userPreference && {\n        analysisModel: userPreference.analysisModel,\n        characterModel: userPreference.characterModel,\n        locationModel: userPreference.locationModel,\n        storyboardModel: userPreference.storyboardModel,\n        editModel: userPreference.editModel,\n        videoModel: userPreference.videoModel,\n        audioModel: userPreference.audioModel,\n        videoRatio: userPreference.videoRatio,\n        artStyle: isArtStyleValue(userPreference.artStyle) ? userPreference.artStyle : 'american-comic',\n        ttsRate: userPreference.ttsRate\n      })\n    }\n  })\n\n  return NextResponse.json({ project }, { status: 201 })\n})\n"
  },
  {
    "path": "src/app/api/runs/[runId]/cancel/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport { cancelTask } from '@/lib/task/service'\nimport { getRunById, requestRunCancel } from '@/lib/run-runtime/service'\nimport { publishRunEvent } from '@/lib/run-runtime/publisher'\nimport { RUN_EVENT_TYPE, RUN_STATUS } from '@/lib/run-runtime/types'\n\nexport const POST = apiHandler(async (\n  _request: NextRequest,\n  context: { params: Promise<{ runId: string }> },\n) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const { runId } = await context.params\n\n  const run = await getRunById(runId)\n  if (!run || run.userId !== session.user.id) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const cancelledRun = await requestRunCancel({\n    runId,\n    userId: session.user.id,\n  })\n  if (!cancelledRun) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  if (cancelledRun.taskId) {\n    await cancelTask(cancelledRun.taskId, 'Run cancelled by user')\n  }\n\n  if (\n    cancelledRun.status === RUN_STATUS.CANCELING ||\n    cancelledRun.status === RUN_STATUS.CANCELED\n  ) {\n    await publishRunEvent({\n      runId: cancelledRun.id,\n      projectId: cancelledRun.projectId,\n      userId: cancelledRun.userId,\n      eventType: RUN_EVENT_TYPE.RUN_CANCELED,\n      payload: {\n        message: 'Run cancelled by user',\n      },\n    })\n  }\n\n  return NextResponse.json({\n    success: true,\n    run: cancelledRun,\n  })\n})\n\n"
  },
  {
    "path": "src/app/api/runs/[runId]/events/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport { listRunEventsAfterSeq } from '@/lib/run-runtime/service'\n\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ runId: string }> },\n) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const { runId } = await context.params\n  const afterSeqRaw = Number.parseInt(request.nextUrl.searchParams.get('afterSeq') || '0', 10)\n  const limitRaw = Number.parseInt(request.nextUrl.searchParams.get('limit') || '200', 10)\n  const afterSeq = Number.isFinite(afterSeqRaw) ? Math.max(0, afterSeqRaw) : 0\n  const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 2000) : 200\n\n  const events = await listRunEventsAfterSeq({\n    runId,\n    userId: session.user.id,\n    afterSeq,\n    limit,\n  })\n  return NextResponse.json({\n    runId,\n    afterSeq,\n    events,\n  })\n})\n\n"
  },
  {
    "path": "src/app/api/runs/[runId]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport { getRunSnapshot } from '@/lib/run-runtime/service'\n\nexport const GET = apiHandler(async (\n  _request: NextRequest,\n  context: { params: Promise<{ runId: string }> },\n) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const { runId } = await context.params\n\n  const snapshot = await getRunSnapshot(runId)\n  if (!snapshot || snapshot.run.userId !== session.user.id) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  return NextResponse.json(snapshot)\n})\n\n"
  },
  {
    "path": "src/app/api/runs/[runId]/steps/[stepKey]/retry/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport { retryFailedStep, getRunById } from '@/lib/run-runtime/service'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { submitTask } from '@/lib/task/submitter'\nimport { TASK_TYPE, type TaskType } from '@/lib/task/types'\n\nconst RETRY_SUPPORTED_TASK_TYPES: ReadonlySet<string> = new Set<string>([\n  TASK_TYPE.STORY_TO_SCRIPT_RUN,\n  TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n])\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction readString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction resolveTaskType(run: {\n  workflowType: string\n  taskType: string | null\n}): TaskType {\n  const candidate = readString(run.taskType || run.workflowType)\n  if (!candidate || !RETRY_SUPPORTED_TASK_TYPES.has(candidate)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'RUN_STEP_RETRY_UNSUPPORTED_TASK_TYPE',\n      taskType: candidate || null,\n    })\n  }\n  return candidate as TaskType\n}\n\nexport const POST = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ runId: string; stepKey: string }> },\n) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const { runId, stepKey: rawStepKey } = await context.params\n  const stepKey = decodeURIComponent(rawStepKey || '').trim()\n  if (!runId || !stepKey) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const run = await getRunById(runId)\n  if (!run || run.userId !== session.user.id) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const body = await request.json().catch(() => null)\n  const payload = toObject(body)\n  const modelOverride = readString(payload.modelOverride)\n  const reason = readString(payload.reason)\n\n  let prepared: Awaited<ReturnType<typeof retryFailedStep>> = null\n  try {\n    prepared = await retryFailedStep({\n      runId,\n      userId: session.user.id,\n      stepKey,\n    })\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    if (message === 'RUN_STEP_NOT_FOUND') {\n      throw new ApiError('NOT_FOUND')\n    }\n    if (message === 'RUN_STEP_NOT_FAILED') {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'RUN_STEP_RETRY_ONLY_FAILED',\n        stepKey,\n      })\n    }\n    throw error\n  }\n  if (!prepared) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const taskType = resolveTaskType(run)\n  const locale = resolveRequiredTaskLocale(request, payload)\n  const runInput = toObject(run.input)\n  const taskPayload: Record<string, unknown> = {\n    ...runInput,\n    episodeId: run.episodeId || runInput.episodeId || null,\n    runId,\n    retryStepKey: stepKey,\n    retryStepAttempt: prepared.retryAttempt,\n    retryReason: reason || null,\n    displayMode: 'detail',\n    meta: {\n      ...toObject(runInput.meta),\n      locale,\n      runId,\n      retryStepKey: stepKey,\n      retryStepAttempt: prepared.retryAttempt,\n      retryReason: reason || null,\n    },\n  }\n  if (modelOverride) {\n    taskPayload.model = modelOverride\n    taskPayload.analysisModel = modelOverride\n  }\n\n  const submitResult = await submitTask({\n    userId: session.user.id,\n    locale,\n    requestId: getRequestId(request),\n    projectId: run.projectId,\n    episodeId: run.episodeId || null,\n    type: taskType,\n    targetType: run.targetType,\n    targetId: run.targetId,\n    payload: taskPayload,\n    dedupeKey: null,\n    priority: 3,\n  })\n\n  return NextResponse.json({\n    success: true,\n    runId,\n    stepKey,\n    retryAttempt: prepared.retryAttempt,\n    taskId: submitResult.taskId,\n    async: true,\n  })\n})\n"
  },
  {
    "path": "src/app/api/runs/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport { createRun, listRuns } from '@/lib/run-runtime/service'\nimport { RUN_STATUS, type RunStatus } from '@/lib/run-runtime/types'\n\nfunction readString(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  return trimmed || null\n}\n\nfunction normalizeStatus(value: string | null): RunStatus | null {\n  if (!value) return null\n  if (\n    value === RUN_STATUS.QUEUED ||\n    value === RUN_STATUS.RUNNING ||\n    value === RUN_STATUS.COMPLETED ||\n    value === RUN_STATUS.FAILED ||\n    value === RUN_STATUS.CANCELING ||\n    value === RUN_STATUS.CANCELED\n  ) return value\n  return null\n}\n\nfunction normalizeStatuses(values: string[]): RunStatus[] {\n  const next: RunStatus[] = []\n  for (const value of values) {\n    const normalized = normalizeStatus(readString(value))\n    if (!normalized) continue\n    if (!next.includes(normalized)) {\n      next.push(normalized)\n    }\n  }\n  return next\n}\n\nexport const GET = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const query = request.nextUrl.searchParams\n  const projectId = readString(query.get('projectId'))\n  const workflowType = readString(query.get('workflowType'))\n  const targetType = readString(query.get('targetType'))\n  const targetId = readString(query.get('targetId'))\n  const episodeId = readString(query.get('episodeId'))\n  const statuses = normalizeStatuses(query.getAll('status'))\n  const limitRaw = Number.parseInt(query.get('limit') || '50', 10)\n  const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50\n  const runs = await listRuns({\n    userId: session.user.id,\n    projectId: projectId || undefined,\n    workflowType: workflowType || undefined,\n    targetType: targetType || undefined,\n    targetId: targetId || undefined,\n    episodeId: episodeId || undefined,\n    statuses: statuses.length > 0 ? statuses : undefined,\n    limit,\n  })\n  return NextResponse.json({ runs })\n})\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json().catch(() => null)\n  if (!body || typeof body !== 'object' || Array.isArray(body)) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const payload = body as Record<string, unknown>\n  const projectId = readString(payload.projectId)\n  const workflowType = readString(payload.workflowType)\n  const targetType = readString(payload.targetType)\n  const targetId = readString(payload.targetId)\n  const episodeId = readString(payload.episodeId)\n  const taskType = readString(payload.taskType)\n  const taskId = readString(payload.taskId)\n  const input = payload.input && typeof payload.input === 'object' && !Array.isArray(payload.input)\n    ? (payload.input as Record<string, unknown>)\n    : null\n\n  if (!projectId || !workflowType || !targetType || !targetId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const run = await createRun({\n    userId: session.user.id,\n    projectId,\n    episodeId,\n    workflowType,\n    taskType,\n    taskId,\n    targetType,\n    targetId,\n    input,\n  })\n  return NextResponse.json({\n    success: true,\n    runId: run.id,\n    run,\n  })\n})\n"
  },
  {
    "path": "src/app/api/sse/route.ts",
    "content": "import { createScopedLogger } from '@/lib/logging/core'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError, getRequestId } from '@/lib/api-errors'\nimport { getProjectChannel, listEventsAfter } from '@/lib/task/publisher'\nimport { isErrorResponse, requireProjectAuthLight, requireUserAuth } from '@/lib/api-auth'\nimport { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types'\nimport { getSharedSubscriber } from '@/lib/sse/shared-subscriber'\nimport { prisma } from '@/lib/prisma'\nimport { coerceTaskIntent } from '@/lib/task/intent'\n\nfunction parseReplayCursorId(value: string | null): number {\n  if (!value) return 0\n  const trimmed = value.trim()\n  if (!trimmed || !/^\\d+$/.test(trimmed)) return 0\n  const parsed = Number.parseInt(trimmed, 10)\n  return Number.isFinite(parsed) && parsed > 0 ? parsed : 0\n}\n\nfunction formatSSE(event: SSEEvent) {\n  const dataLine = `event: ${event.type}\\ndata: ${JSON.stringify(event)}\\n\\n`\n  if (typeof event.id === 'string' && /^\\d+$/.test(event.id)) {\n    return `id: ${event.id}\\n${dataLine}`\n  }\n  return dataLine\n}\n\nfunction formatHeartbeat() {\n  return `event: heartbeat\\ndata: {\"ts\":\"${new Date().toISOString()}\"}\\n\\n`\n}\n\nfunction asObject(value: unknown): Record<string, unknown> | null {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n  return value as Record<string, unknown>\n}\n\nasync function listActiveLifecycleSnapshot(params: {\n  projectId: string\n  episodeId: string | null\n  userId: string\n  limit?: number\n}) {\n  const limit = params.limit || 500\n  const rows = await prisma.task.findMany({\n    where: {\n      projectId: params.projectId,\n      userId: params.userId,\n      status: {\n        in: ['queued', 'processing']},\n      ...(params.episodeId ? { episodeId: params.episodeId } : {})},\n    orderBy: {\n      updatedAt: 'desc'},\n    take: limit,\n    select: {\n      id: true,\n      type: true,\n      targetType: true,\n      targetId: true,\n      episodeId: true,\n      userId: true,\n      status: true,\n      progress: true,\n      payload: true,\n      updatedAt: true}})\n\n  return rows.map((row): SSEEvent => {\n    const payload = asObject(row.payload)\n    const payloadUi = asObject(payload?.ui)\n    const lifecycleType = row.status === 'queued'\n      ? TASK_EVENT_TYPE.CREATED\n      : TASK_EVENT_TYPE.PROCESSING\n    const eventPayload: Record<string, unknown> = {\n      ...(payload || {}),\n      lifecycleType,\n      intent: coerceTaskIntent(payloadUi?.intent ?? payload?.intent, row.type),\n      progress: typeof row.progress === 'number' ? row.progress : null}\n\n    return {\n      id: `snapshot:${row.id}:${row.updatedAt.getTime()}`,\n      type: TASK_SSE_EVENT_TYPE.LIFECYCLE,\n      taskId: row.id,\n      projectId: params.projectId,\n      userId: row.userId,\n      ts: row.updatedAt.toISOString(),\n      taskType: row.type,\n      targetType: row.targetType,\n      targetId: row.targetId,\n      episodeId: row.episodeId,\n      payload: eventPayload}\n  })\n}\n\nexport const GET = apiHandler(async (request: NextRequest) => {\n  const projectId = request.nextUrl.searchParams.get('projectId')\n  const episodeId = request.nextUrl.searchParams.get('episodeId')\n  if (!projectId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const authResult = projectId === 'global-asset-hub'\n    ? await requireUserAuth()\n    : await requireProjectAuthLight(projectId)\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const channel = getProjectChannel(projectId)\n  const sharedSubscriber = getSharedSubscriber()\n  const requestId = getRequestId(request)\n  const encoder = new TextEncoder()\n  const lastEventId = parseReplayCursorId(request.headers.get('last-event-id'))\n  const signal = request.signal\n  let closeStream: (() => Promise<void>) | null = null\n\n  const stream = new ReadableStream<Uint8Array>({\n    async start(controller) {\n      let closed = false\n      let timer: ReturnType<typeof setInterval> | null = null\n      let unsubscribe: (() => Promise<void>) | null = null\n      const logger = createScopedLogger({\n        module: 'sse',\n        action: 'sse.stream',\n        requestId: requestId || undefined,\n        projectId,\n        userId: session.user.id})\n      logger.info({\n        action: 'sse.connect',\n        message: 'sse connection established',\n        details: {\n          lastEventId: lastEventId || 0}})\n\n      const safeEnqueue = (chunk: string) => {\n        if (closed) return\n        controller.enqueue(encoder.encode(chunk))\n      }\n\n      const close = async () => {\n        if (closed) return\n        closed = true\n        try {\n          await unsubscribe?.()\n        } catch {}\n        logger.info({\n          action: 'sse.disconnect',\n          message: 'sse connection closed'})\n        if (timer) {\n          clearInterval(timer)\n          timer = null\n        }\n        try {\n          controller.close()\n        } catch {}\n      }\n      closeStream = close\n\n      signal.addEventListener('abort', () => {\n        void close()\n      })\n\n      if (lastEventId > 0) {\n        const missed = await listEventsAfter(projectId, lastEventId, 5000)\n        logger.info({\n          action: 'sse.replay',\n          message: 'sse replay sent',\n          details: {\n            fromEventId: lastEventId,\n            count: missed.length}})\n        for (const event of missed) {\n          safeEnqueue(formatSSE(event))\n        }\n      } else {\n        const snapshotEvents = await listActiveLifecycleSnapshot({\n          projectId,\n          episodeId,\n          userId: session.user.id,\n          limit: 500})\n        logger.info({\n          action: 'sse.active_snapshot',\n          message: 'sse active snapshot sent',\n          details: {\n            count: snapshotEvents.length}})\n        for (const event of snapshotEvents) {\n          safeEnqueue(formatSSE(event))\n        }\n      }\n\n      unsubscribe = await sharedSubscriber.addChannelListener(channel, (message) => {\n        try {\n          const event = JSON.parse(message) as SSEEvent\n          safeEnqueue(formatSSE(event))\n        } catch {\n          safeEnqueue(`data: ${message}\\n\\n`)\n        }\n      })\n\n      timer = setInterval(() => safeEnqueue(formatHeartbeat()), 15_000)\n    },\n    cancel() {\n      void closeStream?.()\n    }})\n\n  return new NextResponse(stream as unknown as BodyInit, {\n    headers: {\n      'Content-Type': 'text/event-stream; charset=utf-8',\n      'Cache-Control': 'no-cache, no-transform',\n      Connection: 'keep-alive',\n      'X-Accel-Buffering': 'no'}})\n})\n"
  },
  {
    "path": "src/app/api/storage/sign/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { getSignedObjectUrl } from '@/lib/storage'\n\nconst DEFAULT_EXPIRES_SECONDS = 3600\n\nexport const GET = apiHandler(async (request: NextRequest) => {\n  const { searchParams } = new URL(request.url)\n  const key = searchParams.get('key')\n  const expiresRaw = searchParams.get('expires')\n\n  if (!key) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const expires = expiresRaw ? Number.parseInt(expiresRaw, 10) : DEFAULT_EXPIRES_SECONDS\n  const ttl = Number.isFinite(expires) && expires > 0 ? expires : DEFAULT_EXPIRES_SECONDS\n\n  const signedUrl = await getSignedObjectUrl(key, ttl)\n  return NextResponse.redirect(signedUrl)\n})\n"
  },
  {
    "path": "src/app/api/system/boot-id/route.ts",
    "content": "import { NextResponse } from 'next/server'\nimport { SERVER_BOOT_ID } from '@/lib/server-boot'\n\n/**\n * GET /api/system/boot-id\n * 返回服务器启动ID，用于检测服务器是否重启\n */\nexport async function GET() {\n    return NextResponse.json({ bootId: SERVER_BOOT_ID })\n}\n"
  },
  {
    "path": "src/app/api/task-target-states/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport {\n  isErrorResponse,\n  requireProjectAuthLight,\n  requireUserAuth} from '@/lib/api-auth'\nimport { withPrismaRetry } from '@/lib/prisma-retry'\nimport { queryTaskTargetStates, type TaskTargetQuery } from '@/lib/task/state-service'\n\nfunction normalizeTarget(input: unknown): TaskTargetQuery {\n  const payload = input as Record<string, unknown>\n  const targetType = typeof payload.targetType === 'string' ? payload.targetType.trim() : ''\n  const targetId = typeof payload.targetId === 'string' ? payload.targetId.trim() : ''\n  const types = Array.isArray(payload.types)\n    ? payload.types.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)\n    : undefined\n\n  if (!targetType || !targetId) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  return {\n    targetType,\n    targetId,\n    ...(types && types.length > 0 ? { types } : {})}\n}\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  let body: Record<string, unknown>\n  try {\n    body = await request.json()\n  } catch {\n    throw new ApiError('INVALID_PARAMS')\n  }\n  const projectId = typeof body?.projectId === 'string' ? body.projectId.trim() : ''\n  const targetsRaw = Array.isArray(body?.targets) ? body.targets : null\n\n  if (!projectId || !targetsRaw) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  if (targetsRaw.length > 500) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  const targets = targetsRaw.map(normalizeTarget)\n\n  if (targets.length === 0) {\n    return NextResponse.json({ states: [] })\n  }\n\n  let userId: string\n  if (projectId === 'global-asset-hub') {\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    userId = authResult.session.user.id\n  } else {\n    const authResult = await requireProjectAuthLight(projectId)\n    if (isErrorResponse(authResult)) return authResult\n    userId = authResult.session.user.id\n  }\n\n  const states = await withPrismaRetry(() =>\n    queryTaskTargetStates({\n      projectId,\n      userId,\n      targets})\n  )\n\n  return NextResponse.json({ states })\n})\n"
  },
  {
    "path": "src/app/api/tasks/[taskId]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport { removeTaskJob } from '@/lib/task/queues'\nimport { listTaskLifecycleEvents, publishTaskEvent } from '@/lib/task/publisher'\nimport { cancelTask, getTaskById } from '@/lib/task/service'\nimport { TASK_EVENT_TYPE } from '@/lib/task/types'\nimport { normalizeTaskError } from '@/lib/errors/normalize'\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nexport const GET = apiHandler(async (\n  request: NextRequest,\n  context: { params: Promise<{ taskId: string }> },\n) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const { taskId } = await context.params\n\n  const task = await getTaskById(taskId)\n  if (!task || task.userId !== session.user.id) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const includeEvents = request.nextUrl.searchParams.get('includeEvents') === '1'\n  const eventsLimitRaw = Number.parseInt(request.nextUrl.searchParams.get('eventsLimit') || '500', 10)\n  const eventsLimit = Number.isFinite(eventsLimitRaw) ? Math.min(Math.max(eventsLimitRaw, 1), 5000) : 500\n  const events = includeEvents ? await listTaskLifecycleEvents(taskId, eventsLimit) : null\n\n  return NextResponse.json({\n    task: {\n      ...task,\n      error: normalizeTaskError(task.errorCode, task.errorMessage)},\n    ...(events ? { events } : {}),\n  })\n})\n\nexport const DELETE = apiHandler(async (\n  _request: NextRequest,\n  context: { params: Promise<{ taskId: string }> },\n) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const { taskId } = await context.params\n\n  const task = await getTaskById(taskId)\n  if (!task || task.userId !== session.user.id) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  const { task: updatedTask, cancelled } = await cancelTask(taskId)\n  if (!updatedTask) {\n    throw new ApiError('NOT_FOUND')\n  }\n\n  if (cancelled) {\n    // Best effort: remove queued job to avoid worker picking it up after cancellation.\n    await removeTaskJob(taskId).catch(() => false)\n    await publishTaskEvent({\n      taskId: updatedTask.id,\n      projectId: updatedTask.projectId,\n      userId: updatedTask.userId,\n      type: TASK_EVENT_TYPE.FAILED,\n      taskType: updatedTask.type,\n      targetType: updatedTask.targetType,\n      targetId: updatedTask.targetId,\n      episodeId: updatedTask.episodeId || null,\n      payload: {\n        ...toObject(updatedTask.payload),\n        stage: 'cancelled',\n        stageLabel: '任务已取消',\n        cancelled: true,\n        message: updatedTask.errorMessage || 'Task cancelled by user'},\n      persist: false})\n  }\n\n  return NextResponse.json({\n    success: true,\n    cancelled,\n    task: {\n      ...updatedTask,\n      error: normalizeTaskError(updatedTask.errorCode, updatedTask.errorMessage)}})\n})\n"
  },
  {
    "path": "src/app/api/tasks/dismiss/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { dismissFailedTasks } from '@/lib/task/service'\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const body = await request.json()\n    const { taskIds } = body as { taskIds?: string[] }\n\n    if (!Array.isArray(taskIds) || taskIds.length === 0) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    if (taskIds.length > 200) {\n        throw new ApiError('INVALID_PARAMS')\n    }\n\n    const count = await dismissFailedTasks(taskIds, session.user.id)\n\n    return NextResponse.json({ success: true, dismissed: count })\n})\n"
  },
  {
    "path": "src/app/api/tasks/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler } from '@/lib/api-errors'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { queryTasks } from '@/lib/task/service'\nimport { type TaskStatus } from '@/lib/task/types'\nimport { normalizeTaskError } from '@/lib/errors/normalize'\n\nfunction withTaskError(task: Awaited<ReturnType<typeof queryTasks>>[number]) {\n  const error = normalizeTaskError(task.errorCode, task.errorMessage)\n  return {\n    ...task,\n    error,\n  }\n}\n\nexport const GET = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const searchParams = request.nextUrl.searchParams\n  const projectId = searchParams.get('projectId') || undefined\n  const targetType = searchParams.get('targetType') || undefined\n  const targetId = searchParams.get('targetId') || undefined\n  const status = searchParams.getAll('status')\n  const type = searchParams.getAll('type')\n  const limit = Number.parseInt(searchParams.get('limit') || '50', 10)\n\n  const tasks = await queryTasks({\n    projectId,\n    targetType,\n    targetId,\n    status: status.length ? (status as TaskStatus[]) : undefined,\n    type: type.length ? type : undefined,\n    limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 200) : 50,\n  })\n\n  const filtered = tasks\n    .filter((task) => task.userId === session.user.id)\n    .map(withTaskError)\n  return NextResponse.json({ tasks: filtered })\n})\n"
  },
  {
    "path": "src/app/api/user/api-config/assistant/probe-media-template/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport { getProviderKey } from '@/lib/api-config'\nimport { validateOpenAICompatMediaTemplate } from '@/lib/user-api/model-template'\nimport { probeMediaTemplate } from '@/lib/user-api/model-template/probe'\n\ntype RequestBody = {\n  providerId?: unknown\n  modelId?: unknown\n  template?: unknown\n  samplePrompt?: unknown\n  sampleImage?: unknown\n}\n\nfunction readRequiredString(value: unknown, field: string): string {\n  if (typeof value !== 'string' || value.trim().length === 0) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_TEMPLATE_PROBE_INVALID',\n      field,\n    })\n  }\n  return value.trim()\n}\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n\n  let body: RequestBody\n  try {\n    body = (await request.json()) as RequestBody\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'BODY_PARSE_FAILED',\n      field: 'body',\n    })\n  }\n\n  const providerId = readRequiredString(body.providerId, 'providerId')\n  const modelId = readRequiredString(body.modelId, 'modelId')\n  if (getProviderKey(providerId) !== 'openai-compatible') {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_TEMPLATE_PROBE_PROVIDER_INVALID',\n      field: 'providerId',\n    })\n  }\n\n  const validated = validateOpenAICompatMediaTemplate(body.template)\n  if (!validated.ok || !validated.template) {\n    return NextResponse.json({\n      success: false,\n      verified: false,\n      code: 'MODEL_TEMPLATE_INVALID',\n      issues: validated.issues,\n    })\n  }\n\n  const samplePrompt = typeof body.samplePrompt === 'string' ? body.samplePrompt.trim() : undefined\n  const sampleImage = typeof body.sampleImage === 'string' ? body.sampleImage.trim() : undefined\n\n  const result = await probeMediaTemplate({\n    userId: authResult.session.user.id,\n    providerId,\n    modelId,\n    template: validated.template,\n    ...(samplePrompt ? { samplePrompt } : {}),\n    ...(sampleImage ? { sampleImage } : {}),\n  })\n\n  return NextResponse.json(result)\n})\n\n"
  },
  {
    "path": "src/app/api/user/api-config/assistant/validate-media-template/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport { getProviderKey } from '@/lib/api-config'\nimport { validateOpenAICompatMediaTemplate } from '@/lib/user-api/model-template'\n\ntype RequestBody = {\n  providerId?: unknown\n  template?: unknown\n}\n\nfunction readRequiredString(value: unknown, field: string): string {\n  if (typeof value !== 'string' || value.trim().length === 0) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_TEMPLATE_INVALID',\n      field,\n    })\n  }\n  return value.trim()\n}\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n\n  let body: RequestBody\n  try {\n    body = (await request.json()) as RequestBody\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'BODY_PARSE_FAILED',\n      field: 'body',\n    })\n  }\n\n  const providerId = readRequiredString(body.providerId, 'providerId')\n  if (getProviderKey(providerId) !== 'openai-compatible') {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_TEMPLATE_PROVIDER_INVALID',\n      field: 'providerId',\n    })\n  }\n\n  const result = validateOpenAICompatMediaTemplate(body.template)\n  return NextResponse.json({\n    success: result.ok,\n    ...(result.template ? { template: result.template } : {}),\n    issues: result.issues,\n  })\n})\n\n"
  },
  {
    "path": "src/app/api/user/api-config/probe-model-llm-protocol/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport { getProviderKey } from '@/lib/api-config'\nimport { probeModelLlmProtocol } from '@/lib/user-api/model-llm-protocol-probe'\n\ntype ProbeRequestBody = {\n  providerId?: unknown\n  modelId?: unknown\n}\n\nfunction readRequiredString(value: unknown, field: string): string {\n  if (typeof value !== 'string' || value.trim().length === 0) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_LLM_PROTOCOL_PROBE_INVALID',\n      field,\n    })\n  }\n  return value.trim()\n}\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n\n  let body: ProbeRequestBody\n  try {\n    body = (await request.json()) as ProbeRequestBody\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'BODY_PARSE_FAILED',\n      field: 'body',\n    })\n  }\n\n  const providerId = readRequiredString(body.providerId, 'providerId')\n  const modelId = readRequiredString(body.modelId, 'modelId')\n\n  if (getProviderKey(providerId) !== 'openai-compatible') {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_LLM_PROTOCOL_PROBE_PROVIDER_INVALID',\n      field: 'providerId',\n    })\n  }\n\n  const result = await probeModelLlmProtocol({\n    userId: authResult.session.user.id,\n    providerId,\n    modelId,\n  })\n\n  return NextResponse.json(result)\n})\n"
  },
  {
    "path": "src/app/api/user/api-config/route.ts",
    "content": "/**\n * 用户 API 配置管理接口\n *\n * GET  - 读取用户配置(解密)\n * PUT  - 保存/更新配置(加密)\n */\n\nimport { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { encryptApiKey, decryptApiKey } from '@/lib/crypto-utils'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport {\n  composeModelKey,\n  parseModelKeyStrict,\n  type CapabilitySelections,\n  type ModelCapabilities,\n  type UnifiedModelType,\n} from '@/lib/model-config-contract'\nimport {\n  getCapabilityOptionFields,\n  resolveBuiltinModelContext,\n  validateCapabilitySelectionsPayload,\n} from '@/lib/model-capabilities/lookup'\nimport { findBuiltinCapabilities } from '@/lib/model-capabilities/catalog'\nimport {\n  findBuiltinPricingCatalogEntry,\n  listBuiltinPricingCatalog,\n  type PricingApiType,\n} from '@/lib/model-pricing/catalog'\nimport { getBillingMode } from '@/lib/billing/mode'\nimport {\n  DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY,\n  DEFAULT_IMAGE_WORKFLOW_CONCURRENCY,\n  DEFAULT_VIDEO_WORKFLOW_CONCURRENCY,\n  normalizeWorkflowConcurrencyConfig,\n  normalizeWorkflowConcurrencyValue,\n} from '@/lib/workflow-concurrency'\nimport type {\n  OpenAICompatMediaTemplate,\n  OpenAICompatMediaTemplateSource,\n} from '@/lib/openai-compat-media-template'\nimport { validateOpenAICompatMediaTemplate } from '@/lib/user-api/model-template/validator'\n\ntype ApiModeType = 'gemini-sdk' | 'openai-official'\ntype GatewayRouteType = 'official' | 'openai-compat'\ntype LlmProtocolType = 'responses' | 'chat-completions'\ntype DefaultModelField =\n  | 'analysisModel'\n  | 'characterModel'\n  | 'locationModel'\n  | 'storyboardModel'\n  | 'editModel'\n  | 'videoModel'\n  | 'audioModel'\n  | 'lipSyncModel'\n  | 'voiceDesignModel'\n\ninterface StoredProvider {\n  id: string\n  name: string\n  baseUrl?: string\n  apiKey?: string\n  hidden?: boolean\n  apiMode?: ApiModeType\n  gatewayRoute?: GatewayRouteType\n}\n\ninterface StoredModelLlmCustomPricing {\n  inputPerMillion?: number\n  outputPerMillion?: number\n}\n\ninterface StoredModelMediaCustomPricing {\n  basePrice?: number\n  optionPrices?: Record<string, Record<string, number>>\n}\n\ninterface StoredModelCustomPricing {\n  llm?: StoredModelLlmCustomPricing\n  image?: StoredModelMediaCustomPricing\n  video?: StoredModelMediaCustomPricing\n}\n\ninterface StoredModel {\n  modelId: string\n  modelKey: string\n  name: string\n  type: UnifiedModelType\n  provider: string\n  llmProtocol?: LlmProtocolType\n  llmProtocolCheckedAt?: string\n  compatMediaTemplate?: OpenAICompatMediaTemplate\n  compatMediaTemplateCheckedAt?: string\n  compatMediaTemplateSource?: OpenAICompatMediaTemplateSource\n  // Non-authoritative display field; billing always uses server pricing catalog.\n  price: number\n  priceMin?: number\n  priceMax?: number\n  priceLabel?: string\n  priceInput?: number\n  priceOutput?: number\n  capabilities?: ModelCapabilities\n  customPricing?: StoredModelCustomPricing\n}\n\ninterface PricingDisplayItem {\n  min: number\n  max: number\n  label: string\n  input?: number\n  output?: number\n}\n\ntype PricingDisplayMap = Record<string, PricingDisplayItem>\n\ninterface DefaultModelsPayload {\n  analysisModel?: string\n  characterModel?: string\n  locationModel?: string\n  storyboardModel?: string\n  editModel?: string\n  videoModel?: string\n  audioModel?: string\n  lipSyncModel?: string\n  voiceDesignModel?: string\n}\n\ninterface WorkflowConcurrencyPayload {\n  analysis?: number\n  image?: number\n  video?: number\n}\n\ninterface ApiConfigPutBody {\n  models?: unknown\n  providers?: unknown\n  defaultModels?: unknown\n  capabilityDefaults?: unknown\n  workflowConcurrency?: unknown\n}\n\nconst DEFAULT_MODEL_FIELDS: DefaultModelField[] = [\n  'analysisModel',\n  'characterModel',\n  'locationModel',\n  'storyboardModel',\n  'editModel',\n  'videoModel',\n  'audioModel',\n  'lipSyncModel',\n  'voiceDesignModel',\n]\nconst CAPABILITY_MODEL_TYPES: readonly UnifiedModelType[] = [\n  'image',\n  'video',\n  'llm',\n  'audio',\n  'lipsync',\n]\nconst BILLABLE_MODEL_TYPE_TO_PRICING_API_TYPE: Readonly<Record<UnifiedModelType, PricingApiType | null>> = {\n  llm: 'text',\n  image: 'image',\n  video: 'video',\n  audio: 'voice',\n  lipsync: 'lip-sync',\n}\nconst DEFAULT_FIELD_TO_PRICING_API_TYPE: Readonly<Record<DefaultModelField, 'text' | 'image' | 'video' | 'voice' | 'lip-sync'>> = {\n  analysisModel: 'text',\n  characterModel: 'image',\n  locationModel: 'image',\n  storyboardModel: 'image',\n  editModel: 'image',\n  videoModel: 'video',\n  audioModel: 'voice',\n  lipSyncModel: 'lip-sync',\n  voiceDesignModel: 'voice',\n}\nconst DEFAULT_LIPSYNC_MODEL_KEY = composeModelKey('fal', 'fal-ai/kling-video/lipsync/audio-to-video')\n\n/**\n * Provider keys that share pricing/capability catalogs with a canonical provider.\n * gemini-compatible uses the same models/pricing as google.\n */\nconst PRICING_PROVIDER_ALIASES: Readonly<Record<string, string>> = {\n  'gemini-compatible': 'google',\n}\nconst OPTIONAL_PRICING_PROVIDER_KEYS = new Set([\n  'openai-compatible',\n  'gemini-compatible',\n  'bailian',\n  'siliconflow',\n])\nconst OFFICIAL_ONLY_PROVIDER_KEYS = new Set(['bailian', 'siliconflow'])\nconst RETIRED_PROVIDER_KEYS = new Set(['qwen'])\nconst MINIMAX_OFFICIAL_BASE_URL = 'https://api.minimaxi.com/v1'\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction normalizeMinimaxProviderBaseUrl(input: {\n  providerId: string\n  baseUrl?: string\n  strict: boolean\n  field: string\n}): string | undefined {\n  if (getProviderKey(input.providerId) !== 'minimax') return input.baseUrl\n  if (!input.baseUrl) return MINIMAX_OFFICIAL_BASE_URL\n  if (input.baseUrl === MINIMAX_OFFICIAL_BASE_URL) return MINIMAX_OFFICIAL_BASE_URL\n  if (input.strict) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'PROVIDER_BASEURL_INVALID',\n      field: input.field,\n    })\n  }\n  return MINIMAX_OFFICIAL_BASE_URL\n}\n\nfunction formatPriceAmount(amount: number): string {\n  const fixed = amount.toFixed(4)\n  const normalized = fixed.replace(/\\.?0+$/, '')\n  return normalized || '0'\n}\n\nfunction pricingApiTypeToModelType(apiType: PricingApiType): UnifiedModelType | null {\n  if (apiType === 'text') return 'llm'\n  if (apiType === 'image') return 'image'\n  if (apiType === 'video') return 'video'\n  if (apiType === 'voice') return 'audio'\n  if (apiType === 'lip-sync') return 'lipsync'\n  return null\n}\n\nfunction composePricingDisplayKey(modelType: UnifiedModelType, provider: string, modelId: string): string {\n  return `${modelType}::${provider}::${modelId}`\n}\n\nfunction resolveVideoDurationRangeFromCapabilities(\n  provider: string,\n  modelId: string,\n): { min: number; max: number } | null {\n  const capabilities = findBuiltinCapabilities('video', provider, modelId)\n  const options = capabilities?.video?.durationOptions\n  if (!Array.isArray(options) || options.length === 0) return null\n\n  const durations = options.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))\n  if (durations.length === 0) return null\n  return {\n    min: Math.min(...durations),\n    max: Math.max(...durations),\n  }\n}\n\nfunction applyVideoDurationRangeIfNeeded(input: {\n  apiType: PricingApiType\n  provider: string\n  modelId: string\n  min: number\n  max: number\n  hasDurationTier: boolean\n}): { min: number; max: number } {\n  if (input.apiType !== 'video') return { min: input.min, max: input.max }\n  if (input.hasDurationTier) return { min: input.min, max: input.max }\n\n  const durationRange = resolveVideoDurationRangeFromCapabilities(input.provider, input.modelId)\n  if (!durationRange) return { min: input.min, max: input.max }\n\n  // Ark/视频展示口径：未显式按秒建 tier 时，现有金额按 5 秒基准估算，区间扩展为 [最短秒, 最长秒]。\n  const BASE_DURATION_SECONDS = durationRange.min <= 5 && durationRange.max >= 5\n    ? 5\n    : durationRange.min\n  if (BASE_DURATION_SECONDS <= 0) return { min: input.min, max: input.max }\n\n  const scaledMin = input.min * (durationRange.min / BASE_DURATION_SECONDS)\n  const scaledMax = input.max * (durationRange.max / BASE_DURATION_SECONDS)\n  return {\n    min: scaledMin,\n    max: scaledMax,\n  }\n}\n\nfunction buildPricingDisplayMap(): PricingDisplayMap {\n  const map: PricingDisplayMap = {}\n  const entries = listBuiltinPricingCatalog()\n\n  for (const entry of entries) {\n    const modelType = pricingApiTypeToModelType(entry.apiType)\n    if (!modelType) continue\n\n    let min = 0\n    let max = 0\n    let input: number | undefined\n    let output: number | undefined\n    if (entry.pricing.mode === 'flat') {\n      const amount = entry.pricing.flatAmount ?? 0\n      min = amount\n      max = amount\n    } else {\n      const tiers = entry.pricing.tiers || []\n      const amounts = tiers.map((tier) => tier.amount)\n      if (amounts.length === 0) continue\n      const hasDurationTier = tiers.some((tier) => typeof tier.when.duration === 'number')\n\n      const durationExpanded = applyVideoDurationRangeIfNeeded({\n        apiType: entry.apiType,\n        provider: entry.provider,\n        modelId: entry.modelId,\n        min: Math.min(...amounts),\n        max: Math.max(...amounts),\n        hasDurationTier,\n      })\n      min = durationExpanded.min\n      max = durationExpanded.max\n\n      if (entry.apiType === 'text') {\n        for (const tier of tiers) {\n          const tokenType = tier.when.tokenType\n          if (tokenType === 'input') input = tier.amount\n          if (tokenType === 'output') output = tier.amount\n        }\n      }\n    }\n\n    map[composePricingDisplayKey(modelType, entry.provider, entry.modelId)] = {\n      min,\n      max,\n      label: min === max\n        ? formatPriceAmount(min)\n        : `${formatPriceAmount(min)}~${formatPriceAmount(max)}`,\n      ...(typeof input === 'number' ? { input } : {}),\n      ...(typeof output === 'number' ? { output } : {}),\n    }\n  }\n\n  return map\n}\n\nfunction resolvePricingDisplayItem(\n  map: PricingDisplayMap,\n  modelType: UnifiedModelType,\n  provider: string,\n  modelId: string,\n): PricingDisplayItem | null {\n  const exact = map[composePricingDisplayKey(modelType, provider, modelId)]\n  if (exact) return exact\n\n  const providerKey = getProviderKey(provider)\n  if (providerKey !== provider) {\n    const fallback = map[composePricingDisplayKey(modelType, providerKey, modelId)]\n    if (fallback) return fallback\n  }\n\n  // Fallback: check canonical provider alias (e.g. gemini-compatible → google)\n  const aliasTarget = PRICING_PROVIDER_ALIASES[providerKey]\n  if (aliasTarget) {\n    const aliasFallback = map[composePricingDisplayKey(modelType, aliasTarget, modelId)]\n    if (aliasFallback) return aliasFallback\n  }\n  return null\n}\n\nfunction withDisplayPricing(model: StoredModel, map: PricingDisplayMap): StoredModel {\n  const display = resolvePricingDisplayItem(map, model.type, model.provider, model.modelId)\n  if (!display) {\n    // Derive display from user custom pricing if available\n    if (model.customPricing) {\n      const llmPricing = model.customPricing.llm\n      if (typeof llmPricing?.inputPerMillion === 'number' && typeof llmPricing.outputPerMillion === 'number') {\n        const minPrice = Math.min(llmPricing.inputPerMillion, llmPricing.outputPerMillion)\n        const maxPrice = Math.max(llmPricing.inputPerMillion, llmPricing.outputPerMillion)\n        return {\n          ...model,\n          price: minPrice,\n          priceMin: minPrice,\n          priceMax: maxPrice,\n          priceLabel: `${formatPriceAmount(minPrice)}~${formatPriceAmount(maxPrice)}`,\n          priceInput: llmPricing.inputPerMillion,\n          priceOutput: llmPricing.outputPerMillion,\n        }\n      }\n\n      const mediaPricing = model.type === 'image'\n        ? model.customPricing.image\n        : model.type === 'video'\n          ? model.customPricing.video\n          : undefined\n      if (mediaPricing) {\n        const basePrice = typeof mediaPricing.basePrice === 'number' ? mediaPricing.basePrice : 0\n        let minExtra = 0\n        let maxExtra = 0\n        if (mediaPricing.optionPrices) {\n          for (const optionMap of Object.values(mediaPricing.optionPrices)) {\n            const values = Object.values(optionMap).filter((value) => Number.isFinite(value))\n            if (values.length === 0) continue\n            minExtra += Math.min(...values)\n            maxExtra += Math.max(...values)\n          }\n        }\n        const minPrice = basePrice + minExtra\n        const maxPrice = basePrice + maxExtra\n        return {\n          ...model,\n          price: minPrice,\n          priceMin: minPrice,\n          priceMax: maxPrice,\n          priceLabel: minPrice === maxPrice\n            ? formatPriceAmount(minPrice)\n            : `${formatPriceAmount(minPrice)}~${formatPriceAmount(maxPrice)}`,\n        }\n      }\n    }\n    return {\n      ...model,\n      price: 0,\n      priceLabel: '--',\n      priceMin: undefined,\n      priceMax: undefined,\n    }\n  }\n\n  return {\n    ...model,\n    price: display.min,\n    priceMin: display.min,\n    priceMax: display.max,\n    priceLabel: display.label,\n    ...(typeof display.input === 'number' ? { priceInput: display.input } : {}),\n    ...(typeof display.output === 'number' ? { priceOutput: display.output } : {}),\n  }\n}\n\nfunction getProviderKey(providerId: string): string {\n  const index = providerId.indexOf(':')\n  return index === -1 ? providerId : providerId.slice(0, index)\n}\n\nfunction isUnifiedModelType(value: unknown): value is UnifiedModelType {\n  return (\n    value === 'llm'\n    || value === 'image'\n    || value === 'video'\n    || value === 'audio'\n    || value === 'lipsync'\n  )\n}\n\nfunction isApiMode(value: unknown): value is ApiModeType {\n  return value === 'gemini-sdk' || value === 'openai-official'\n}\n\nfunction isGatewayRoute(value: unknown): value is GatewayRouteType {\n  return value === 'official' || value === 'openai-compat'\n}\n\nfunction isLlmProtocol(value: unknown): value is LlmProtocolType {\n  return value === 'responses' || value === 'chat-completions'\n}\n\nfunction isMediaTemplateSource(value: unknown): value is OpenAICompatMediaTemplateSource {\n  return value === 'ai' || value === 'manual'\n}\n\nfunction resolveProviderGatewayRoute(\n  providerId: string,\n  rawGatewayRoute: unknown,\n): GatewayRouteType {\n  const providerKey = getProviderKey(providerId)\n  const isOpenAICompatibleProvider = providerKey === 'openai-compatible'\n  const isGeminiCompatibleProvider = providerKey === 'gemini-compatible'\n\n  if (rawGatewayRoute !== undefined && !isGatewayRoute(rawGatewayRoute)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'PROVIDER_GATEWAY_ROUTE_INVALID',\n    })\n  }\n\n  if (isOpenAICompatibleProvider) {\n    if (rawGatewayRoute === 'official') {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_GATEWAY_ROUTE_INVALID',\n      })\n    }\n    return 'openai-compat'\n  }\n\n  if (isGeminiCompatibleProvider) {\n    if (rawGatewayRoute === 'openai-compat') {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_GATEWAY_ROUTE_INVALID',\n      })\n    }\n    return 'official'\n  }\n\n  if (OFFICIAL_ONLY_PROVIDER_KEYS.has(providerKey)) {\n    if (rawGatewayRoute === 'openai-compat') {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_GATEWAY_ROUTE_INVALID',\n      })\n    }\n    return 'official'\n  }\n\n  return rawGatewayRoute === 'openai-compat' ? 'openai-compat' : 'official'\n}\n\nfunction resolveProviderByIdOrKey(providers: StoredProvider[], providerId: string): StoredProvider | null {\n  const exact = providers.find((provider) => provider.id === providerId)\n  if (exact) return exact\n\n  const providerKey = getProviderKey(providerId)\n  const candidates = providers.filter((provider) => getProviderKey(provider.id) === providerKey)\n  if (candidates.length === 0) return null\n  if (candidates.length > 1) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'PROVIDER_AMBIGUOUS',\n      field: 'providers',\n    })\n  }\n\n  return candidates[0]\n}\n\nfunction withBuiltinCapabilities(model: StoredModel): StoredModel {\n  const capabilities = findBuiltinCapabilities(model.type, model.provider, model.modelId)\n  if (!capabilities) {\n    return {\n      ...model,\n      capabilities: undefined,\n    }\n  }\n\n  return {\n    ...model,\n    capabilities,\n  }\n}\n\nfunction readNonNegativeNumber(value: unknown): number | undefined {\n  if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {\n    return undefined\n  }\n  return value\n}\n\nfunction parseNonNegativeNumberStrict(value: unknown, field: string): number | undefined {\n  if (value === undefined || value === null) return undefined\n  const parsed = readNonNegativeNumber(value)\n  if (parsed !== undefined) return parsed\n  throw new ApiError('INVALID_PARAMS', {\n    code: 'MODEL_CUSTOM_PRICING_INVALID',\n    field,\n  })\n}\n\nfunction validateAllowedObjectKeys(\n  raw: Record<string, unknown>,\n  allowed: readonly string[],\n  field: string,\n) {\n  const allowedSet = new Set(allowed)\n  for (const key of Object.keys(raw)) {\n    if (allowedSet.has(key)) continue\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_CUSTOM_PRICING_INVALID',\n      field: `${field}.${key}`,\n    })\n  }\n}\n\nfunction normalizeOptionPrices(\n  raw: unknown,\n  options?: { strict?: boolean; field?: string },\n): Record<string, Record<string, number>> | undefined {\n  if (raw === undefined || raw === null) return undefined\n  if (!isRecord(raw)) {\n    if (options?.strict) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MODEL_CUSTOM_PRICING_INVALID',\n        field: options.field || 'models.customPricing.optionPrices',\n      })\n    }\n    return undefined\n  }\n\n  const normalized: Record<string, Record<string, number>> = {}\n  for (const [field, rawFieldPricing] of Object.entries(raw)) {\n    if (!isRecord(rawFieldPricing)) {\n      if (options?.strict) {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'MODEL_CUSTOM_PRICING_INVALID',\n          field: options.field ? `${options.field}.${field}` : `models.customPricing.optionPrices.${field}`,\n        })\n      }\n      continue\n    }\n    const fieldPricing: Record<string, number> = {}\n    for (const [optionValue, rawAmount] of Object.entries(rawFieldPricing)) {\n      const amount = options?.strict\n        ? parseNonNegativeNumberStrict(\n          rawAmount,\n          options.field\n            ? `${options.field}.${field}.${optionValue}`\n            : `models.customPricing.optionPrices.${field}.${optionValue}`,\n        )\n        : readNonNegativeNumber(rawAmount)\n      if (amount === undefined) continue\n      fieldPricing[optionValue] = amount\n    }\n    if (Object.keys(fieldPricing).length > 0) {\n      normalized[field] = fieldPricing\n    }\n  }\n\n  return Object.keys(normalized).length > 0 ? normalized : undefined\n}\n\nfunction normalizeMediaCustomPricing(\n  raw: unknown,\n  options?: { strict?: boolean; field?: string },\n): StoredModelMediaCustomPricing | undefined {\n  if (raw === undefined || raw === null) return undefined\n  if (!isRecord(raw)) {\n    if (options?.strict) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MODEL_CUSTOM_PRICING_INVALID',\n        field: options.field || 'models.customPricing',\n      })\n    }\n    return undefined\n  }\n  if (options?.strict) {\n    validateAllowedObjectKeys(raw, ['basePrice', 'optionPrices'], options.field || 'models.customPricing')\n  }\n  const basePrice = options?.strict\n    ? parseNonNegativeNumberStrict(raw.basePrice, options.field ? `${options.field}.basePrice` : 'models.customPricing.basePrice')\n    : readNonNegativeNumber(raw.basePrice)\n  const optionPrices = normalizeOptionPrices(raw.optionPrices, {\n    strict: options?.strict,\n    field: options?.field ? `${options.field}.optionPrices` : 'models.customPricing.optionPrices',\n  })\n  if (basePrice === undefined && optionPrices === undefined) return undefined\n\n  return {\n    ...(basePrice !== undefined ? { basePrice } : {}),\n    ...(optionPrices ? { optionPrices } : {}),\n  }\n}\n\nfunction normalizeCustomPricing(\n  raw: unknown,\n  options?: { strict?: boolean; field?: string },\n): StoredModelCustomPricing | undefined {\n  if (raw === undefined || raw === null) return undefined\n  if (!isRecord(raw)) {\n    if (options?.strict) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MODEL_CUSTOM_PRICING_INVALID',\n        field: options.field || 'models.customPricing',\n      })\n    }\n    return undefined\n  }\n  if (options?.strict) {\n    validateAllowedObjectKeys(raw, ['llm', 'image', 'video', 'input', 'output'], options.field || 'models.customPricing')\n  }\n\n  const llmRaw = isRecord(raw.llm) ? raw.llm : raw\n  if (options?.strict && raw.llm !== undefined && !isRecord(raw.llm)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_CUSTOM_PRICING_INVALID',\n      field: options.field ? `${options.field}.llm` : 'models.customPricing.llm',\n    })\n  }\n  if (options?.strict && isRecord(raw.llm)) {\n    validateAllowedObjectKeys(raw.llm, ['inputPerMillion', 'outputPerMillion'], options.field ? `${options.field}.llm` : 'models.customPricing.llm')\n  }\n  const inputPerMillion = options?.strict\n    ? parseNonNegativeNumberStrict(llmRaw.inputPerMillion, options.field ? `${options.field}.llm.inputPerMillion` : 'models.customPricing.llm.inputPerMillion')\n    : readNonNegativeNumber(llmRaw.inputPerMillion)\n  const outputPerMillion = options?.strict\n    ? parseNonNegativeNumberStrict(llmRaw.outputPerMillion, options.field ? `${options.field}.llm.outputPerMillion` : 'models.customPricing.llm.outputPerMillion')\n    : readNonNegativeNumber(llmRaw.outputPerMillion)\n  // Legacy bridge: migrate old shape { input, output } into llm.*\n  const legacyInput = options?.strict\n    ? parseNonNegativeNumberStrict((raw as Record<string, unknown>).input, options.field ? `${options.field}.input` : 'models.customPricing.input')\n    : readNonNegativeNumber((raw as Record<string, unknown>).input)\n  const legacyOutput = options?.strict\n    ? parseNonNegativeNumberStrict((raw as Record<string, unknown>).output, options.field ? `${options.field}.output` : 'models.customPricing.output')\n    : readNonNegativeNumber((raw as Record<string, unknown>).output)\n  const llm = (inputPerMillion !== undefined || outputPerMillion !== undefined || legacyInput !== undefined || legacyOutput !== undefined)\n    ? {\n      ...(inputPerMillion !== undefined ? { inputPerMillion } : {}),\n      ...(outputPerMillion !== undefined ? { outputPerMillion } : {}),\n      ...(inputPerMillion === undefined && legacyInput !== undefined ? { inputPerMillion: legacyInput } : {}),\n      ...(outputPerMillion === undefined && legacyOutput !== undefined ? { outputPerMillion: legacyOutput } : {}),\n    }\n    : undefined\n  if (\n    options?.strict\n    && llm\n    && (\n      typeof llm.inputPerMillion !== 'number'\n      || typeof llm.outputPerMillion !== 'number'\n    )\n  ) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_CUSTOM_PRICING_INVALID',\n      field: options.field ? `${options.field}.llm` : 'models.customPricing.llm',\n    })\n  }\n\n  const image = normalizeMediaCustomPricing(raw.image, {\n    strict: options?.strict,\n    field: options?.field ? `${options.field}.image` : 'models.customPricing.image',\n  })\n  const video = normalizeMediaCustomPricing(raw.video, {\n    strict: options?.strict,\n    field: options?.field ? `${options.field}.video` : 'models.customPricing.video',\n  })\n\n  if (!llm && !image && !video) return undefined\n  return {\n    ...(llm ? { llm } : {}),\n    ...(image ? { image } : {}),\n    ...(video ? { video } : {}),\n  }\n}\n\nfunction normalizeStoredModel(raw: unknown, index: number, options?: { strictCustomPricing?: boolean }): StoredModel {\n  if (!isRecord(raw)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_PAYLOAD_INVALID',\n      field: `models[${index}]`,\n    })\n  }\n\n  const modelType = raw.type\n  if (!isUnifiedModelType(modelType)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_TYPE_INVALID',\n      field: `models[${index}].type`,\n    })\n  }\n\n  const providerFromField = readTrimmedString(raw.provider)\n  const modelIdFromField = readTrimmedString(raw.modelId)\n  const modelKeyFromField = readTrimmedString(raw.modelKey)\n  const parsedModelKey = parseModelKeyStrict(modelKeyFromField)\n\n  const provider = providerFromField || parsedModelKey?.provider || ''\n  const modelId = modelIdFromField || parsedModelKey?.modelId || ''\n  const modelKey = composeModelKey(provider, modelId)\n\n  if (!modelKey) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_INVALID',\n      field: `models[${index}].modelKey`,\n    })\n  }\n  if (modelKeyFromField && (!parsedModelKey || parsedModelKey.modelKey !== modelKey)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_MISMATCH',\n      field: `models[${index}].modelKey`,\n    })\n  }\n\n  const modelName = readTrimmedString(raw.name) || modelId\n\n  const customPricing = normalizeCustomPricing(raw.customPricing, {\n    strict: options?.strictCustomPricing,\n    field: `models[${index}].customPricing`,\n  })\n\n  const llmProtocolRaw = raw.llmProtocol\n  let llmProtocol: LlmProtocolType | undefined\n  if (llmProtocolRaw !== undefined && llmProtocolRaw !== null) {\n    if (!isLlmProtocol(llmProtocolRaw)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MODEL_LLM_PROTOCOL_INVALID',\n        field: `models[${index}].llmProtocol`,\n      })\n    }\n    llmProtocol = llmProtocolRaw\n  }\n  const llmProtocolCheckedAt = readTrimmedString(raw.llmProtocolCheckedAt) || undefined\n\n  const compatMediaTemplateRaw = raw.compatMediaTemplate\n  let compatMediaTemplate: OpenAICompatMediaTemplate | undefined\n  if (compatMediaTemplateRaw !== undefined && compatMediaTemplateRaw !== null) {\n    const validated = validateOpenAICompatMediaTemplate(compatMediaTemplateRaw)\n    if (!validated.ok || !validated.template) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MODEL_COMPAT_MEDIA_TEMPLATE_INVALID',\n        field: `models[${index}].compatMediaTemplate`,\n      })\n    }\n    compatMediaTemplate = validated.template\n  }\n  const compatMediaTemplateCheckedAt = readTrimmedString(raw.compatMediaTemplateCheckedAt) || undefined\n  const compatMediaTemplateSourceRaw = raw.compatMediaTemplateSource\n  let compatMediaTemplateSource: OpenAICompatMediaTemplateSource | undefined\n  if (compatMediaTemplateSourceRaw !== undefined && compatMediaTemplateSourceRaw !== null) {\n    if (!isMediaTemplateSource(compatMediaTemplateSourceRaw)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MODEL_COMPAT_MEDIA_TEMPLATE_SOURCE_INVALID',\n        field: `models[${index}].compatMediaTemplateSource`,\n      })\n    }\n    compatMediaTemplateSource = compatMediaTemplateSourceRaw\n  }\n\n  return {\n    modelId,\n    modelKey,\n    name: modelName,\n    type: modelType,\n    provider,\n    ...(llmProtocol ? { llmProtocol } : {}),\n    ...(llmProtocolCheckedAt ? { llmProtocolCheckedAt } : {}),\n    ...(compatMediaTemplate ? { compatMediaTemplate } : {}),\n    ...(compatMediaTemplateCheckedAt ? { compatMediaTemplateCheckedAt } : {}),\n    ...(compatMediaTemplateSource ? { compatMediaTemplateSource } : {}),\n    price: 0,\n    ...(customPricing ? { customPricing } : {}),\n  }\n}\n\nfunction normalizeProvidersInput(rawProviders: unknown): StoredProvider[] {\n  if (rawProviders === undefined) return []\n  if (!Array.isArray(rawProviders)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'PROVIDER_PAYLOAD_INVALID',\n      field: 'providers',\n    })\n  }\n\n  const normalized: StoredProvider[] = []\n  for (let index = 0; index < rawProviders.length; index += 1) {\n    const item = rawProviders[index]\n    if (!isRecord(item)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_PAYLOAD_INVALID',\n        field: `providers[${index}]`,\n      })\n    }\n    const id = readTrimmedString(item.id)\n    const name = readTrimmedString(item.name)\n    if (!id || !name) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_PAYLOAD_INVALID',\n        field: `providers[${index}]`,\n      })\n    }\n    const normalizedId = id.toLowerCase()\n    const providerKey = getProviderKey(normalizedId)\n    if (RETIRED_PROVIDER_KEYS.has(providerKey)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_NOT_SUPPORTED',\n        field: `providers[${index}].id`,\n      })\n    }\n    if (normalized.some((provider) => provider.id.toLowerCase() === normalizedId)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_DUPLICATE',\n        field: `providers[${index}].id`,\n      })\n    }\n    const apiModeRaw = item.apiMode\n    if (apiModeRaw !== undefined && !isApiMode(apiModeRaw)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_APIMODE_INVALID',\n        field: `providers[${index}].apiMode`,\n      })\n    }\n    if (getProviderKey(id) === 'gemini-compatible' && apiModeRaw === 'openai-official') {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_APIMODE_INVALID',\n        field: `providers[${index}].apiMode`,\n      })\n    }\n    let gatewayRoute: GatewayRouteType\n    try {\n      gatewayRoute = resolveProviderGatewayRoute(id, item.gatewayRoute)\n    } catch (error) {\n      if (error instanceof ApiError) {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'PROVIDER_GATEWAY_ROUTE_INVALID',\n          field: `providers[${index}].gatewayRoute`,\n        })\n      }\n      throw error\n    }\n    const hiddenRaw = item.hidden\n    if (hiddenRaw !== undefined && typeof hiddenRaw !== 'boolean') {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_HIDDEN_INVALID',\n        field: `providers[${index}].hidden`,\n      })\n    }\n\n    const baseUrl = normalizeMinimaxProviderBaseUrl({\n      providerId: id,\n      baseUrl: readTrimmedString(item.baseUrl) || undefined,\n      strict: true,\n      field: `providers[${index}].baseUrl`,\n    })\n\n    normalized.push({\n      id,\n      name,\n      baseUrl,\n      apiKey: typeof item.apiKey === 'string' ? item.apiKey.trim() : undefined,\n      hidden: hiddenRaw === true,\n      apiMode: apiModeRaw,\n      gatewayRoute,\n    })\n  }\n\n  return normalized\n}\n\nfunction normalizeModelList(rawModels: unknown): StoredModel[] {\n  if (rawModels === undefined) return []\n  if (!Array.isArray(rawModels)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_PAYLOAD_INVALID',\n      field: 'models',\n    })\n  }\n\n  return rawModels.map((item, index) => normalizeStoredModel(item, index, { strictCustomPricing: true }))\n}\n\nfunction validateModelProviderConsistency(models: StoredModel[], providers: StoredProvider[]) {\n  for (let index = 0; index < models.length; index += 1) {\n    const model = models[index]\n    const matchedProvider = resolveProviderByIdOrKey(providers, model.provider)\n    if (!matchedProvider) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MODEL_PROVIDER_NOT_FOUND',\n        field: `models[${index}].provider`,\n      })\n    }\n  }\n}\n\nfunction validateModelProviderTypeSupport(models: StoredModel[], providers: StoredProvider[]) {\n  for (let index = 0; index < models.length; index += 1) {\n    const model = models[index]\n    const matchedProvider = resolveProviderByIdOrKey(providers, model.provider)\n    if (!matchedProvider) continue\n\n    const providerKey = getProviderKey(matchedProvider.id)\n    if (model.type === 'lipsync' && providerKey !== 'fal' && providerKey !== 'vidu' && providerKey !== 'bailian') {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MODEL_PROVIDER_TYPE_UNSUPPORTED',\n        field: `models[${index}].provider`,\n      })\n    }\n  }\n}\n\nfunction isOpenAICompatibleLlmModel(model: StoredModel): boolean {\n  return model.type === 'llm' && getProviderKey(model.provider) === 'openai-compatible'\n}\n\nfunction isOpenAICompatibleMediaTemplateModel(model: StoredModel): boolean {\n  if (getProviderKey(model.provider) !== 'openai-compatible') return false\n  return model.type === 'image' || model.type === 'video'\n}\n\nfunction getDefaultMediaTemplate(type: 'image' | 'video'): OpenAICompatMediaTemplate {\n  if (type === 'image') {\n    return {\n      version: 1,\n      mediaType: 'image',\n      mode: 'sync',\n      create: {\n        method: 'POST',\n        path: '/images/generations',\n        contentType: 'application/json',\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n        },\n      },\n      response: {\n        outputUrlPath: '$.data[0].url',\n        outputUrlsPath: '$.data',\n        errorPath: '$.error.message',\n      },\n    }\n  }\n\n  return {\n    version: 1,\n    mediaType: 'video',\n    mode: 'async',\n    create: {\n      method: 'POST',\n      path: '/videos',\n      contentType: 'multipart/form-data',\n      multipartFileFields: ['input_reference'],\n      bodyTemplate: {\n        model: '{{model}}',\n        prompt: '{{prompt}}',\n        seconds: '{{duration}}',\n        size: '{{size}}',\n        input_reference: '{{image}}',\n      },\n    },\n    status: {\n      method: 'GET',\n      path: '/videos/{{task_id}}',\n    },\n    content: {\n      method: 'GET',\n      path: '/videos/{{task_id}}/content',\n    },\n    response: {\n      taskIdPath: '$.id',\n      statusPath: '$.status',\n      errorPath: '$.error.message',\n    },\n    polling: {\n      intervalMs: 3000,\n      timeoutMs: 600000,\n      doneStates: ['completed', 'succeeded'],\n      failStates: ['failed', 'error', 'canceled'],\n    },\n  }\n}\n\nfunction resolveStoredLlmProtocols(\n  models: StoredModel[],\n  existingModels: StoredModel[],\n): StoredModel[] {\n  const existingByModelKey = new Map(existingModels.map((model) => [model.modelKey, model] as const))\n  const checkedAtFallback = new Date().toISOString()\n\n  return models.map((model, index) => {\n    const isTargetModel = isOpenAICompatibleLlmModel(model)\n\n    if (!isTargetModel) {\n      if (model.llmProtocol !== undefined || model.llmProtocolCheckedAt !== undefined) {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'MODEL_LLM_PROTOCOL_NOT_ALLOWED',\n          field: `models[${index}].llmProtocol`,\n        })\n      }\n      return model\n    }\n\n    if (model.llmProtocol) {\n      return {\n        ...model,\n        llmProtocolCheckedAt: model.llmProtocolCheckedAt || checkedAtFallback,\n      }\n    }\n\n    const existing = existingByModelKey.get(model.modelKey)\n    if (existing?.llmProtocol) {\n      return {\n        ...model,\n        llmProtocol: existing.llmProtocol,\n        llmProtocolCheckedAt: existing.llmProtocolCheckedAt || checkedAtFallback,\n      }\n    }\n    if (existing) {\n      return {\n        ...model,\n        llmProtocol: 'chat-completions',\n        llmProtocolCheckedAt: checkedAtFallback,\n      }\n    }\n\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_LLM_PROTOCOL_REQUIRED',\n      field: `models[${index}].llmProtocol`,\n    })\n  })\n}\n\nfunction resolveStoredMediaTemplates(\n  models: StoredModel[],\n  existingModels: StoredModel[],\n): StoredModel[] {\n  const existingByModelKey = new Map(existingModels.map((model) => [model.modelKey, model] as const))\n  const checkedAtFallback = new Date().toISOString()\n\n  return models.map((model, index) => {\n    const isTargetModel = isOpenAICompatibleMediaTemplateModel(model)\n\n    if (!isTargetModel) {\n      if (model.compatMediaTemplate !== undefined || model.compatMediaTemplateCheckedAt !== undefined || model.compatMediaTemplateSource !== undefined) {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'MODEL_COMPAT_MEDIA_TEMPLATE_NOT_ALLOWED',\n          field: `models[${index}].compatMediaTemplate`,\n        })\n      }\n      return model\n    }\n\n    const expectedMediaType = model.type === 'image' ? 'image' : 'video'\n    if (model.compatMediaTemplate) {\n      if (model.compatMediaTemplate.mediaType !== expectedMediaType) {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'MODEL_COMPAT_MEDIA_TEMPLATE_MEDIATYPE_MISMATCH',\n          field: `models[${index}].compatMediaTemplate.mediaType`,\n        })\n      }\n      return {\n        ...model,\n        compatMediaTemplateCheckedAt: model.compatMediaTemplateCheckedAt || checkedAtFallback,\n        compatMediaTemplateSource: model.compatMediaTemplateSource || 'ai',\n      }\n    }\n\n    const existing = existingByModelKey.get(model.modelKey)\n    if (existing?.compatMediaTemplate) {\n      return {\n        ...model,\n        compatMediaTemplate: existing.compatMediaTemplate,\n        compatMediaTemplateCheckedAt: existing.compatMediaTemplateCheckedAt || checkedAtFallback,\n        compatMediaTemplateSource: existing.compatMediaTemplateSource || 'manual',\n      }\n    }\n\n    return {\n      ...model,\n      compatMediaTemplate: getDefaultMediaTemplate(expectedMediaType),\n      compatMediaTemplateCheckedAt: checkedAtFallback,\n      compatMediaTemplateSource: 'manual',\n    }\n  })\n}\n\nfunction validateCustomPricingCapabilityMappings(models: StoredModel[]) {\n  for (let index = 0; index < models.length; index += 1) {\n    const model = models[index]\n    if (model.type !== 'image' && model.type !== 'video') continue\n\n    const mediaPricing = model.type === 'image'\n      ? model.customPricing?.image\n      : model.customPricing?.video\n    const optionPrices = mediaPricing?.optionPrices\n    if (!optionPrices || Object.keys(optionPrices).length === 0) continue\n\n    const context = resolveBuiltinModelContext(model.type, model.modelKey)\n    if (!context) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'CAPABILITY_MODEL_UNSUPPORTED',\n        field: `models[${index}].customPricing.${model.type}.optionPrices`,\n      })\n    }\n\n    const optionFields = getCapabilityOptionFields(model.type, context.capabilities)\n    for (const [field, optionMap] of Object.entries(optionPrices)) {\n      const allowedValues = optionFields[field]\n      if (!allowedValues) {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'CAPABILITY_FIELD_INVALID',\n          field: `models[${index}].customPricing.${model.type}.optionPrices.${field}`,\n        })\n      }\n      for (const optionValue of Object.keys(optionMap)) {\n        if (allowedValues.includes(optionValue)) continue\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'CAPABILITY_VALUE_NOT_ALLOWED',\n          field: `models[${index}].customPricing.${model.type}.optionPrices.${field}.${optionValue}`,\n          allowedValues,\n        })\n      }\n    }\n  }\n}\n\n\n\nfunction hasBuiltinPricingForModel(apiType: PricingApiType, provider: string, modelId: string): boolean {\n  // findBuiltinPricingCatalogEntry handles providerKey stripping and alias fallback internally\n  return !!findBuiltinPricingCatalogEntry(apiType, provider, modelId)\n}\n\nfunction hasCustomPricingForType(model: StoredModel): boolean {\n  if (!model.customPricing) return false\n  if (model.type === 'llm') {\n    return (\n      typeof model.customPricing.llm?.inputPerMillion === 'number'\n      && typeof model.customPricing.llm?.outputPerMillion === 'number'\n    )\n  }\n  if (model.type === 'image') {\n    const imagePricing = model.customPricing.image\n    return (\n      typeof imagePricing?.basePrice === 'number'\n      || (isRecord(imagePricing?.optionPrices) && Object.keys(imagePricing.optionPrices).length > 0)\n    )\n  }\n  if (model.type === 'video') {\n    const videoPricing = model.customPricing.video\n    return (\n      typeof videoPricing?.basePrice === 'number'\n      || (isRecord(videoPricing?.optionPrices) && Object.keys(videoPricing.optionPrices).length > 0)\n    )\n  }\n  return false\n}\n\nfunction validateBillableModelPricing(models: StoredModel[]) {\n  for (let index = 0; index < models.length; index += 1) {\n    const model = models[index]\n    const apiType = BILLABLE_MODEL_TYPE_TO_PRICING_API_TYPE[model.type]\n    if (!apiType) continue\n\n    // Skip validation if user provided custom pricing\n    if (hasCustomPricingForType(model)) continue\n    if (OPTIONAL_PRICING_PROVIDER_KEYS.has(getProviderKey(model.provider))) continue\n\n    if (!hasBuiltinPricingForModel(apiType, model.provider, model.modelId)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'MODEL_PRICING_NOT_CONFIGURED',\n        field: `models[${index}].modelId`,\n        modelKey: model.modelKey,\n        apiType,\n      })\n    }\n  }\n}\n\nfunction validateDefaultModelKey(field: DefaultModelField, value: unknown): string | null {\n  // Contract anchor: default model key must be provider::modelId\n  if (value === undefined) return null\n  const modelKey = readTrimmedString(value)\n  if (!modelKey) return null\n  const parsed = parseModelKeyStrict(modelKey)\n  if (!parsed) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_KEY_INVALID',\n      field: `defaultModels.${field}`,\n    })\n  }\n  return parsed.modelKey\n}\n\nfunction normalizeDefaultModelsInput(rawDefaultModels: unknown): DefaultModelsPayload {\n  if (rawDefaultModels === undefined) return {}\n  if (!isRecord(rawDefaultModels)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'DEFAULT_MODELS_INVALID',\n      field: 'defaultModels',\n    })\n  }\n\n  const normalized: DefaultModelsPayload = {}\n  for (const field of DEFAULT_MODEL_FIELDS) {\n    if (rawDefaultModels[field] !== undefined) {\n      normalized[field] = validateDefaultModelKey(field, rawDefaultModels[field]) || ''\n    }\n  }\n\n  return normalized\n}\n\nfunction normalizeWorkflowConcurrencyInput(rawWorkflowConcurrency: unknown): WorkflowConcurrencyPayload {\n  if (rawWorkflowConcurrency === undefined) return {}\n  if (!isRecord(rawWorkflowConcurrency)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'INVALID_PARAMS',\n      field: 'workflowConcurrency',\n    })\n  }\n\n  const normalized: WorkflowConcurrencyPayload = {}\n\n  if (rawWorkflowConcurrency.analysis !== undefined) {\n    const value = normalizeWorkflowConcurrencyValue(\n      rawWorkflowConcurrency.analysis,\n      DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY,\n    )\n    if (value !== rawWorkflowConcurrency.analysis) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'INVALID_PARAMS',\n        field: 'workflowConcurrency.analysis',\n      })\n    }\n    normalized.analysis = value\n  }\n\n  if (rawWorkflowConcurrency.image !== undefined) {\n    const value = normalizeWorkflowConcurrencyValue(\n      rawWorkflowConcurrency.image,\n      DEFAULT_IMAGE_WORKFLOW_CONCURRENCY,\n    )\n    if (value !== rawWorkflowConcurrency.image) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'INVALID_PARAMS',\n        field: 'workflowConcurrency.image',\n      })\n    }\n    normalized.image = value\n  }\n\n  if (rawWorkflowConcurrency.video !== undefined) {\n    const value = normalizeWorkflowConcurrencyValue(\n      rawWorkflowConcurrency.video,\n      DEFAULT_VIDEO_WORKFLOW_CONCURRENCY,\n    )\n    if (value !== rawWorkflowConcurrency.video) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'INVALID_PARAMS',\n        field: 'workflowConcurrency.video',\n      })\n    }\n    normalized.video = value\n  }\n\n  return normalized\n}\n\nfunction validateDefaultModelPricing(defaultModels: DefaultModelsPayload) {\n  for (const field of DEFAULT_MODEL_FIELDS) {\n    const modelKey = defaultModels[field]\n    if (!modelKey) continue\n\n    const parsed = parseModelKeyStrict(modelKey)\n    if (!parsed) continue\n    if (OPTIONAL_PRICING_PROVIDER_KEYS.has(getProviderKey(parsed.provider))) continue\n    const apiType = DEFAULT_FIELD_TO_PRICING_API_TYPE[field]\n\n    if (!hasBuiltinPricingForModel(apiType, parsed.provider, parsed.modelId)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'DEFAULT_MODEL_PRICING_NOT_CONFIGURED',\n        field: `defaultModels.${field}`,\n        modelKey: parsed.modelKey,\n        apiType,\n      })\n    }\n  }\n}\n\nfunction isModelPricedForBilling(model: StoredModel): boolean {\n  const apiType = BILLABLE_MODEL_TYPE_TO_PRICING_API_TYPE[model.type]\n  if (!apiType) return true\n  if (hasCustomPricingForType(model)) return true\n  if (OPTIONAL_PRICING_PROVIDER_KEYS.has(getProviderKey(model.provider))) return true\n  return hasBuiltinPricingForModel(apiType, model.provider, model.modelId)\n}\n\nfunction sanitizeModelsForBilling(models: StoredModel[]): StoredModel[] {\n  return models.filter((model) => isModelPricedForBilling(model))\n}\n\nfunction sanitizeDefaultModelsForBilling(defaultModels: DefaultModelsPayload): DefaultModelsPayload {\n  const sanitized: DefaultModelsPayload = {}\n\n  for (const field of DEFAULT_MODEL_FIELDS) {\n    const rawModelKey = defaultModels[field]\n    if (rawModelKey === undefined) continue\n    const modelKey = readTrimmedString(rawModelKey)\n    if (!modelKey) {\n      sanitized[field] = ''\n      continue\n    }\n\n    const parsed = parseModelKeyStrict(modelKey)\n    if (!parsed) {\n      sanitized[field] = ''\n      continue\n    }\n    if (OPTIONAL_PRICING_PROVIDER_KEYS.has(getProviderKey(parsed.provider))) {\n      sanitized[field] = parsed.modelKey\n      continue\n    }\n\n    const apiType = DEFAULT_FIELD_TO_PRICING_API_TYPE[field]\n    sanitized[field] = hasBuiltinPricingForModel(apiType, parsed.provider, parsed.modelId)\n      ? parsed.modelKey\n      : ''\n  }\n\n  return sanitized\n}\n\nfunction parseStoredProviders(rawProviders: string | null | undefined): StoredProvider[] {\n  if (!rawProviders) return []\n  let parsedUnknown: unknown\n  try {\n    parsedUnknown = JSON.parse(rawProviders)\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'PROVIDER_PAYLOAD_INVALID',\n      field: 'customProviders',\n    })\n  }\n  if (!Array.isArray(parsedUnknown)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'PROVIDER_PAYLOAD_INVALID',\n      field: 'customProviders',\n    })\n  }\n\n  const normalized: StoredProvider[] = []\n  for (let index = 0; index < parsedUnknown.length; index += 1) {\n    const raw = parsedUnknown[index]\n    if (!isRecord(raw)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_PAYLOAD_INVALID',\n        field: `customProviders[${index}]`,\n      })\n    }\n\n    const id = readTrimmedString(raw.id)\n    const name = readTrimmedString(raw.name)\n    if (!id || !name) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_PAYLOAD_INVALID',\n        field: `customProviders[${index}]`,\n      })\n    }\n\n    const providerKey = getProviderKey(id)\n    const apiModeRaw = raw.apiMode\n    let apiMode: ApiModeType | undefined\n    if (apiModeRaw !== undefined) {\n      if (!isApiMode(apiModeRaw)) {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'PROVIDER_APIMODE_INVALID',\n          field: `customProviders[${index}].apiMode`,\n        })\n      }\n      if (providerKey === 'gemini-compatible' && apiModeRaw === 'openai-official') {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'PROVIDER_APIMODE_INVALID',\n          field: `customProviders[${index}].apiMode`,\n        })\n      }\n      apiMode = apiModeRaw\n    }\n\n    let gatewayRoute: GatewayRouteType\n    try {\n      gatewayRoute = resolveProviderGatewayRoute(id, raw.gatewayRoute)\n    } catch {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_GATEWAY_ROUTE_INVALID',\n        field: `customProviders[${index}].gatewayRoute`,\n      })\n    }\n    const hiddenRaw = raw.hidden\n    if (hiddenRaw !== undefined && typeof hiddenRaw !== 'boolean') {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'PROVIDER_HIDDEN_INVALID',\n        field: `customProviders[${index}].hidden`,\n      })\n    }\n\n    const baseUrl = normalizeMinimaxProviderBaseUrl({\n      providerId: id,\n      baseUrl: readTrimmedString(raw.baseUrl) || undefined,\n      strict: false,\n      field: `customProviders[${index}].baseUrl`,\n    })\n\n    normalized.push({\n      id,\n      name,\n      baseUrl,\n      apiKey: typeof raw.apiKey === 'string' ? raw.apiKey.trim() : undefined,\n      hidden: hiddenRaw === true,\n      apiMode,\n      gatewayRoute,\n    })\n  }\n\n  return normalized\n}\n\nfunction parseStoredModels(rawModels: string | null | undefined): StoredModel[] {\n  if (!rawModels) return []\n  let parsedUnknown: unknown\n  try {\n    parsedUnknown = JSON.parse(rawModels)\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_PAYLOAD_INVALID',\n      field: 'customModels',\n    })\n  }\n  if (!Array.isArray(parsedUnknown)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_PAYLOAD_INVALID',\n      field: 'customModels',\n    })\n  }\n  const normalized: StoredModel[] = []\n  for (let index = 0; index < parsedUnknown.length; index += 1) {\n    normalized.push(withBuiltinCapabilities(normalizeStoredModel(parsedUnknown[index], index)))\n  }\n  return normalized\n}\n\nfunction normalizeCapabilitySelectionsInput(\n  raw: unknown,\n  options?: { allowLegacyAspectRatio?: boolean },\n): CapabilitySelections {\n  if (raw === undefined || raw === null) return {}\n  if (!isRecord(raw)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'CAPABILITY_SELECTION_INVALID',\n      field: 'capabilityDefaults',\n    })\n  }\n\n  const normalized: CapabilitySelections = {}\n  for (const [modelKey, rawSelection] of Object.entries(raw)) {\n    if (!isRecord(rawSelection)) {\n      throw new ApiError('INVALID_PARAMS', {\n        code: 'CAPABILITY_SELECTION_INVALID',\n        field: `capabilityDefaults.${modelKey}`,\n      })\n    }\n\n    const selection: Record<string, string | number | boolean> = {}\n    for (const [field, value] of Object.entries(rawSelection)) {\n      if (field === 'aspectRatio') {\n        if (options?.allowLegacyAspectRatio) continue\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'CAPABILITY_FIELD_INVALID',\n          field: `capabilityDefaults.${modelKey}.${field}`,\n        })\n      }\n      if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {\n        throw new ApiError('INVALID_PARAMS', {\n          code: 'CAPABILITY_SELECTION_INVALID',\n          field: `capabilityDefaults.${modelKey}.${field}`,\n        })\n      }\n      selection[field] = value\n    }\n\n    if (Object.keys(selection).length > 0) {\n      normalized[modelKey] = selection\n    }\n  }\n\n  return normalized\n}\n\nfunction parseStoredCapabilitySelections(raw: string | null | undefined, field: string): CapabilitySelections {\n  if (!raw) return {}\n\n  let parsed: unknown\n  try {\n    parsed = JSON.parse(raw)\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'CAPABILITY_SELECTION_INVALID',\n      field,\n    })\n  }\n\n  return normalizeCapabilitySelectionsInput(parsed, { allowLegacyAspectRatio: true })\n}\n\nfunction serializeCapabilitySelections(selections: CapabilitySelections): string | null {\n  if (Object.keys(selections).length === 0) return null\n  return JSON.stringify(selections)\n}\n\nfunction buildStoredModelMap(models: StoredModel[]): Map<string, StoredModel> {\n  const modelMap = new Map<string, StoredModel>()\n  for (const model of models) {\n    modelMap.set(model.modelKey, model)\n  }\n  return modelMap\n}\n\nfunction resolveCapabilityContextForModelKey(\n  modelMap: Map<string, StoredModel>,\n  modelKey: string,\n) {\n  const model = modelMap.get(modelKey)\n  if (model) {\n    return resolveBuiltinModelContext(model.type, model.modelKey) || null\n  }\n\n  if (!parseModelKeyStrict(modelKey)) return null\n  for (const modelType of CAPABILITY_MODEL_TYPES) {\n    const context = resolveBuiltinModelContext(modelType, modelKey)\n    if (context) return context\n  }\n  return null\n}\n\nfunction sanitizeCapabilitySelectionsAgainstModels(\n  selections: CapabilitySelections,\n  models: StoredModel[],\n): CapabilitySelections {\n  const modelMap = buildStoredModelMap(models)\n  const sanitized: CapabilitySelections = {}\n\n  for (const [modelKey, selection] of Object.entries(selections)) {\n    const context = resolveCapabilityContextForModelKey(modelMap, modelKey)\n    if (!context) continue\n\n    const optionFields = getCapabilityOptionFields(context.modelType, context.capabilities)\n    if (Object.keys(optionFields).length === 0) continue\n\n    const cleanedSelection: Record<string, string | number | boolean> = {}\n    for (const [field, value] of Object.entries(selection)) {\n      const allowedValues = optionFields[field]\n      if (!allowedValues) continue\n      if (!allowedValues.includes(value)) continue\n      cleanedSelection[field] = value\n    }\n\n    if (Object.keys(cleanedSelection).length > 0) {\n      sanitized[modelKey] = cleanedSelection\n    }\n  }\n\n  return sanitized\n}\n\nfunction validateCapabilitySelectionsAgainstModels(\n  selections: CapabilitySelections,\n  models: StoredModel[],\n) {\n  const modelMap = buildStoredModelMap(models)\n  const issues = validateCapabilitySelectionsPayload(\n    selections,\n    (modelKey) => resolveCapabilityContextForModelKey(modelMap, modelKey),\n  )\n\n  if (issues.length > 0) {\n    const firstIssue = issues[0]\n    throw new ApiError('INVALID_PARAMS', {\n      code: firstIssue.code,\n      field: firstIssue.field,\n      allowedValues: firstIssue.allowedValues,\n    })\n  }\n}\n\nexport const GET = apiHandler(async () => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const userId = session.user.id\n\n  const pref = await prisma.userPreference.findUnique({\n    where: { userId },\n    select: {\n      customModels: true,\n      customProviders: true,\n      analysisModel: true,\n      characterModel: true,\n      locationModel: true,\n      storyboardModel: true,\n      editModel: true,\n      videoModel: true,\n      audioModel: true,\n      lipSyncModel: true,\n      voiceDesignModel: true,\n      capabilityDefaults: true,\n      analysisConcurrency: true,\n      imageConcurrency: true,\n      videoConcurrency: true,\n    },\n  })\n\n  const providers = parseStoredProviders(pref?.customProviders).map((provider) => ({\n    ...provider,\n    apiKey: provider.apiKey ? decryptApiKey(provider.apiKey) : '',\n  }))\n\n  const billingMode = await getBillingMode()\n  const parsedModels = parseStoredModels(pref?.customModels)\n  const models = billingMode === 'OFF' ? parsedModels : sanitizeModelsForBilling(parsedModels)\n  const pricingDisplay = buildPricingDisplayMap()\n  const pricedModels = models.map((model) => withDisplayPricing(model, pricingDisplay))\n\n  // 对每个 gemini-compatible provider，注入尚未保存过的 Google preset 模型（disabled，带完整 capabilities）\n  // gemini-compatible 本质就是改了 baseURL 和 key，模型和能力与 Google 官方完全一致\n  const GEMINI_COMPATIBLE_PRESETS: { type: UnifiedModelType; modelId: string; name: string }[] = [\n    { type: 'llm', modelId: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro' },\n    { type: 'llm', modelId: 'gemini-3-flash-preview', name: 'Gemini 3 Flash' },\n    { type: 'llm', modelId: 'gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash-Lite' },\n    { type: 'image', modelId: 'gemini-3-pro-image-preview', name: 'Banana Pro' },\n    { type: 'image', modelId: 'gemini-3.1-flash-image-preview', name: 'Nano Banana 2' },\n    { type: 'image', modelId: 'gemini-2.5-flash-image', name: 'Gemini 2.5 Flash Image' },\n    { type: 'image', modelId: 'imagen-4.0-generate-001', name: 'Imagen 4' },\n    { type: 'image', modelId: 'imagen-4.0-ultra-generate-001', name: 'Imagen 4 Ultra' },\n    { type: 'image', modelId: 'imagen-4.0-fast-generate-001', name: 'Imagen 4 Fast' },\n    { type: 'video', modelId: 'veo-3.1-generate-preview', name: 'Veo 3.1' },\n    { type: 'video', modelId: 'veo-3.1-fast-generate-preview', name: 'Veo 3.1 Fast' },\n    { type: 'video', modelId: 'veo-3.0-generate-001', name: 'Veo 3.0' },\n    { type: 'video', modelId: 'veo-3.0-fast-generate-001', name: 'Veo 3.0 Fast' },\n    { type: 'video', modelId: 'veo-2.0-generate-001', name: 'Veo 2.0' },\n  ]\n  const savedModelKeys = new Set(pricedModels.map((m) => m.modelKey))\n  const disabledPresets: (StoredModel & { enabled: false })[] = []\n  for (const p of providers) {\n    if (getProviderKey(p.id) !== 'gemini-compatible') continue\n    for (const preset of GEMINI_COMPATIBLE_PRESETS) {\n      const modelKey = composeModelKey(p.id, preset.modelId)\n      if (!modelKey || savedModelKeys.has(modelKey)) continue\n      savedModelKeys.add(modelKey)\n      const base: StoredModel = {\n        modelId: preset.modelId,\n        modelKey,\n        name: preset.name,\n        type: preset.type,\n        provider: p.id,\n        price: 0,\n        // alias 回退自动从 google catalog 获取 capabilities\n        capabilities: findBuiltinCapabilities(preset.type, p.id, preset.modelId),\n      }\n      disabledPresets.push({ ...withDisplayPricing(base, pricingDisplay), enabled: false })\n    }\n  }\n\n  const rawDefaults: DefaultModelsPayload = {\n    analysisModel: pref?.analysisModel || '',\n    characterModel: pref?.characterModel || '',\n    locationModel: pref?.locationModel || '',\n    storyboardModel: pref?.storyboardModel || '',\n    editModel: pref?.editModel || '',\n    videoModel: pref?.videoModel || '',\n    audioModel: pref?.audioModel || '',\n    lipSyncModel: pref?.lipSyncModel || DEFAULT_LIPSYNC_MODEL_KEY,\n    voiceDesignModel: pref?.voiceDesignModel || '',\n  }\n  const defaultModels = billingMode === 'OFF'\n    ? rawDefaults\n    : sanitizeDefaultModelsForBilling(rawDefaults)\n  const capabilityDefaults = sanitizeCapabilitySelectionsAgainstModels(\n    parseStoredCapabilitySelections(pref?.capabilityDefaults, 'capabilityDefaults'),\n    [...models, ...disabledPresets],\n  )\n  const workflowConcurrency = normalizeWorkflowConcurrencyConfig({\n    analysis: pref?.analysisConcurrency,\n    image: pref?.imageConcurrency,\n    video: pref?.videoConcurrency,\n  })\n\n  return NextResponse.json({\n    models: [...pricedModels, ...disabledPresets],\n    providers,\n    defaultModels,\n    capabilityDefaults,\n    workflowConcurrency,\n    pricingDisplay,\n  })\n})\n\nexport const PUT = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const userId = session.user.id\n\n  let body: ApiConfigPutBody\n  try {\n    body = (await request.json()) as ApiConfigPutBody\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'BODY_PARSE_FAILED',\n      field: 'body',\n    })\n  }\n  const normalizedModelsInput = body.models === undefined ? undefined : normalizeModelList(body.models)\n  const normalizedProviders = body.providers === undefined ? undefined : normalizeProvidersInput(body.providers)\n  const normalizedDefaults = body.defaultModels === undefined ? undefined : normalizeDefaultModelsInput(body.defaultModels)\n  const normalizedCapabilityDefaults = body.capabilityDefaults === undefined\n    ? undefined\n    : normalizeCapabilitySelectionsInput(body.capabilityDefaults)\n  const normalizedWorkflowConcurrency = body.workflowConcurrency === undefined\n    ? undefined\n    : normalizeWorkflowConcurrencyInput(body.workflowConcurrency)\n  const billingMode = await getBillingMode()\n\n  const updateData: Record<string, unknown> = {}\n  const existingPref = await prisma.userPreference.findUnique({\n    where: { userId },\n    select: {\n      customProviders: true,\n      customModels: true,\n    },\n  })\n  const existingProviders = parseStoredProviders(existingPref?.customProviders)\n  const existingModels = parseStoredModels(existingPref?.customModels)\n  const normalizedModels = normalizedModelsInput === undefined\n    ? undefined\n    : resolveStoredMediaTemplates(resolveStoredLlmProtocols(normalizedModelsInput, existingModels), existingModels)\n\n  const providerSourceForValidation = normalizedProviders ?? existingProviders\n  if (normalizedModels !== undefined) {\n    validateModelProviderConsistency(normalizedModels, providerSourceForValidation)\n    validateModelProviderTypeSupport(normalizedModels, providerSourceForValidation)\n    validateCustomPricingCapabilityMappings(normalizedModels)\n    if (billingMode !== 'OFF') {\n      validateBillableModelPricing(normalizedModels)\n    }\n  }\n\n  if (normalizedModels !== undefined) {\n    updateData.customModels = JSON.stringify(normalizedModels)\n  }\n\n  if (normalizedProviders !== undefined) {\n    const providersToSave = normalizedProviders.map((provider) => {\n      const existing = existingProviders.find((candidate) => candidate.id === provider.id)\n      let finalApiKey: string | undefined\n      if (provider.apiKey === undefined) {\n        finalApiKey = existing?.apiKey\n      } else if (provider.apiKey === '') {\n        finalApiKey = undefined\n      } else {\n        finalApiKey = encryptApiKey(provider.apiKey)\n      }\n      const finalHidden = provider.hidden === undefined\n        ? existing?.hidden === true\n        : provider.hidden === true\n\n      return {\n        id: provider.id,\n        name: provider.name,\n        baseUrl: provider.baseUrl,\n        hidden: finalHidden,\n        apiMode: provider.apiMode,\n        gatewayRoute: provider.gatewayRoute,\n        apiKey: finalApiKey,\n      }\n    })\n    updateData.customProviders = JSON.stringify(providersToSave)\n  }\n\n  if (normalizedDefaults !== undefined) {\n    if (billingMode !== 'OFF') {\n      validateDefaultModelPricing(normalizedDefaults)\n    }\n    if (normalizedDefaults.analysisModel !== undefined) {\n      updateData.analysisModel = normalizedDefaults.analysisModel || null\n    }\n    if (normalizedDefaults.characterModel !== undefined) {\n      updateData.characterModel = normalizedDefaults.characterModel || null\n    }\n    if (normalizedDefaults.locationModel !== undefined) {\n      updateData.locationModel = normalizedDefaults.locationModel || null\n    }\n    if (normalizedDefaults.storyboardModel !== undefined) {\n      updateData.storyboardModel = normalizedDefaults.storyboardModel || null\n    }\n    if (normalizedDefaults.editModel !== undefined) {\n      updateData.editModel = normalizedDefaults.editModel || null\n    }\n    if (normalizedDefaults.videoModel !== undefined) {\n      updateData.videoModel = normalizedDefaults.videoModel || null\n    }\n    if (normalizedDefaults.audioModel !== undefined) {\n      updateData.audioModel = normalizedDefaults.audioModel || null\n    }\n    if (normalizedDefaults.lipSyncModel !== undefined) {\n      updateData.lipSyncModel = normalizedDefaults.lipSyncModel || null\n    }\n    if (normalizedDefaults.voiceDesignModel !== undefined) {\n      updateData.voiceDesignModel = normalizedDefaults.voiceDesignModel || null\n    }\n  }\n\n  if (normalizedWorkflowConcurrency !== undefined) {\n    if (normalizedWorkflowConcurrency.analysis !== undefined) {\n      updateData.analysisConcurrency = normalizedWorkflowConcurrency.analysis\n    }\n    if (normalizedWorkflowConcurrency.image !== undefined) {\n      updateData.imageConcurrency = normalizedWorkflowConcurrency.image\n    }\n    if (normalizedWorkflowConcurrency.video !== undefined) {\n      updateData.videoConcurrency = normalizedWorkflowConcurrency.video\n    }\n  }\n\n  if (normalizedCapabilityDefaults !== undefined) {\n    const modelSource = normalizedModels ?? existingModels\n    const cleanedCapabilityDefaults = sanitizeCapabilitySelectionsAgainstModels(\n      normalizedCapabilityDefaults,\n      modelSource,\n    )\n    validateCapabilitySelectionsAgainstModels(cleanedCapabilityDefaults, modelSource)\n    updateData.capabilityDefaults = serializeCapabilitySelections(cleanedCapabilityDefaults)\n  }\n\n  await prisma.userPreference.upsert({\n    where: { userId },\n    update: updateData,\n    create: { userId, ...updateData },\n  })\n\n  return NextResponse.json({ success: true })\n})\n"
  },
  {
    "path": "src/app/api/user/api-config/test-connection/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\nimport { testLlmConnection } from '@/lib/user-api/llm-test-connection'\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n\n    const body = await request.json().catch(() => ({}))\n    const startedAt = Date.now()\n    const result = await testLlmConnection(body)\n    return NextResponse.json({\n        success: true,\n        latencyMs: Date.now() - startedAt,\n        ...result,\n    })\n})\n"
  },
  {
    "path": "src/app/api/user/api-config/test-provider/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\nimport { testProviderConnection } from '@/lib/user-api/provider-test'\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n\n  const body = await request.json().catch(() => ({}))\n  const startedAt = Date.now()\n  const result = await testProviderConnection(body)\n  return NextResponse.json({\n    ...result,\n    latencyMs: Date.now() - startedAt,\n  })\n})\n"
  },
  {
    "path": "src/app/api/user/assistant/chat/route.ts",
    "content": "import { NextRequest } from 'next/server'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport { isErrorResponse, requireUserAuth } from '@/lib/api-auth'\nimport {\n  AssistantPlatformError,\n  createAssistantChatResponse,\n  isAssistantId,\n} from '@/lib/assistant-platform'\n\ntype RequestBody = {\n  assistantId?: unknown\n  messages?: unknown\n  context?: unknown\n}\n\nfunction readAssistantId(value: unknown): 'api-config-template' | 'tutorial' {\n  if (!isAssistantId(value)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'ASSISTANT_INVALID_REQUEST',\n      field: 'assistantId',\n      message: 'assistantId must be api-config-template or tutorial',\n    })\n  }\n  return value\n}\n\nfunction mapAssistantError(error: AssistantPlatformError): ApiError {\n  if (error.code === 'ASSISTANT_MODEL_NOT_CONFIGURED') {\n    return new ApiError('MISSING_CONFIG', {\n      code: error.code,\n      message: 'analysisModel is required before using assistant',\n    })\n  }\n\n  if (error.code === 'ASSISTANT_INVALID_REQUEST' || error.code === 'ASSISTANT_CONTEXT_REQUIRED') {\n    return new ApiError('INVALID_PARAMS', {\n      code: error.code,\n      message: error.message,\n    })\n  }\n\n  if (error.code === 'ASSISTANT_SKILL_NOT_FOUND') {\n    return new ApiError('INVALID_PARAMS', {\n      code: error.code,\n      message: error.message,\n    })\n  }\n\n  return new ApiError('EXTERNAL_ERROR', {\n    code: error.code,\n    message: error.message,\n  })\n}\n\nexport const POST = apiHandler(async (request: NextRequest) => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n\n  let body: RequestBody\n  try {\n    body = (await request.json()) as RequestBody\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'BODY_PARSE_FAILED',\n      field: 'body',\n      message: 'request body must be valid JSON',\n    })\n  }\n\n  const assistantId = readAssistantId(body.assistantId)\n\n  try {\n    return await createAssistantChatResponse({\n      userId: authResult.session.user.id,\n      assistantId,\n      context: body.context,\n      messages: body.messages,\n    })\n  } catch (error) {\n    if (error instanceof AssistantPlatformError) {\n      throw mapAssistantError(error)\n    }\n    throw error\n  }\n})\n"
  },
  {
    "path": "src/app/api/user/balance/route.ts",
    "content": "import { NextResponse } from 'next/server'\nimport { getBalance } from '@/lib/billing'\nimport { BILLING_CURRENCY } from '@/lib/billing/currency'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\n\n/**\n * GET /api/user/balance\n * 获取当前用户余额\n */\nexport const GET = apiHandler(async () => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const balance = await getBalance(session.user.id)\n\n    return NextResponse.json({\n        success: true,\n        currency: BILLING_CURRENCY,\n        balance: balance.balance,\n        frozenAmount: balance.frozenAmount,\n        totalSpent: balance.totalSpent\n    })\n})\n"
  },
  {
    "path": "src/app/api/user/costs/details/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { getUserCostDetails } from '@/lib/billing'\nimport { BILLING_CURRENCY } from '@/lib/billing/currency'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\n\n/**\n * GET /api/user/costs/details\n * 获取用户费用明细（分页）\n */\nexport const GET = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const { searchParams } = new URL(request.url)\n    const page = parseInt(searchParams.get('page') || '1')\n    const pageSize = parseInt(searchParams.get('pageSize') || '20')\n\n    const result = await getUserCostDetails(session.user.id, page, pageSize)\n\n    return NextResponse.json({\n        success: true,\n        currency: BILLING_CURRENCY,\n        ...result\n    })\n})\n"
  },
  {
    "path": "src/app/api/user/costs/route.ts",
    "content": "import { NextResponse } from 'next/server'\nimport { getUserCostSummary } from '@/lib/billing'\nimport { BILLING_CURRENCY } from '@/lib/billing/currency'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\n\n/**\n * GET /api/user/costs\n * 获取当前用户所有项目费用汇总\n */\nexport const GET = apiHandler(async () => {\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const userId = session.user.id\n\n  // 获取用户费用汇总\n  const costSummary = await getUserCostSummary(userId)\n\n  // 获取项目名称\n  const projectIds = costSummary.byProject.map(p => p.projectId)\n  const projects = await prisma.project.findMany({\n    where: { id: { in: projectIds } },\n    select: { id: true, name: true }\n  })\n\n  const projectMap = new Map(projects.map(p => [p.id, p.name]))\n\n  // 合并项目名称\n  const byProjectWithNames = costSummary.byProject.map(p => ({\n    projectId: p.projectId,\n    projectName: projectMap.get(p.projectId) || '未知项目',\n    totalCost: p._sum.cost || 0,\n    recordCount: p._count\n  }))\n\n  return NextResponse.json({\n    userId,\n    currency: BILLING_CURRENCY,\n    total: costSummary.total,\n    byProject: byProjectWithNames.sort((a, b) => b.totalCost - a.totalCost)\n  })\n})\n"
  },
  {
    "path": "src/app/api/user/models/route.ts",
    "content": "/**\n * 获取用户的模型列表\n *\n * 返回用户在个人中心启用的模型，供项目配置下拉框使用。\n * capabilities 仅来自系统内置目录（不信任用户提交的 model.capabilities）。\n */\n\nimport { NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler, ApiError } from '@/lib/api-errors'\nimport {\n  composeModelKey,\n  parseModelKeyStrict,\n  type CapabilityValue,\n  type ModelCapabilities,\n  type UnifiedModelType,\n} from '@/lib/model-config-contract'\nimport { findBuiltinCapabilities } from '@/lib/model-capabilities/catalog'\nimport { findBuiltinPricingCatalogEntry } from '@/lib/model-pricing/catalog'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\n\ntype StoredModelType = UnifiedModelType | string\n\ninterface StoredModel {\n  modelId?: string\n  modelKey?: string\n  name?: string\n  type?: StoredModelType\n  provider?: string\n}\n\ninterface StoredProvider {\n  id?: string\n  name?: string\n  apiKey?: string\n}\n\ninterface UserModelOption {\n  value: string\n  label: string\n  provider?: string\n  providerName?: string\n  capabilities?: ModelCapabilities\n  videoPricingTiers?: VideoPricingTier[]\n}\n\ninterface UserModelsPayload {\n  llm: UserModelOption[]\n  image: UserModelOption[]\n  video: UserModelOption[]\n  audio: UserModelOption[]\n  lipsync: UserModelOption[]\n}\n\nconst AUDIO_MODEL_EXCLUDED_IDS = new Set([\n  'qwen-voice-design',\n])\n\nfunction isUnifiedModelType(type: unknown): type is UnifiedModelType {\n  return (\n    type === 'llm'\n    || type === 'image'\n    || type === 'video'\n    || type === 'audio'\n    || type === 'lipsync'\n  )\n}\n\nfunction toModelKey(model: StoredModel): string {\n  const provider = typeof model.provider === 'string' ? model.provider.trim() : ''\n  const modelId = typeof model.modelId === 'string' ? model.modelId.trim() : ''\n\n  if (provider && modelId) {\n    return composeModelKey(provider, modelId)\n  }\n\n  const parsed = parseModelKeyStrict(typeof model.modelKey === 'string' ? model.modelKey : '')\n  return parsed?.modelKey || ''\n}\n\nfunction toProvider(model: StoredModel): string | undefined {\n  if (typeof model.provider === 'string' && model.provider.trim()) return model.provider.trim()\n  const parsed = parseModelKeyStrict(typeof model.modelKey === 'string' ? model.modelKey : '')\n  return parsed?.provider || undefined\n}\n\nfunction toModelId(model: StoredModel): string {\n  if (typeof model.modelId === 'string' && model.modelId.trim()) {\n    return model.modelId.trim()\n  }\n  const parsed = parseModelKeyStrict(typeof model.modelKey === 'string' ? model.modelKey : '')\n  return parsed?.modelId || ''\n}\n\nfunction toDisplayLabel(model: StoredModel, fallbackModelId: string): string {\n  if (typeof model.name === 'string' && model.name.trim()) return model.name.trim()\n  return fallbackModelId\n}\n\nfunction dedupeByModelKey(items: UserModelOption[]): UserModelOption[] {\n  const seen = new Set<string>()\n  return items.filter((item) => {\n    if (seen.has(item.value)) return false\n    seen.add(item.value)\n    return true\n  })\n}\n\nfunction cloneVideoPricingTiers(rawTiers: Array<{ when: Record<string, CapabilityValue> }>): VideoPricingTier[] {\n  return rawTiers.map((tier) => ({\n    when: { ...tier.when },\n  }))\n}\n\nfunction parseStoredModels(rawModels: string | null | undefined): StoredModel[] {\n  if (!rawModels) return []\n  let parsedUnknown: unknown\n  try {\n    parsedUnknown = JSON.parse(rawModels)\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_PAYLOAD_INVALID',\n      field: 'customModels',\n    })\n  }\n  if (!Array.isArray(parsedUnknown)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'MODEL_PAYLOAD_INVALID',\n      field: 'customModels',\n    })\n  }\n  return parsedUnknown as StoredModel[]\n}\n\nfunction parseStoredProviders(rawProviders: string | null | undefined): StoredProvider[] {\n  if (!rawProviders) return []\n  let parsedUnknown: unknown\n  try {\n    parsedUnknown = JSON.parse(rawProviders)\n  } catch {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'PROVIDER_PAYLOAD_INVALID',\n      field: 'customProviders',\n    })\n  }\n  if (!Array.isArray(parsedUnknown)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'PROVIDER_PAYLOAD_INVALID',\n      field: 'customProviders',\n    })\n  }\n  return parsedUnknown as StoredProvider[]\n}\n\nfunction hasStoredProviderApiKey(provider: StoredProvider): boolean {\n  return typeof provider.apiKey === 'string' && provider.apiKey.trim().length > 0\n}\n\nfunction isUserSelectableModel(model: StoredModel): boolean {\n  if (model.type !== 'audio') return true\n  const modelId = toModelId(model)\n  return !AUDIO_MODEL_EXCLUDED_IDS.has(modelId)\n}\n\nexport const GET = apiHandler(async () => {\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n  const userId = session.user.id\n\n  const pref = await prisma.userPreference.findUnique({\n    where: { userId },\n    select: { customModels: true, customProviders: true },\n  })\n\n  const modelsRaw: StoredModel[] = parseStoredModels(pref?.customModels)\n  const providers: StoredProvider[] = parseStoredProviders(pref?.customProviders)\n\n  const providerNameMap = new Map<string, string>()\n  const providerIdsWithApiKey = new Set<string>()\n  providers.forEach((provider) => {\n    const providerId = typeof provider?.id === 'string' ? provider.id.trim() : ''\n    if (!providerId) return\n\n    if (provider?.name && typeof provider.name === 'string') {\n      providerNameMap.set(providerId, provider.name)\n    }\n    if (hasStoredProviderApiKey(provider)) providerIdsWithApiKey.add(providerId)\n  })\n\n  const grouped: UserModelsPayload = {\n    llm: [],\n    image: [],\n    video: [],\n    audio: [],\n    lipsync: [],\n  }\n\n  for (const model of modelsRaw) {\n    if (!isUnifiedModelType(model.type)) continue\n    if (!isUserSelectableModel(model)) continue\n\n    const modelType = model.type\n    const modelKey = toModelKey(model)\n    if (!modelKey) continue\n\n    const provider = toProvider(model)\n    if (!provider || !providerIdsWithApiKey.has(provider)) continue\n    const modelId = toModelId(model)\n    const option: UserModelOption = {\n      value: modelKey,\n      label: toDisplayLabel(model, modelId || modelKey),\n      provider,\n      providerName: provider ? providerNameMap.get(provider) : undefined,\n    }\n\n    if (provider && modelId) {\n      const capabilities = findBuiltinCapabilities(modelType, provider, modelId)\n      if (capabilities) {\n        option.capabilities = capabilities\n      }\n\n      if (modelType === 'video') {\n        const pricingEntry = findBuiltinPricingCatalogEntry('video', provider, modelId)\n        if (pricingEntry?.pricing.mode === 'capability' && Array.isArray(pricingEntry.pricing.tiers)) {\n          option.videoPricingTiers = cloneVideoPricingTiers(pricingEntry.pricing.tiers)\n        }\n      }\n    }\n\n    grouped[modelType].push(option)\n  }\n\n  return NextResponse.json({\n    llm: dedupeByModelKey(grouped.llm),\n    image: dedupeByModelKey(grouped.image),\n    video: dedupeByModelKey(grouped.video),\n    audio: dedupeByModelKey(grouped.audio),\n    lipsync: dedupeByModelKey(grouped.lipsync),\n  } satisfies UserModelsPayload)\n})\n"
  },
  {
    "path": "src/app/api/user/transactions/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { BILLING_CURRENCY } from '@/lib/billing/currency'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { apiHandler } from '@/lib/api-errors'\nimport type { Prisma } from '@prisma/client'\nimport { toMoneyNumber } from '@/lib/billing/money'\n\n// action key 的特征：小写字母、数字、下划线组成\nconst ACTION_KEY_PATTERN = /^[a-z][a-z0-9_]*$/\n\n/**\n * 从 BalanceTransaction.description 中解析出 action key\n * 支持的格式：\n *   \"[SHADOW] modify_asset_image - gemini-compatible:... - ¥0.96\"\n *   \"modify_asset_image - gemini-compatible:... - ¥0.96\"\n * 返回 action key（如 \"modify_asset_image\"），解析失败返回 null\n */\nfunction extractActionFromDescription(description: string | null): string | null {\n    if (!description) return null\n    const cleaned = description.replace(/^\\[SHADOW\\]\\s*/i, '').trim()\n    const firstPart = cleaned.split(' - ')[0].trim()\n    if (ACTION_KEY_PATTERN.test(firstPart)) return firstPart\n    return null\n}\n\n/**\n * GET /api/user/transactions\n * 获取用户余额流水记录\n */\nexport const GET = apiHandler(async (request: NextRequest) => {\n    // 🔐 统一权限验证\n    const authResult = await requireUserAuth()\n    if (isErrorResponse(authResult)) return authResult\n    const { session } = authResult\n\n    const { searchParams } = new URL(request.url)\n    const page = parseInt(searchParams.get('page') || '1')\n    const pageSize = parseInt(searchParams.get('pageSize') || '20')\n    const type = searchParams.get('type') // recharge | consume | all\n    const startDate = searchParams.get('startDate')\n    const endDate = searchParams.get('endDate')\n\n    const where: Prisma.BalanceTransactionWhereInput = { userId: session.user.id }\n    if (type && type !== 'all') {\n        where.type = type\n    }\n\n    // 日期筛选\n    if (startDate || endDate) {\n        where.createdAt = {}\n        if (startDate) {\n            where.createdAt.gte = new Date(startDate)\n        }\n        if (endDate) {\n            // 包含结束日期的整天\n            const endDateTime = new Date(endDate)\n            endDateTime.setHours(23, 59, 59, 999)\n            where.createdAt.lte = endDateTime\n        }\n    }\n\n    // 获取流水记录\n    const [transactionsRaw, total] = await Promise.all([\n        prisma.balanceTransaction.findMany({\n            where,\n            orderBy: { createdAt: 'desc' },\n            skip: (page - 1) * pageSize,\n            take: pageSize\n        }),\n        prisma.balanceTransaction.count({ where })\n    ])\n\n    // 批量查询涉及的项目名和集数（避免 N+1）\n    const projectIds = [...new Set(transactionsRaw.map((t) => t.projectId).filter(Boolean) as string[])]\n    const episodeIds = [...new Set(transactionsRaw.map((t) => t.episodeId).filter(Boolean) as string[])]\n\n    const [projects, episodes] = await Promise.all([\n        projectIds.length > 0\n            ? prisma.project.findMany({\n                where: { id: { in: projectIds } },\n                select: { id: true, name: true },\n            })\n            : Promise.resolve([]),\n        episodeIds.length > 0\n            ? prisma.novelPromotionEpisode.findMany({\n                where: { id: { in: episodeIds } },\n                select: { id: true, episodeNumber: true, name: true },\n            })\n            : Promise.resolve([]),\n    ])\n\n    const projectMap = new Map(projects.map((p) => [p.id, p.name]))\n    const episodeMap = new Map(episodes.map((e) => [e.id, { episodeNumber: e.episodeNumber, name: e.name }]))\n\n    const transactions = transactionsRaw.map((item) => {\n        // 解析 billingMeta JSON\n        let billingMeta: Record<string, unknown> | null = null\n        if (item.billingMeta && typeof item.billingMeta === 'string') {\n            try {\n                billingMeta = JSON.parse(item.billingMeta) as Record<string, unknown>\n            } catch { /* ignore */ }\n        }\n\n        return {\n            ...item,\n            amount: toMoneyNumber(item.amount),\n            balanceAfter: toMoneyNumber(item.balanceAfter),\n            // 优先用新字段 taskType，其次从 description 解析，供前端做 i18n 翻译\n            action: item.taskType ?? extractActionFromDescription(item.description),\n            // 项目名（新记录有 projectId 时有值，老记录为 null）\n            projectName: item.projectId ? (projectMap.get(item.projectId) ?? null) : null,\n            // 集数（新记录有 episodeId 时有值）\n            episodeNumber: item.episodeId ? (episodeMap.get(item.episodeId)?.episodeNumber ?? null) : null,\n            episodeName: item.episodeId ? (episodeMap.get(item.episodeId)?.name ?? null) : null,\n            // 结构化计费详情\n            billingMeta,\n        }\n    })\n\n    return NextResponse.json({\n        currency: BILLING_CURRENCY,\n        transactions,\n        pagination: {\n            page,\n            pageSize,\n            total,\n            totalPages: Math.ceil(total / pageSize)\n        }\n    })\n})\n"
  },
  {
    "path": "src/app/api/user-preference/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { prisma } from '@/lib/prisma'\nimport { requireUserAuth, isErrorResponse } from '@/lib/api-auth'\nimport { ApiError, apiHandler } from '@/lib/api-errors'\nimport { isArtStyleValue } from '@/lib/constants'\n\nfunction validateArtStyleField(value: unknown): string {\n  if (typeof value !== 'string') {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'INVALID_ART_STYLE',\n      field: 'artStyle',\n      message: 'artStyle must be a supported value',\n    })\n  }\n  const artStyle = value.trim()\n  if (!isArtStyleValue(artStyle)) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'INVALID_ART_STYLE',\n      field: 'artStyle',\n      message: 'artStyle must be a supported value',\n    })\n  }\n  return artStyle\n}\n\n// GET - 获取用户偏好配置\nexport const GET = apiHandler(async () => {\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  // 获取或创建用户偏好\n  const preference = await prisma.userPreference.upsert({\n    where: { userId: session.user.id },\n    update: {},\n    create: { userId: session.user.id }\n  })\n\n  return NextResponse.json({ preference })\n})\n\n// PATCH - 更新用户偏好配置\nexport const PATCH = apiHandler(async (request: NextRequest) => {\n  // 🔐 统一权限验证\n  const authResult = await requireUserAuth()\n  if (isErrorResponse(authResult)) return authResult\n  const { session } = authResult\n\n  const body = await request.json()\n\n  // 只允许更新特定字段\n  const allowedFields = [\n    'analysisModel',\n    'characterModel',\n    'locationModel',\n    'storyboardModel',\n    'editModel',\n    'videoModel',\n    'audioModel',\n    'lipSyncModel',\n    'videoRatio',\n    'artStyle',\n    'ttsRate'\n  ]\n\n  const updateData: Record<string, unknown> = {}\n  for (const field of allowedFields) {\n    if (body[field] !== undefined) {\n      if (field === 'artStyle') {\n        updateData[field] = validateArtStyleField(body[field])\n        continue\n      }\n      updateData[field] = body[field]\n    }\n  }\n\n  if (Object.keys(updateData).length === 0) {\n    throw new ApiError('INVALID_PARAMS')\n  }\n\n  // 更新或创建用户偏好\n  const preference = await prisma.userPreference.upsert({\n    where: { userId: session.user.id },\n    update: updateData,\n    create: {\n      userId: session.user.id,\n      ...updateData\n    }\n  })\n\n  return NextResponse.json({ preference })\n})\n"
  },
  {
    "path": "src/app/globals.css",
    "content": "@import \"tailwindcss\";\n@import \"../styles/ui-tokens-glass.css\";\n@import \"../styles/ui-semantic-glass.css\";\n\n@custom-variant dark (&:is(.dark *));\n\n:root {\n\n  /* 设计系统 - 颜色 */\n  --bg-primary: var(--glass-bg-canvas);\n  /* 浅灰背景 */\n  --bg-card: var(--glass-bg-surface);\n  /* 卡片白色 */\n  --bg-glass: var(--glass-bg-surface-strong);\n  /* 玻璃态背景 */\n\n  /* 设计系统 - 阴影 */\n  --shadow-card: var(--glass-shadow-sm);\n  --shadow-card-hover: var(--glass-shadow-md);\n  --shadow-glass: var(--glass-shadow-lg);\n\n  /* 设计系统 - 圆角 */\n  --radius-card: var(--glass-radius-md);\n  --radius-button: var(--glass-radius-sm);\n  --radius-input: var(--glass-radius-xs);\n\n  /* 设计系统 - 模糊 */\n  --blur-glass: var(--glass-blur-lg);\n  --radius: 0.625rem;\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.145 0 0);\n  --card: oklch(1 0 0);\n  --card-foreground: oklch(0.145 0 0);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.145 0 0);\n  --primary: oklch(0.205 0 0);\n  --primary-foreground: oklch(0.985 0 0);\n  --secondary: oklch(0.97 0 0);\n  --secondary-foreground: oklch(0.205 0 0);\n  --muted: oklch(0.97 0 0);\n  --muted-foreground: oklch(0.556 0 0);\n  --accent: oklch(0.97 0 0);\n  --accent-foreground: oklch(0.205 0 0);\n  --destructive: oklch(0.577 0.245 27.325);\n  --border: oklch(0.922 0 0);\n  --input: oklch(0.922 0 0);\n  --ring: oklch(0.708 0 0);\n  --chart-1: oklch(0.646 0.222 41.116);\n  --chart-2: oklch(0.6 0.118 184.704);\n  --chart-3: oklch(0.398 0.07 227.392);\n  --chart-4: oklch(0.828 0.189 84.429);\n  --chart-5: oklch(0.769 0.188 70.08);\n  --sidebar: oklch(0.985 0 0);\n  --sidebar-foreground: oklch(0.145 0 0);\n  --sidebar-primary: oklch(0.205 0 0);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.97 0 0);\n  --sidebar-accent-foreground: oklch(0.205 0 0);\n  --sidebar-border: oklch(0.922 0 0);\n  --sidebar-ring: oklch(0.708 0 0);\n}\n\n@theme inline {\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --font-sans: var(--font-geist-sans);\n  --font-mono: var(--font-geist-mono);\n\n  --color-sidebar-ring: var(--sidebar-ring);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar: var(--sidebar);\n  --color-chart-5: var(--chart-5);\n  --color-chart-4: var(--chart-4);\n  --color-chart-3: var(--chart-3);\n  --color-chart-2: var(--chart-2);\n  --color-chart-1: var(--chart-1);\n  --color-ring: var(--ring);\n  --color-input: var(--input);\n  --color-border: var(--border);\n  --color-destructive: var(--destructive);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-accent: var(--accent);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-muted: var(--muted);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-secondary: var(--secondary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-primary: var(--primary);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-popover: var(--popover);\n  --color-card-foreground: var(--card-foreground);\n  --color-card: var(--card);\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --radius-2xl: calc(var(--radius) + 8px);\n  --radius-3xl: calc(var(--radius) + 12px);\n  --radius-4xl: calc(var(--radius) + 16px);\n}\n\nbody {\n  font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;\n}\n\n/* 可复用组件类 */\n.card-base {\n  background: var(--bg-card);\n  border-radius: var(--radius-card);\n  box-shadow: var(--shadow-card);\n  transition: box-shadow 0.3s ease;\n}\n\n.card-base:hover {\n  box-shadow: var(--shadow-card-hover);\n}\n\n.glass-nav {\n  background: var(--glass-bg-nav);\n  backdrop-filter: blur(var(--glass-blur-nav)) saturate(1.12);\n  -webkit-backdrop-filter: blur(var(--glass-blur-nav)) saturate(1.12);\n  box-shadow: var(--glass-shadow-nav);\n  border-bottom: 1px solid var(--glass-stroke-soft);\n}\n\n.input-base {\n  border-radius: var(--radius-input);\n  transition: all 0.2s ease;\n}\n\n/* 液体流动动画 */\n@keyframes float {\n\n  0%,\n  100% {\n    transform: translateY(0px) translateX(0px) scale(1);\n    opacity: 0.6;\n  }\n\n  25% {\n    transform: translateY(-40px) translateX(30px) scale(1.1);\n    opacity: 0.8;\n  }\n\n  50% {\n    transform: translateY(-20px) translateX(-20px) scale(0.9);\n    opacity: 0.7;\n  }\n\n  75% {\n    transform: translateY(30px) translateX(40px) scale(1.05);\n    opacity: 0.9;\n  }\n}\n\n@keyframes float-delayed {\n\n  0%,\n  100% {\n    transform: translateY(0px) translateX(0px) scale(1);\n    opacity: 0.5;\n  }\n\n  25% {\n    transform: translateY(50px) translateX(-40px) scale(0.85);\n    opacity: 0.7;\n  }\n\n  50% {\n    transform: translateY(-30px) translateX(60px) scale(1.2);\n    opacity: 0.6;\n  }\n\n  75% {\n    transform: translateY(20px) translateX(-30px) scale(0.95);\n    opacity: 0.8;\n  }\n}\n\n@keyframes float-slow {\n\n  0%,\n  100% {\n    transform: translateY(0px) translateX(0px) scale(1);\n    opacity: 0.4;\n  }\n\n  33% {\n    transform: translateY(-60px) translateX(50px) scale(1.3);\n    opacity: 0.7;\n  }\n\n  66% {\n    transform: translateY(40px) translateX(-60px) scale(0.8);\n    opacity: 0.6;\n  }\n}\n\n.animate-float {\n  animation: float 15s ease-in-out infinite;\n}\n\n.animate-float-delayed {\n  animation: float-delayed 18s ease-in-out infinite;\n  animation-delay: -3s;\n}\n\n.animate-float-slow {\n  animation: float-slow 20s ease-in-out infinite;\n  animation-delay: -6s;\n}\n\n@keyframes assistant-stream-gradient {\n  0% {\n    background-position: 100% 50%;\n    opacity: 0.72;\n  }\n\n  100% {\n    background-position: 0% 50%;\n    opacity: 1;\n  }\n}\n\n.assistant-streaming-response {\n  background-image: linear-gradient(\n    90deg,\n    color-mix(in oklch, var(--glass-text-secondary) 85%, white 15%) 0%,\n    color-mix(in oklch, var(--glass-text-primary) 95%, white 5%) 42%,\n    color-mix(in oklch, var(--glass-text-secondary) 85%, white 15%) 100%\n  );\n  background-size: 220% 100%;\n  background-position: 100% 50%;\n  -webkit-background-clip: text;\n  background-clip: text;\n  -webkit-text-fill-color: transparent;\n  color: transparent;\n  animation: assistant-stream-gradient 520ms ease-out both;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .assistant-streaming-response {\n    animation: none;\n    background-image: none;\n    background-size: auto;\n    -webkit-text-fill-color: currentColor;\n    color: var(--glass-text-primary);\n  }\n}\n\n/* 测试页面动画 - 增强版 */\n@keyframes float-1 {\n\n  0%,\n  100% {\n    transform: translateY(0px) translateX(0px) scale(1) rotate(0deg);\n    opacity: 0.6;\n  }\n\n  25% {\n    transform: translateY(-120px) translateX(80px) scale(1.3) rotate(5deg);\n    opacity: 0.8;\n  }\n\n  50% {\n    transform: translateY(-60px) translateX(-90px) scale(0.7) rotate(-3deg);\n    opacity: 0.7;\n  }\n\n  75% {\n    transform: translateY(100px) translateX(110px) scale(1.1) rotate(8deg);\n    opacity: 0.9;\n  }\n}\n\n@keyframes float-2 {\n\n  0%,\n  100% {\n    transform: translateY(0px) translateX(0px) scale(1) rotate(0deg);\n    opacity: 0.5;\n  }\n\n  20% {\n    transform: translateY(80px) translateX(-70px) scale(0.8) rotate(-4deg);\n    opacity: 0.7;\n  }\n\n  40% {\n    transform: translateY(-90px) translateX(130px) scale(1.4) rotate(6deg);\n    opacity: 0.6;\n  }\n\n  60% {\n    transform: translateY(110px) translateX(-100px) scale(0.9) rotate(-7deg);\n    opacity: 0.8;\n  }\n\n  80% {\n    transform: translateY(-50px) translateX(90px) scale(1.2) rotate(3deg);\n    opacity: 0.7;\n  }\n}\n\n@keyframes float-3 {\n\n  0%,\n  100% {\n    transform: translateY(0px) translateX(0px) scale(1) rotate(0deg);\n    opacity: 0.4;\n  }\n\n  33% {\n    transform: translateY(-140px) translateX(120px) scale(1.5) rotate(10deg);\n    opacity: 0.8;\n  }\n\n  66% {\n    transform: translateY(90px) translateX(-140px) scale(0.6) rotate(-8deg);\n    opacity: 0.6;\n  }\n}\n\n.animate-float-1 {\n  animation: float-1 10s ease-in-out infinite;\n}\n\n.animate-float-2 {\n  animation: float-2 12s ease-in-out infinite;\n  animation-delay: -2s;\n}\n\n.animate-float-3 {\n  animation: float-3 14s ease-in-out infinite;\n  animation-delay: -4s;\n}\n\n/* Toast动画 */\n@keyframes slide-up {\n  from {\n    transform: translateY(100%);\n    opacity: 0;\n  }\n\n  to {\n    transform: translateY(0);\n    opacity: 1;\n  }\n}\n\n.animate-slide-up {\n  animation: slide-up 0.8s ease-out both;\n}\n\n/* 进度条动画 */\n@keyframes progress {\n  0% {\n    width: 0%;\n  }\n\n  100% {\n    width: 100%;\n  }\n}\n\n.animate-progress {\n  animation: progress 2s ease-in-out infinite;\n}\n\n/* 确认对话框动画 */\n@keyframes fade-in {\n  from {\n    opacity: 0;\n  }\n\n  to {\n    opacity: 1;\n  }\n}\n\n@keyframes scale-in {\n  from {\n    transform: scale(0.95);\n    opacity: 0;\n  }\n\n  to {\n    transform: scale(1);\n    opacity: 1;\n  }\n}\n\n.animate-fade-in {\n  animation: fade-in 0.2s ease-out both;\n}\n\n.animate-scale-in {\n  animation: scale-in 0.22s ease-out both;\n}\n\n/* 竖向文字 */\n.writing-vertical {\n  writing-mode: vertical-rl;\n  text-orientation: mixed;\n}\n\n/* 渐变动画 */\n@keyframes gradient-x {\n  0% {\n    background-position: 0% 50%;\n  }\n\n  50% {\n    background-position: 100% 50%;\n  }\n\n  100% {\n    background-position: 0% 50%;\n  }\n}\n\n.animate-gradient-x {\n  background-size: 200% auto;\n  animation: gradient-x 3s linear infinite;\n}\n\n@keyframes float-slow {\n\n  0%,\n  100% {\n    transform: translate(0, 0);\n  }\n\n  50% {\n    transform: translate(20px, 20px);\n  }\n}\n\n@keyframes float-delayed {\n\n  0%,\n  100% {\n    transform: translate(0, 0);\n  }\n\n  50% {\n    transform: translate(-20px, 20px);\n  }\n}\n\n.animate-float-slow {\n  animation: float-slow 8s ease-in-out infinite;\n}\n\n.animate-float-delayed {\n  animation: float-delayed 10s ease-in-out infinite;\n  animation-delay: 2s;\n}\n\n@keyframes pulse-slow {\n\n  0%,\n  100% {\n    opacity: 1;\n  }\n\n  50% {\n    opacity: 0.5;\n  }\n}\n\n.animate-pulse-slow {\n  animation: pulse-slow 8s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n}\n\n.dark {\n  --background: oklch(0.145 0 0);\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.205 0 0);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.205 0 0);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.922 0 0);\n  --primary-foreground: oklch(0.205 0 0);\n  --secondary: oklch(0.269 0 0);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.269 0 0);\n  --muted-foreground: oklch(0.708 0 0);\n  --accent: oklch(0.269 0 0);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --border: oklch(1 0 0 / 10%);\n  --input: oklch(1 0 0 / 15%);\n  --ring: oklch(0.556 0 0);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --sidebar: oklch(0.205 0 0);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.488 0.243 264.376);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.269 0 0);\n  --sidebar-accent-foreground: oklch(0.985 0 0);\n  --sidebar-border: oklch(1 0 0 / 10%);\n  --sidebar-ring: oklch(0.556 0 0);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n"
  },
  {
    "path": "src/app/m/[publicId]/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { getSignedUrl, toFetchableUrl } from '@/lib/storage'\nimport { getMediaObjectByPublicId } from '@/lib/media/service'\n\nexport const runtime = 'nodejs'\n\nfunction buildEtag(media: { sha256?: string | null; id: string; updatedAt?: string | null }) {\n  if (media.sha256) return `\"${media.sha256}\"`\n  return `W/\"media-${media.id}-${media.updatedAt || '0'}\"`\n}\n\nexport async function GET(\n  request: NextRequest,\n  context: { params: Promise<{ publicId: string }> },\n) {\n  const { publicId } = await context.params\n  const media = await getMediaObjectByPublicId(publicId)\n\n  if (!media) {\n    return NextResponse.json({ error: 'Media not found' }, { status: 404 })\n  }\n  if (!media.storageKey) {\n    return NextResponse.json({ error: 'Media storage key missing' }, { status: 500 })\n  }\n\n  const etag = buildEtag({\n    id: media.id,\n    sha256: media.sha256,\n    updatedAt: media.updatedAt || null,\n  })\n\n  const ifNoneMatch = request.headers.get('if-none-match')\n  if (ifNoneMatch && ifNoneMatch === etag) {\n    return new NextResponse(null, {\n      status: 304,\n      headers: {\n        ETag: etag,\n        'Cache-Control': 'public, max-age=31536000, immutable',\n      },\n    })\n  }\n\n  const fetchUrl = toFetchableUrl(getSignedUrl(media.storageKey))\n  const range = request.headers.get('range')\n\n  const upstream = await fetch(fetchUrl, {\n    headers: range ? { Range: range } : undefined,\n  })\n\n  if (!upstream.ok) {\n    const status = upstream.status === 404 ? 404 : 502\n    return NextResponse.json({ error: 'Failed to fetch media' }, { status })\n  }\n\n  const contentType = media.mimeType || upstream.headers.get('content-type') || 'application/octet-stream'\n  const contentLength = upstream.headers.get('content-length')\n  const contentRange = upstream.headers.get('content-range')\n  const acceptRanges = upstream.headers.get('accept-ranges') || (contentType.startsWith('video/') ? 'bytes' : null)\n\n  const headers = new Headers()\n  headers.set('Content-Type', contentType)\n  headers.set('Cache-Control', 'public, max-age=31536000, immutable')\n  headers.set('ETag', etag)\n  if (contentLength) headers.set('Content-Length', contentLength)\n  if (contentRange) headers.set('Content-Range', contentRange)\n  if (acceptRanges) headers.set('Accept-Ranges', acceptRanges)\n\n  return new Response(upstream.body, {\n    status: upstream.status === 206 ? 206 : 200,\n    headers,\n  })\n}\n\nexport async function HEAD(\n  request: NextRequest,\n  context: { params: Promise<{ publicId: string }> },\n) {\n  const { publicId } = await context.params\n  const media = await getMediaObjectByPublicId(publicId)\n  if (!media) {\n    return NextResponse.json({ error: 'Media not found' }, { status: 404 })\n  }\n\n  const etag = buildEtag({\n    id: media.id,\n    sha256: media.sha256,\n    updatedAt: media.updatedAt || null,\n  })\n\n  const headers = new Headers()\n  headers.set('Cache-Control', 'public, max-age=31536000, immutable')\n  headers.set('ETag', etag)\n  if (media.mimeType) headers.set('Content-Type', media.mimeType)\n  if (media.sizeBytes != null) headers.set('Content-Length', String(media.sizeBytes))\n  return new Response(null, { status: 200, headers })\n}\n"
  },
  {
    "path": "src/components/ConfirmDialog.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ConfirmDialogProps {\n  show: boolean\n  title: string\n  message: string\n  confirmText?: string\n  cancelText?: string\n  onConfirm: () => void\n  onCancel: () => void\n  type?: 'danger' | 'warning' | 'info'\n}\n\nexport default function ConfirmDialog({\n  show,\n  title,\n  message,\n  confirmText,\n  cancelText,\n  onConfirm,\n  onCancel,\n  type = 'danger'\n}: ConfirmDialogProps) {\n  const t = useTranslations('common')\n\n  const finalConfirmText = confirmText || t('confirm')\n  const finalCancelText = cancelText || t('cancel')\n  if (!show) return null\n\n  const typeStyles = {\n    danger: {\n      icon: (\n        <AppIcon name=\"alert\" className=\"w-6 h-6 text-[var(--glass-tone-danger-fg)]\" />\n      ),\n      confirmBg: 'glass-btn-tone-danger',\n      iconBg: 'bg-[var(--glass-tone-danger-bg)]'\n    },\n    warning: {\n      icon: (\n        <AppIcon name=\"alert\" className=\"w-6 h-6 text-[var(--glass-tone-warning-fg)]\" />\n      ),\n      confirmBg: 'glass-btn-tone-warning',\n      iconBg: 'bg-[var(--glass-tone-warning-bg)]'\n    },\n    info: {\n      icon: (\n        <AppIcon name=\"info\" className=\"w-6 h-6 text-[var(--glass-tone-info-fg)]\" />\n      ),\n      confirmBg: 'glass-btn-tone-info',\n      iconBg: 'bg-[var(--glass-tone-info-bg)]'\n    }\n  }\n\n  const currentStyle = typeStyles[type]\n\n  return (\n    <>\n      {/* 背景遮罩 */}\n      <div\n        className=\"fixed inset-0 z-50 glass-overlay animate-fade-in\"\n        onClick={onCancel}\n      />\n\n      {/* 对话框 */}\n      <div className=\"fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none\">\n        <div\n          className=\"glass-surface-modal max-w-md w-full p-6 pointer-events-auto animate-scale-in\"\n          onClick={(e) => e.stopPropagation()}\n        >\n          {/* 图标 */}\n          <div className={`w-12 h-12 rounded-full ${currentStyle.iconBg} flex items-center justify-center mb-4`}>\n            {currentStyle.icon}\n          </div>\n\n          {/* 标题 */}\n          <h3 className=\"mb-2 text-xl font-semibold text-[var(--glass-text-primary)]\">\n            {title}\n          </h3>\n\n          {/* 消息 */}\n          <p className=\"mb-6 text-[var(--glass-text-secondary)]\">\n            {message}\n          </p>\n\n          {/* 按钮 */}\n          <div className=\"flex gap-3\">\n            <button\n              onClick={onCancel}\n              className=\"glass-btn-base glass-btn-secondary flex-1 px-4 py-2.5 font-medium rounded-xl\"\n            >\n              {finalCancelText}\n            </button>\n            <button\n              onClick={onConfirm}\n              className={`glass-btn-base flex-1 px-4 py-2.5 font-medium rounded-xl ${currentStyle.confirmBg}`}\n            >\n              {finalConfirmText}\n            </button>\n          </div>\n        </div>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/components/LanguageSwitcher.tsx",
    "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { useLocale } from 'next-intl'\nimport { type Locale } from '@/i18n/routing'\nimport ConfirmDialog from './ConfirmDialog'\nimport { AppIcon } from '@/components/ui/icons'\nimport { usePathname, useRouter } from '@/i18n/navigation'\n\nconst LANGUAGE_LABELS: Record<Locale, string> = {\n    zh: '简体中文',\n    en: 'English',\n}\n\nconst SWITCH_CONFIRM_COPY: Record<Locale, { title: string; message: string; action: string; cancel: string; triggerLabel: string }> = {\n    zh: {\n        title: '切换语言？',\n        message:\n            '切换到 {targetLanguage} 后，不仅界面文字会改变，整条流程的提示词模板、剧本生成和任务输出语言也会同步切换。是否继续？',\n        action: '确认切换',\n        cancel: '取消',\n        triggerLabel: '切换语言',\n    },\n    en: {\n        title: 'Switch language?',\n        message:\n            'Switching to {targetLanguage} will update not only interface text, but also end-to-end prompt templates, script generation, and workflow output language. Continue?',\n        action: 'Switch now',\n        cancel: 'Cancel',\n        triggerLabel: 'Switch language',\n    },\n}\n\nfunction isSupportedLocale(locale?: string): locale is Locale {\n    return locale === 'zh' || locale === 'en'\n}\n\nexport default function LanguageSwitcher() {\n    const router = useRouter()\n    const pathname = usePathname()\n    const locale = useLocale()\n    const containerRef = useRef<HTMLDivElement | null>(null)\n    const [isMenuOpen, setIsMenuOpen] = useState(false)\n    const [showConfirm, setShowConfirm] = useState(false)\n    const [pendingLocale, setPendingLocale] = useState<Locale | null>(null)\n\n    if (!isSupportedLocale(locale)) {\n        throw new Error('LanguageSwitcher requires locale to be zh or en')\n    }\n    const currentLocale: Locale = locale\n    const targetLocale: Locale = currentLocale === 'zh' ? 'en' : 'zh'\n    const activeLocaleForCopy: Locale = pendingLocale ?? targetLocale\n    const confirmCopy = SWITCH_CONFIRM_COPY[activeLocaleForCopy]\n\n    useEffect(() => {\n        if (!isMenuOpen) return\n\n        const handleClickOutside = (event: MouseEvent) => {\n            if (containerRef.current && !containerRef.current.contains(event.target as Node)) {\n                setIsMenuOpen(false)\n            }\n        }\n\n        document.addEventListener('mousedown', handleClickOutside)\n        return () => {\n            document.removeEventListener('mousedown', handleClickOutside)\n        }\n    }, [isMenuOpen])\n\n    const requestLanguageSwitch = (newLocale: Locale) => {\n        setIsMenuOpen(false)\n        if (newLocale === currentLocale) return\n        setPendingLocale(newLocale)\n        setShowConfirm(true)\n    }\n\n    const confirmLanguageSwitch = () => {\n        if (!pendingLocale) {\n            throw new Error('LanguageSwitcher confirm requires a pending locale')\n        }\n        setShowConfirm(false)\n        setPendingLocale(null)\n        router.replace(pathname, { locale: pendingLocale })\n    }\n\n    const cancelLanguageSwitch = () => {\n        setShowConfirm(false)\n        setPendingLocale(null)\n    }\n\n    return (\n        <>\n            <div ref={containerRef} className=\"relative inline-block\">\n                <button\n                    type=\"button\"\n                    onClick={() => setIsMenuOpen((prev) => !prev)}\n                    aria-label={SWITCH_CONFIRM_COPY[targetLocale].triggerLabel}\n                    aria-expanded={isMenuOpen}\n                    className=\"glass-btn-base glass-btn-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm\"\n                >\n                    <AppIcon name=\"globe\" className=\"h-4 w-4\" />\n                    <span>{LANGUAGE_LABELS[currentLocale]}</span>\n                    <AppIcon name=\"chevronDown\" className=\"h-4 w-4 text-[var(--glass-text-tertiary)]\" />\n                </button>\n\n                {isMenuOpen ? (\n                    <div className=\"glass-surface-modal absolute right-0 z-50 mt-2 w-44 rounded-xl p-2\">\n                        {(Object.entries(LANGUAGE_LABELS) as Array<[Locale, string]>).map(([locale, label]) => {\n                            const isActive = locale === currentLocale\n                            return (\n                                <button\n                                    key={locale}\n                                    type=\"button\"\n                                    onClick={() => requestLanguageSwitch(locale)}\n                                    className={`w-full rounded-lg px-3 py-2 text-left text-sm transition-colors ${isActive\n                                        ? 'bg-[var(--glass-fill-active)] text-[var(--glass-text-primary)]'\n                                        : 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-fill-hover)] hover:text-[var(--glass-text-primary)]'\n                                        }`}\n                                >\n                                    {label}\n                                </button>\n                            )\n                        })}\n                    </div>\n                ) : null}\n            </div>\n            <ConfirmDialog\n                show={showConfirm}\n                title={confirmCopy.title}\n                message={confirmCopy.message.replace('{targetLanguage}', pendingLocale ? LANGUAGE_LABELS[pendingLocale] : '')}\n                confirmText={confirmCopy.action}\n                cancelText={confirmCopy.cancel}\n                onConfirm={confirmLanguageSwitch}\n                onCancel={cancelLanguageSwitch}\n                type=\"info\"\n            />\n        </>\n    )\n}\n"
  },
  {
    "path": "src/components/Navbar.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport Image from 'next/image'\nimport { useSession } from 'next-auth/react'\nimport { useTranslations } from 'next-intl'\nimport LanguageSwitcher from './LanguageSwitcher'\nimport { AppIcon } from '@/components/ui/icons'\nimport UpdateNoticeModal from './UpdateNoticeModal'\nimport { useGithubReleaseUpdate } from '@/hooks/common/useGithubReleaseUpdate'\nimport { Link } from '@/i18n/navigation'\n\n\nexport default function Navbar() {\n  const { data: session, status } = useSession()\n  const t = useTranslations('nav')\n  const tc = useTranslations('common')\n  const { currentVersion, update, shouldPulse, showModal, openModal, dismissCurrentUpdate, checkNow } = useGithubReleaseUpdate()\n  const [checkMsg, setCheckMsg] = useState<string | null>(null)\n  const [checkMsgFading, setCheckMsgFading] = useState(false)\n  const [manualChecking, setManualChecking] = useState(false)\n  const downloadLogsHref = '/api/admin/download-logs'\n\n  const handleCheckUpdate = async () => {\n    setCheckMsg(null)\n    setCheckMsgFading(false)\n    setManualChecking(true)\n    const minSpin = new Promise(r => setTimeout(r, 1000))\n    await Promise.all([checkNow(), minSpin])\n    setManualChecking(false)\n    setTimeout(() => {\n      setCheckMsg('upToDate')\n      setTimeout(() => setCheckMsgFading(true), 2000)\n      setTimeout(() => { setCheckMsg(null); setCheckMsgFading(false) }, 3000)\n    }, 100)\n  }\n\n  return (\n    <>\n      <nav className=\"glass-nav sticky top-0 z-50\">\n        <div className=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\">\n          <div className=\"flex justify-between items-center h-16\">\n            <div className=\"flex items-center gap-2\">\n              <Link href={{ pathname: session ? '/workspace' : '/' }} className=\"group\">\n                <Image\n                  src=\"/logo-small.png?v=1\"\n                  alt={tc('appName')}\n                  width={80}\n                  height={80}\n                  className=\"object-contain transition-transform group-hover:scale-110\"\n                />\n              </Link>\n              <button\n                type=\"button\"\n                onClick={openModal}\n                disabled={!update}\n                className={`relative inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-semibold tracking-[0.02em] transition-all ${update\n                  ? 'border-[var(--glass-tone-warning-fg)]/40 bg-[linear-gradient(135deg,var(--glass-tone-warning-bg),var(--glass-bg-surface-strong))] text-[var(--glass-tone-warning-fg)] shadow-[0_8px_24px_-16px_rgba(245,158,11,0.9)] hover:brightness-105'\n                  : 'border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] text-[var(--glass-text-secondary)] hover:border-[var(--glass-stroke-focus)] hover:text-[var(--glass-text-primary)] disabled:cursor-default'\n                  }`}\n                aria-label={tc('updateNotice.openDialog')}\n              >\n                <span className=\"inline-flex items-center gap-1.5\">\n                  <AppIcon name=\"sparkles\" className=\"h-3.5 w-3.5\" />\n                  {tc('betaVersion', { version: currentVersion })}\n                  {update ? (\n                    <span className=\"relative inline-flex items-center\">\n                      {shouldPulse ? <span className=\"absolute -inset-1.5 animate-ping rounded-full bg-[var(--glass-tone-warning-fg)] opacity-20\" /> : null}\n                      <span className=\"relative inline-flex items-center gap-1 rounded-full bg-[var(--glass-tone-warning-fg)]/16 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em]\">\n                        <AppIcon name=\"upload\" className=\"h-3 w-3\" />\n                        {tc('updateNotice.updateTag')}\n                      </span>\n                    </span>\n                  ) : null}\n                </span>\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => void handleCheckUpdate()}\n                disabled={manualChecking}\n                className=\"rounded-full p-1.5 text-[var(--glass-text-tertiary)] hover:bg-[var(--glass-bg-muted)] hover:text-[var(--glass-text-secondary)] transition-colors disabled:opacity-40\"\n                title={tc('updateNotice.checkUpdate')}\n              >\n                <AppIcon name=\"refresh\" className={`h-3.5 w-3.5 ${manualChecking ? 'animate-spin' : ''}`} />\n              </button>\n              {checkMsg === 'upToDate' && !update && (\n                <span\n                  className=\"text-[11px] text-[var(--glass-tone-success-fg)] font-medium transition-opacity duration-1000\"\n                  style={{ opacity: checkMsgFading ? 0 : 1 }}\n                >\n                  ✓ {tc('updateNotice.upToDate')}\n                </span>\n              )}\n            </div>\n            <div className=\"flex items-center space-x-6\">\n              {status === 'loading' ? (\n                /* Session 加载中骨架屏 */\n                <div className=\"flex items-center space-x-4\">\n                  <div className=\"h-4 w-16 rounded-full bg-[var(--glass-bg-muted)] animate-pulse\" />\n                  <div className=\"h-4 w-16 rounded-full bg-[var(--glass-bg-muted)] animate-pulse\" />\n                  <div className=\"h-8 w-20 rounded-lg bg-[var(--glass-bg-muted)] animate-pulse\" />\n                </div>\n              ) : session ? (\n                <>\n                  <Link\n                    href={{ pathname: '/workspace' }}\n                    className=\"text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1\"\n                  >\n                    <AppIcon name=\"monitor\" className=\"w-4 h-4\" />\n                    {t('workspace')}\n                  </Link>\n                  <Link\n                    href={{ pathname: '/workspace/asset-hub' }}\n                    className=\"text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1\"\n                  >\n                    <AppIcon name=\"folderHeart\" className=\"w-4 h-4\" />\n                    {t('assetHub')}\n                  </Link>\n                  <Link\n                    href={{ pathname: '/profile' }}\n                    className=\"text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1\"\n                    title={t('profile')}\n                  >\n                    <AppIcon name=\"userRoundCog\" className=\"w-5 h-5\" />\n                    {t('profile')}\n                  </Link>\n                  <LanguageSwitcher />\n                  <a\n                    href={downloadLogsHref}\n                    download\n                    className=\"text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors flex items-center gap-1\"\n                    title={t('downloadLogs')}\n                  >\n                    <AppIcon name=\"download\" className=\"w-4 h-4\" />\n                    {t('downloadLogs')}\n                  </a>\n                </>\n\n              ) : (\n                <>\n                  <Link\n                    href={{ pathname: '/auth/signin' }}\n                    className=\"text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] font-medium transition-colors\"\n                  >\n                    {t('signin')}\n                  </Link>\n                  <Link\n                    href={{ pathname: '/auth/signup' }}\n                    className=\"glass-btn-base glass-btn-primary px-4 py-2 text-sm font-medium\"\n                  >\n                    {t('signup')}\n                  </Link>\n                  <LanguageSwitcher />\n                </>\n              )}\n            </div>\n          </div>\n        </div>\n      </nav>\n      {update ? (\n        <UpdateNoticeModal\n          show={showModal}\n          currentVersion={currentVersion}\n          latestVersion={update.latestVersion}\n          releaseUrl={update.releaseUrl}\n          releaseName={update.releaseName}\n          publishedAt={update.publishedAt}\n          onDismiss={dismissCurrentUpdate}\n        />\n      ) : null}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/components/ProgressToast.tsx",
    "content": "'use client'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\n\ninterface ProgressToastRunBadge {\n  id: string\n  label: string\n  onClick: () => void\n}\n\ninterface ProgressToastProps {\n  show: boolean\n  message: string\n  step?: string\n  runBadges?: ProgressToastRunBadge[]\n}\n\nexport default function ProgressToast({ show, message, step, runBadges }: ProgressToastProps) {\n  if (!show) return null\n  const runningState = resolveTaskPresentationState({\n    phase: 'processing',\n    intent: 'generate',\n    resource: 'text',\n    hasOutput: true,\n  })\n\n  return (\n    <div className=\"fixed bottom-8 right-8 z-50 animate-slide-up\">\n      <div className=\"glass-surface-modal min-w-[280px] max-w-[90vw] p-4\">\n        <div className=\"flex items-start space-x-3\">\n          {/* Loading Spinner */}\n          <div className=\"mt-0.5 shrink-0\">\n            <TaskStatusInline state={runningState} className=\"[&>span]:sr-only\" />\n          </div>\n\n          {/* Content */}\n          <div className=\"flex-1\">\n            <div className=\"mb-1 font-semibold text-(--glass-text-primary)\">\n              {message}\n            </div>\n            {step && (\n              <div className=\"text-sm text-(--glass-text-secondary)\">\n                {step}\n              </div>\n            )}\n\n            {runBadges && runBadges.length > 0 && (\n              <div className=\"mt-2 flex flex-wrap gap-2\">\n                {runBadges.map((badge) => (\n                  <button\n                    key={badge.id}\n                    type=\"button\"\n                    onClick={badge.onClick}\n                    className=\"glass-btn-base glass-btn-secondary rounded-full px-3 py-1 text-xs text-(--glass-tone-info-fg)\"\n                  >\n                    {badge.label}\n                  </button>\n                ))}\n              </div>\n            )}\n          </div>\n        </div>\n\n        {/* Progress Bar */}\n        <div className=\"mt-3 h-1.5 w-full overflow-hidden rounded-full bg-(--glass-bg-muted)\">\n          <div className=\"h-1.5 rounded-full bg-(--glass-accent-from) animate-progress\" />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/UpdateNoticeModal.tsx",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport Link from 'next/link'\nimport { useLocale, useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\n\nexport interface UpdateNoticeModalProps {\n  show: boolean\n  currentVersion: string\n  latestVersion: string\n  releaseUrl: string\n  releaseName: string | null\n  publishedAt: string | null\n  onDismiss: () => void\n}\n\nexport default function UpdateNoticeModal({\n  show,\n  currentVersion,\n  latestVersion,\n  releaseUrl,\n  releaseName,\n  publishedAt,\n  onDismiss,\n}: UpdateNoticeModalProps) {\n  const t = useTranslations('common.updateNotice')\n  const locale = useLocale()\n\n  const formattedPublishedAt = useMemo(() => {\n    if (!publishedAt) return null\n    const parsed = new Date(publishedAt)\n    if (Number.isNaN(parsed.getTime())) {\n      return publishedAt\n    }\n\n    return new Intl.DateTimeFormat(locale, {\n      dateStyle: 'medium',\n      timeStyle: 'short',\n    }).format(parsed)\n  }, [locale, publishedAt])\n\n  if (!show) return null\n\n  return (\n    <>\n      <div className=\"fixed inset-0 z-[80] glass-overlay animate-fade-in\" onClick={onDismiss} />\n      <div className=\"fixed inset-0 z-[81] flex items-center justify-center p-4 pointer-events-none\">\n        <section\n          className=\"glass-surface-modal w-full max-w-lg p-6 pointer-events-auto animate-scale-in\"\n          onClick={(event) => event.stopPropagation()}\n        >\n          <header className=\"flex items-start justify-between gap-3\">\n            <div className=\"flex items-start gap-3\">\n              <div className=\"mt-0.5 flex h-11 w-11 items-center justify-center rounded-full bg-[var(--glass-tone-info-bg)]\">\n                <AppIcon name=\"sparkles\" className=\"h-5 w-5 text-[var(--glass-tone-info-fg)]\" />\n              </div>\n              <div>\n                <h3 className=\"text-xl font-semibold text-[var(--glass-text-primary)]\">{t('title')}</h3>\n                <p className=\"mt-1 text-sm text-[var(--glass-text-secondary)]\">\n                  {t('subtitle', { current: currentVersion, latest: latestVersion })}\n                </p>\n              </div>\n            </div>\n            <button\n              type=\"button\"\n              onClick={onDismiss}\n              className=\"glass-btn-base glass-btn-soft rounded-lg p-1.5 text-[var(--glass-text-tertiary)]\"\n              aria-label={t('close')}\n            >\n              <AppIcon name=\"close\" className=\"h-4 w-4\" />\n            </button>\n          </header>\n\n          <p className=\"mt-4 text-sm leading-6 text-[var(--glass-text-secondary)]\">{t('description')}</p>\n\n          <div className=\"mt-4 space-y-2 rounded-xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] p-3.5\">\n            {releaseName ? (\n              <div className=\"flex items-start justify-between gap-3 text-sm\">\n                <span className=\"text-[var(--glass-text-tertiary)]\">{t('releaseName')}</span>\n                <span className=\"text-right font-medium text-[var(--glass-text-primary)]\">{releaseName}</span>\n              </div>\n            ) : null}\n            {formattedPublishedAt ? (\n              <div className=\"flex items-start justify-between gap-3 text-sm\">\n                <span className=\"text-[var(--glass-text-tertiary)]\">{t('publishedAt')}</span>\n                <span className=\"text-right text-[var(--glass-text-primary)]\">{formattedPublishedAt}</span>\n              </div>\n            ) : null}\n          </div>\n\n          <footer className=\"mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n            <button\n              type=\"button\"\n              onClick={onDismiss}\n              className=\"glass-btn-base glass-btn-secondary rounded-xl px-4 py-2.5 text-sm font-medium\"\n            >\n              {t('close')}\n            </button>\n            <Link\n              href={releaseUrl}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"glass-btn-base glass-btn-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium\"\n            >\n              <AppIcon name=\"externalLink\" className=\"h-4 w-4\" />\n              {t('viewRelease')}\n            </Link>\n          </footer>\n        </section>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/components/ai-elements/conversation.tsx",
    "content": "\"use client\";\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type HTMLAttributes,\n} from \"react\";\n\nconst BOTTOM_THRESHOLD_PX = 24;\n\ninterface ConversationContextValue {\n  viewport: HTMLDivElement | null;\n  setViewport: (node: HTMLDivElement | null) => void;\n  isAtBottom: boolean;\n  scrollToBottom: (behavior?: ScrollBehavior) => void;\n}\n\nconst ConversationContext = createContext<ConversationContextValue | null>(null);\n\nfunction useConversationContext(): ConversationContextValue {\n  const context = useContext(ConversationContext);\n  if (!context) {\n    throw new Error(\"Conversation components must be used within Conversation\");\n  }\n  return context;\n}\n\nfunction joinClassName(...values: Array<string | undefined>): string {\n  return values.filter((value): value is string => Boolean(value)).join(\" \");\n}\n\nfunction computeIsAtBottom(element: HTMLDivElement): boolean {\n  const remaining = element.scrollHeight - element.scrollTop - element.clientHeight;\n  return remaining <= BOTTOM_THRESHOLD_PX;\n}\n\nexport type ConversationProps = HTMLAttributes<HTMLDivElement>;\n\nexport const Conversation = ({\n  className,\n  children,\n  ...props\n}: ConversationProps) => {\n  const [viewport, setViewport] = useState<HTMLDivElement | null>(null);\n  const [isAtBottom, setIsAtBottom] = useState(true);\n\n  const syncAtBottom = useCallback(() => {\n    if (!viewport) return;\n    setIsAtBottom(computeIsAtBottom(viewport));\n  }, [viewport]);\n\n  const scrollToBottom = useCallback((behavior: ScrollBehavior = \"smooth\"): void => {\n    if (!viewport) return;\n    viewport.scrollTo({\n      top: viewport.scrollHeight,\n      behavior,\n    });\n  }, [viewport]);\n\n  useEffect(() => {\n    if (!viewport) return;\n    const onScroll = (): void => syncAtBottom();\n    viewport.addEventListener(\"scroll\", onScroll, { passive: true });\n    syncAtBottom();\n    return () => viewport.removeEventListener(\"scroll\", onScroll);\n  }, [syncAtBottom, viewport]);\n\n  const contextValue = useMemo<ConversationContextValue>(() => ({\n    viewport,\n    setViewport,\n    isAtBottom,\n    scrollToBottom,\n  }), [isAtBottom, scrollToBottom, viewport]);\n\n  return (\n    <ConversationContext.Provider value={contextValue}>\n      <div className={joinClassName(\"relative flex h-full min-h-0 flex-col\", className)} {...props}>\n        {children}\n      </div>\n    </ConversationContext.Provider>\n  );\n};\n\nexport type ConversationContentProps = HTMLAttributes<HTMLDivElement>;\n\nexport const ConversationContent = ({\n  className,\n  children,\n  ...props\n}: ConversationContentProps) => {\n  const { viewport, setViewport, isAtBottom, scrollToBottom } = useConversationContext();\n  const scrollFrameIdRef = useRef<number | null>(null);\n\n  useEffect(() => {\n    if (!viewport || !isAtBottom) return;\n    if (scrollFrameIdRef.current !== null) cancelAnimationFrame(scrollFrameIdRef.current);\n    scrollFrameIdRef.current = requestAnimationFrame(() => {\n      scrollFrameIdRef.current = null;\n      scrollToBottom(\"auto\");\n    });\n    return () => {\n      if (scrollFrameIdRef.current !== null) {\n        cancelAnimationFrame(scrollFrameIdRef.current);\n        scrollFrameIdRef.current = null;\n      }\n    };\n  }, [children, isAtBottom, scrollToBottom, viewport]);\n\n  return (\n    <div\n      ref={setViewport}\n      className={joinClassName(\"h-full min-h-0 overflow-y-auto overscroll-contain\", className)}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n};\n\nexport type ConversationScrollButtonProps = HTMLAttributes<HTMLButtonElement>;\n\nexport const ConversationScrollButton = ({\n  className,\n  onClick,\n  children,\n  ...props\n}: ConversationScrollButtonProps) => {\n  const { isAtBottom, scrollToBottom } = useConversationContext();\n  if (isAtBottom) return null;\n  return (\n    <button\n      type=\"button\"\n      className={joinClassName(\n        \"absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-3 py-1 text-xs text-[var(--glass-text-secondary)] shadow-sm backdrop-blur\",\n        \"hover:bg-[var(--glass-bg-soft)]\",\n        className,\n      )}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented) return;\n        scrollToBottom(\"smooth\");\n      }}\n      {...props}\n    >\n      {children ?? \"跳到底部\"}\n    </button>\n  );\n};\n"
  },
  {
    "path": "src/components/ai-elements/message.tsx",
    "content": "\"use client\";\n\nimport type { UIMessage } from \"ai\";\nimport type { HTMLAttributes } from \"react\";\n\nexport type MessageProps = HTMLAttributes<HTMLDivElement> & {\n  from: UIMessage[\"role\"];\n};\n\nfunction joinClassName(...values: Array<string | undefined>): string {\n  return values.filter((value): value is string => Boolean(value)).join(\" \");\n}\n\nexport const Message = ({ className, from, ...props }: MessageProps) => (\n  <div\n    className={joinClassName(\n      \"group flex w-full flex-col gap-2\",\n      from === \"user\" ? \"is-user items-end\" : \"is-assistant items-start\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nexport type MessageContentProps = HTMLAttributes<HTMLDivElement>;\n\nexport const MessageContent = ({\n  children,\n  className,\n  ...props\n}: MessageContentProps) => (\n  <div\n    className={joinClassName(\n      \"flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm\",\n      className,\n    )}\n    {...props}\n  >\n    {children}\n  </div>\n);\n\nexport type MessageResponseProps = HTMLAttributes<HTMLDivElement>;\n\nexport const MessageResponse = ({\n  className,\n  ...props\n}: MessageResponseProps) => (\n  <div\n    className={joinClassName(\"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0\", className)}\n    {...props}\n  />\n);\n"
  },
  {
    "path": "src/components/ai-elements/reasoning.tsx",
    "content": "\"use client\";\n\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { createContext, useCallback, useContext, useMemo, useState } from \"react\";\n\ninterface ReasoningContextValue {\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n  isStreaming: boolean;\n  duration?: number;\n}\n\nconst ReasoningContext = createContext<ReasoningContextValue | null>(null);\n\nfunction useReasoningContext(): ReasoningContextValue {\n  const context = useContext(ReasoningContext);\n  if (!context) {\n    throw new Error(\"Reasoning components must be used within Reasoning\");\n  }\n  return context;\n}\n\nexport type ReasoningProps = HTMLAttributes<HTMLDivElement> & {\n  isStreaming?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  duration?: number;\n};\n\nfunction joinClassName(...values: Array<string | undefined>): string {\n  return values.filter((value): value is string => Boolean(value)).join(\" \");\n}\n\nexport const Reasoning = ({\n  className,\n  isStreaming = false,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  duration,\n  children,\n  ...props\n}: ReasoningProps) => {\n  const [uncontrolledOpen, setUncontrolledOpen] = useState<boolean>(defaultOpen);\n  const isOpen = open ?? uncontrolledOpen;\n\n  const setIsOpen = useCallback((next: boolean): void => {\n    if (open === undefined) {\n      setUncontrolledOpen(next);\n    }\n    onOpenChange?.(next);\n  }, [onOpenChange, open]);\n\n  const value = useMemo<ReasoningContextValue>(() => ({\n    isOpen,\n    setIsOpen,\n    isStreaming,\n    duration,\n  }), [duration, isOpen, isStreaming, setIsOpen]);\n\n  return (\n    <ReasoningContext.Provider value={value}>\n      <div className={joinClassName(\"space-y-2\", className)} {...props}>\n        {children}\n      </div>\n    </ReasoningContext.Provider>\n  );\n};\n\nexport type ReasoningTriggerProps = HTMLAttributes<HTMLButtonElement> & {\n  getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;\n};\n\nconst defaultGetThinkingMessage = (isStreaming: boolean, duration?: number): ReactNode => {\n  if (isStreaming || duration === 0) return \"Thinking...\";\n  if (duration === undefined) return \"Thought for a few seconds\";\n  return `Thought for ${duration} seconds`;\n};\n\nexport const ReasoningTrigger = ({\n  className,\n  children,\n  getThinkingMessage = defaultGetThinkingMessage,\n  ...props\n}: ReasoningTriggerProps) => {\n  const { isOpen, setIsOpen, isStreaming, duration } = useReasoningContext();\n  return (\n    <button\n      type=\"button\"\n      className={joinClassName(\n        \"inline-flex items-center gap-1 text-left text-xs text-[var(--glass-text-secondary)]\",\n        className,\n      )}\n      onClick={() => setIsOpen(!isOpen)}\n      aria-expanded={isOpen}\n      {...props}\n    >\n      <span aria-hidden>{isOpen ? \"▾\" : \"▸\"}</span>\n      {children ?? getThinkingMessage(isStreaming, duration)}\n    </button>\n  );\n};\n\nexport type ReasoningContentProps = HTMLAttributes<HTMLDivElement> & {\n  children: string;\n};\n\nexport const ReasoningContent = ({\n  className,\n  children,\n  ...props\n}: ReasoningContentProps) => {\n  const { isOpen } = useReasoningContext();\n  if (!isOpen) return null;\n  return (\n    <div className={joinClassName(\"whitespace-pre-wrap break-words text-xs leading-relaxed\", className)} {...props}>\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/ai-elements/tool.tsx",
    "content": "\"use client\";\n\nimport type { DynamicToolUIPart, ToolUIPart } from \"ai\";\nimport type { HTMLAttributes } from \"react\";\nimport { createContext, useContext, useMemo, useState } from \"react\";\n\nexport type ToolPart = ToolUIPart | DynamicToolUIPart;\n\ninterface ToolContextValue {\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n}\n\nconst ToolContext = createContext<ToolContextValue | null>(null);\n\nfunction useToolContext(): ToolContextValue {\n  const context = useContext(ToolContext);\n  if (!context) throw new Error(\"Tool components must be used within Tool\");\n  return context;\n}\n\nfunction joinClassName(...values: Array<string | undefined>): string {\n  return values.filter((value): value is string => Boolean(value)).join(\" \");\n}\n\nexport type ToolProps = HTMLAttributes<HTMLDivElement> & {\n  defaultOpen?: boolean;\n};\n\nexport const Tool = ({\n  className,\n  defaultOpen = false,\n  children,\n  ...props\n}: ToolProps) => {\n  const [isOpen, setIsOpen] = useState(defaultOpen);\n  const value = useMemo<ToolContextValue>(() => ({ isOpen, setIsOpen }), [isOpen]);\n  return (\n    <ToolContext.Provider value={value}>\n      <div className={joinClassName(\"rounded-xl border\", className)} {...props}>\n        {children}\n      </div>\n    </ToolContext.Provider>\n  );\n};\n\nexport type ToolHeaderProps = HTMLAttributes<HTMLButtonElement> & {\n  title?: string;\n} & (\n  | { type: ToolUIPart[\"type\"]; state: ToolUIPart[\"state\"]; toolName?: never }\n  | {\n    type: DynamicToolUIPart[\"type\"];\n    state: DynamicToolUIPart[\"state\"];\n    toolName: string;\n  }\n);\n\nexport const ToolHeader = ({\n  className,\n  title,\n  type,\n  state,\n  toolName,\n  ...props\n}: ToolHeaderProps) => {\n  const { isOpen, setIsOpen } = useToolContext();\n  const derivedName =\n    type === \"dynamic-tool\" ? toolName : type.split(\"-\").slice(1).join(\"-\");\n  const headerTitle = title ?? `${derivedName} (${state})`;\n  return (\n    <button\n      type=\"button\"\n      className={joinClassName(\n        \"flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-[var(--glass-text-secondary)]\",\n        className,\n      )}\n      onClick={() => setIsOpen(!isOpen)}\n      aria-expanded={isOpen}\n      {...props}\n    >\n      <span>{headerTitle}</span>\n      <span aria-hidden>{isOpen ? \"▾\" : \"▸\"}</span>\n    </button>\n  );\n};\n\nexport type ToolContentProps = HTMLAttributes<HTMLDivElement>;\n\nexport const ToolContent = ({\n  className,\n  children,\n  ...props\n}: ToolContentProps) => {\n  const { isOpen } = useToolContext();\n  if (!isOpen) return null;\n  return (\n    <div className={joinClassName(\"space-y-2 border-t px-3 py-2\", className)} {...props}>\n      {children}\n    </div>\n  );\n};\n\nfunction formatJson(value: unknown): string {\n  try {\n    return JSON.stringify(value, null, 2);\n  } catch {\n    return String(value);\n  }\n}\n\nexport type ToolInputProps = HTMLAttributes<HTMLDivElement> & {\n  input: ToolPart[\"input\"];\n};\n\nexport const ToolInput = ({ className, input, ...props }: ToolInputProps) => (\n  <div className={joinClassName(\"space-y-1\", className)} {...props}>\n    <div className=\"mb-1 text-xs text-[var(--glass-text-tertiary)]\">Parameters</div>\n    <pre className=\"max-h-56 overflow-x-auto overflow-y-auto rounded-md bg-muted/50 p-3 text-xs leading-relaxed\">\n      {formatJson(input)}\n    </pre>\n  </div>\n);\n\nexport type ToolOutputProps = HTMLAttributes<HTMLDivElement> & {\n  output: ToolPart[\"output\"];\n  errorText: ToolPart[\"errorText\"];\n};\n\nexport const ToolOutput = ({\n  className,\n  output,\n  errorText,\n  ...props\n}: ToolOutputProps) => {\n  if (!(output || errorText)) return null;\n  return (\n    <div className={joinClassName(\"space-y-1\", className)} {...props}>\n      <div className=\"mb-1 text-xs text-[var(--glass-text-tertiary)]\">\n        {errorText ? \"Error\" : \"Result\"}\n      </div>\n      <div className={errorText ? \"text-red-500\" : undefined}>\n        {errorText && (\n          <div className=\"mb-2 whitespace-pre-wrap break-words text-xs leading-relaxed\">\n            {errorText}\n          </div>\n        )}\n        <pre className=\"max-h-56 overflow-x-auto overflow-y-auto rounded-md bg-muted/50 p-3 text-xs leading-relaxed\">\n          {typeof output === \"string\" ? output : formatJson(output)}\n        </pre>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/assistant/AssistantChatModal.tsx",
    "content": "'use client'\n\nimport type { UIMessage } from 'ai'\nimport { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'\nimport { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation'\nimport { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message'\nimport { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'\nimport { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, type ToolPart } from '@/components/ai-elements/tool'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface AssistantChatModalProps {\n  open: boolean\n  title: string\n  subtitle: string\n  closeLabel: string\n  userLabel: string\n  assistantLabel: string\n  reasoningTitle: string\n  reasoningExpandLabel: string\n  reasoningCollapseLabel: string\n  emptyAssistantMessage?: string\n  inputPlaceholder: string\n  sendLabel: string\n  pendingLabel: string\n  messages: UIMessage[]\n  input: string\n  pending: boolean\n  completed?: boolean\n  completedTitle?: string\n  completedMessage?: string\n  errorMessage?: string\n  onClose: () => void\n  onInputChange: (value: string) => void\n  onSend: () => void\n}\n\ninterface ParsedMessageContent {\n  lines: string[]\n  reasoningLines: string[]\n}\n\ntype ParsedToolPart =\n  | {\n    partType: 'dynamic-tool'\n    state: ToolPart['state']\n    toolName: string\n    input: unknown\n    output: unknown\n    errorText?: string\n  }\n  | {\n    partType: Exclude<ToolPart['type'], 'dynamic-tool'>\n    state: ToolPart['state']\n    input: unknown\n    output: unknown\n    errorText?: string\n  }\n\ninterface RenderableMessage {\n  id: string\n  role: UIMessage['role']\n  lines: string[]\n  reasoningLines: string[]\n  tools: ParsedToolPart[]\n}\n\ninterface MessageCacheEntry {\n  signature: string\n  rendered: RenderableMessage\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction joinClassNames(...values: Array<string | undefined>): string {\n  return values.filter((value): value is string => Boolean(value)).join(' ')\n}\n\nfunction isToolState(value: string): value is ToolPart['state'] {\n  return value === 'approval-requested'\n    || value === 'approval-responded'\n    || value === 'input-streaming'\n    || value === 'input-available'\n    || value === 'output-available'\n    || value === 'output-error'\n    || value === 'output-denied'\n}\n\nfunction isToolPartType(value: string): value is ToolPart['type'] {\n  if (value === 'dynamic-tool') return true\n  return value.startsWith('tool-')\n}\n\nfunction splitThinkTaggedContent(input: string): { text: string; reasoning: string } {\n  const thinkTagPattern = /<(think|thinking)\\b[^>]*>([\\s\\S]*?)<\\/\\1>/gi\n  const reasoningParts: string[] = []\n  let hadTag = false\n\n  const stripped = input.replace(thinkTagPattern, (_fullMatch, _tagName: string, inner: string) => {\n    hadTag = true\n    const trimmedInner = inner.trim()\n    if (trimmedInner) reasoningParts.push(trimmedInner)\n    return ''\n  })\n\n  let visibleText = stripped\n\n  const openTagMatch = visibleText.match(/<(think|thinking)\\b[^>]*>/i)\n  if (openTagMatch && typeof openTagMatch.index === 'number') {\n    hadTag = true\n    const start = openTagMatch.index\n    const openTag = openTagMatch[0]\n    const tail = visibleText\n      .slice(start + openTag.length)\n      .replace(/<\\/(think|thinking)\\s*>/gi, '')\n      .trim()\n    if (tail) reasoningParts.push(tail)\n    visibleText = visibleText.slice(0, start)\n  }\n\n  if (!hadTag) {\n    return {\n      text: input.trim(),\n      reasoning: '',\n    }\n  }\n\n  return {\n    text: visibleText.trim(),\n    reasoning: reasoningParts.join('\\n\\n').trim(),\n  }\n}\n\nfunction parseToolPart(part: unknown): ParsedToolPart | null {\n  if (!isRecord(part)) return null\n  const rawType = readTrimmedString(part.type)\n  if (!isToolPartType(rawType)) return null\n  const rawState = readTrimmedString(part.state)\n  if (!isToolState(rawState)) return null\n  const toolName = readTrimmedString(part.toolName)\n  const output = part.output\n  const input = part.input\n  const errorText = readTrimmedString(part.errorText) || undefined\n  if (rawType === 'dynamic-tool') {\n    if (!toolName) return null\n    return {\n      partType: rawType,\n      state: rawState,\n      toolName,\n      input,\n      output,\n      ...(errorText ? { errorText } : {}),\n    }\n  }\n  return {\n    partType: rawType,\n    state: rawState,\n    input,\n    output,\n    ...(errorText ? { errorText } : {}),\n  }\n}\n\nfunction buildMessageSignature(message: UIMessage): string {\n  const parts: string[] = []\n  for (const part of message.parts) {\n    if (!isRecord(part)) {\n      parts.push('x')\n      continue\n    }\n    const partRecord = part as Record<string, unknown>\n    const type = readTrimmedString(partRecord.type)\n    const state = readTrimmedString(partRecord.state)\n    const toolName = readTrimmedString(partRecord.toolName)\n    const text = typeof partRecord.text === 'string' ? partRecord.text : ''\n    const errorText = typeof partRecord.errorText === 'string' ? partRecord.errorText : ''\n    let outputMarker = ''\n    if (isRecord(partRecord.output)) {\n      outputMarker = `${readTrimmedString(partRecord.output.status)}:${readTrimmedString(partRecord.output.message)}`\n    }\n    parts.push(`${type}:${state}:${toolName}:${text}:${errorText}:${outputMarker}`)\n  }\n  return `${message.role}|${parts.join('|')}`\n}\n\nexport function extractMessageContent(message: UIMessage): ParsedMessageContent {\n  const lines: string[] = []\n  const reasoningLines: string[] = []\n\n  for (const part of message.parts) {\n    if (!isRecord(part)) continue\n    const partRecord = part as Record<string, unknown>\n    const partType = readTrimmedString(partRecord.type)\n    const text = typeof partRecord.text === 'string' ? partRecord.text.trim() : ''\n\n    if (partType === 'text' && text) {\n      const parsed = splitThinkTaggedContent(text)\n      if (parsed.reasoning) reasoningLines.push(parsed.reasoning)\n      if (parsed.text) lines.push(parsed.text)\n      continue\n    }\n\n    if (partType === 'reasoning' && text) {\n      const parsed = splitThinkTaggedContent(text)\n      if (parsed.reasoning) reasoningLines.push(parsed.reasoning)\n      else if (parsed.text) reasoningLines.push(parsed.text)\n      continue\n    }\n\n    const isSaveToolPart = partType === 'tool-saveModelTemplate' || partType === 'tool-saveModelTemplates'\n    if (!isSaveToolPart) continue\n\n    const state = readTrimmedString(partRecord.state)\n    if (state === 'output-error') {\n      const errorText = readTrimmedString(partRecord.errorText)\n      if (errorText) lines.push(errorText)\n      continue\n    }\n\n    if (state !== 'output-available') continue\n    const output = partRecord.output\n    if (!isRecord(output)) continue\n    const messageText = readTrimmedString(output.message)\n    if (messageText) lines.push(messageText)\n    const issues = output.issues\n    if (Array.isArray(issues)) {\n      for (const issue of issues) {\n        if (!isRecord(issue)) continue\n        const field = readTrimmedString(issue.field)\n        const issueMessage = readTrimmedString(issue.message)\n        if (!field && !issueMessage) continue\n        lines.push(`${field || 'issue'}: ${issueMessage || 'invalid'}`)\n      }\n    }\n  }\n\n  return {\n    lines,\n    reasoningLines,\n  }\n}\n\nfunction buildRenderableMessage(message: UIMessage): RenderableMessage {\n  const base = extractMessageContent(message)\n  const tools = message.parts\n    .map((part) => parseToolPart(part))\n    .filter((part): part is ParsedToolPart => part !== null)\n\n  return {\n    id: message.id,\n    role: message.role,\n    lines: base.lines,\n    reasoningLines: base.reasoningLines,\n    tools,\n  }\n}\n\nfunction onEnterSubmit(event: KeyboardEvent<HTMLInputElement>, submit: () => void) {\n  if (event.key !== 'Enter') return\n  if (event.shiftKey || event.nativeEvent.isComposing) return\n  event.preventDefault()\n  submit()\n}\n\nexport function AssistantChatModal({\n  open,\n  title,\n  subtitle,\n  closeLabel,\n  userLabel,\n  assistantLabel,\n  reasoningTitle,\n  reasoningExpandLabel,\n  reasoningCollapseLabel,\n  emptyAssistantMessage,\n  inputPlaceholder,\n  sendLabel,\n  pendingLabel,\n  messages,\n  input,\n  pending,\n  completed = false,\n  completedTitle,\n  completedMessage,\n  errorMessage,\n  onClose,\n  onInputChange,\n  onSend,\n}: AssistantChatModalProps) {\n  const [expandedReasoningByMessageId, setExpandedReasoningByMessageId] = useState<Record<string, boolean>>({})\n  const messageCacheRef = useRef(new Map<string, MessageCacheEntry>())\n\n  useEffect(() => {\n    if (!open) setExpandedReasoningByMessageId({})\n  }, [open])\n\n  const visibleMessages = useMemo(() => {\n    const nextCache = new Map<string, MessageCacheEntry>()\n    const list: RenderableMessage[] = []\n    for (const message of messages) {\n      const signature = buildMessageSignature(message)\n      const cached = messageCacheRef.current.get(message.id)\n      const rendered = cached && cached.signature === signature\n        ? cached.rendered\n        : buildRenderableMessage(message)\n      nextCache.set(message.id, { signature, rendered })\n      if (rendered.lines.length > 0 || rendered.reasoningLines.length > 0 || rendered.tools.length > 0) {\n        list.push(rendered)\n      }\n    }\n    messageCacheRef.current = nextCache\n    return list\n  }, [messages])\n\n  const lastAssistantMessageId = useMemo(() => {\n    for (let index = visibleMessages.length - 1; index >= 0; index -= 1) {\n      const message = visibleMessages[index]\n      if (message?.role === 'assistant') return message.id\n    }\n    return null\n  }, [visibleMessages])\n\n  const shouldShowEmptyAssistantMessage =\n    visibleMessages.length === 0\n    && typeof emptyAssistantMessage === 'string'\n    && emptyAssistantMessage.trim().length > 0\n\n  if (!open) return null\n\n  const setReasoningExpanded = (messageId: string, openState: boolean): void => {\n    setExpandedReasoningByMessageId((previous) => ({\n      ...previous,\n      [messageId]: openState,\n    }))\n  }\n\n  return (\n    <div\n      className=\"fixed inset-0 z-50 flex items-center justify-center glass-overlay px-4\"\n      onClick={onClose}\n    >\n      <div\n        className=\"glass-surface-modal w-full max-w-3xl overflow-hidden rounded-2xl\"\n        onClick={(event) => event.stopPropagation()}\n      >\n        <div className=\"flex items-center justify-between border-b border-[var(--glass-stroke-base)] px-4 py-3\">\n          <div>\n            <h3 className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">{title}</h3>\n            <p className=\"text-xs text-[var(--glass-text-secondary)]\">{subtitle}</p>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"glass-icon-btn-sm\"\n            title={closeLabel}\n          >\n            <AppIcon name=\"close\" className=\"h-4 w-4\" />\n          </button>\n        </div>\n\n        <div className=\"h-[420px] overflow-hidden bg-[var(--glass-bg-soft)]\">\n          {completed ? (\n            <div className=\"flex h-full items-center justify-center px-4 py-4\">\n              <div className=\"w-full max-w-md rounded-2xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-6 py-7 text-center shadow-sm\">\n                <div className=\"relative mx-auto mb-4 flex h-20 w-20 items-center justify-center\">\n                  <div className=\"absolute h-20 w-20 rounded-full bg-emerald-500/20 animate-ping\" />\n                  <div className=\"relative z-10 flex h-20 w-20 items-center justify-center rounded-full border border-emerald-400/60 bg-emerald-500/15\">\n                    <AppIcon name=\"check\" className=\"h-10 w-10 text-emerald-500\" />\n                  </div>\n                </div>\n                <div className=\"text-base font-semibold text-[var(--glass-text-primary)]\">\n                  {completedTitle || assistantLabel}\n                </div>\n                {completedMessage && (\n                  <div className=\"mt-2 whitespace-pre-wrap text-sm leading-relaxed text-[var(--glass-text-secondary)]\">\n                    {completedMessage}\n                  </div>\n                )}\n                <div className=\"mt-4 text-xs text-[var(--glass-text-tertiary)]\">\n                  {closeLabel}\n                </div>\n              </div>\n            </div>\n          ) : (\n            <Conversation className=\"h-full\">\n              <ConversationContent className=\"h-full space-y-3 p-4\">\n                {shouldShowEmptyAssistantMessage && (\n                  <Message from=\"assistant\">\n                    <MessageContent className=\"max-w-[84%] rounded-2xl rounded-bl-md border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-3 py-2\">\n                      <div className=\"mb-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--glass-text-tertiary)]\">\n                        {assistantLabel}\n                      </div>\n                      <MessageResponse className=\"whitespace-pre-wrap break-words leading-relaxed\">\n                        {emptyAssistantMessage}\n                      </MessageResponse>\n                    </MessageContent>\n                  </Message>\n                )}\n\n                {visibleMessages.map((message) => {\n                  const isAssistant = message.role === 'assistant'\n                  const isStreamingAssistantMessage = pending && isAssistant && message.id === lastAssistantMessageId\n                  return (\n                    <Message key={message.id} from={message.role}>\n                      <MessageContent\n                        className={isAssistant\n                          ? 'max-w-[84%] rounded-2xl rounded-bl-md border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-3 py-2'\n                          : 'max-w-[84%] rounded-2xl rounded-br-md bg-[var(--brand-primary)]/15 px-3 py-2'}\n                      >\n                        <div className=\"mb-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--glass-text-tertiary)]\">\n                          {isAssistant ? assistantLabel : userLabel}\n                        </div>\n\n                        {isAssistant && message.reasoningLines.length > 0 && (\n                          <Reasoning\n                            open={Boolean(expandedReasoningByMessageId[message.id])}\n                            onOpenChange={(nextOpenState) => setReasoningExpanded(message.id, nextOpenState)}\n                            className=\"mb-2 rounded-xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-soft)] p-2\"\n                          >\n                            <ReasoningTrigger className=\"text-xs text-[var(--glass-text-secondary)]\">\n                              <span className=\"mr-2\">{reasoningTitle}</span>\n                              <span className=\"text-[11px] text-[var(--glass-text-tertiary)]\">\n                                {expandedReasoningByMessageId[message.id] ? reasoningCollapseLabel : reasoningExpandLabel}\n                              </span>\n                            </ReasoningTrigger>\n                            <ReasoningContent className=\"space-y-1 border-t border-[var(--glass-stroke-base)] pt-2 text-xs text-[var(--glass-text-secondary)]\">\n                              {message.reasoningLines.join('\\n\\n')}\n                            </ReasoningContent>\n                          </Reasoning>\n                        )}\n\n                        {message.lines.map((line, index) => (\n                          <MessageResponse\n                            key={`${message.id}-line-${index}`}\n                            className={joinClassNames(\n                              'whitespace-pre-wrap break-words leading-relaxed',\n                              isStreamingAssistantMessage ? 'assistant-streaming-response' : undefined,\n                            )}\n                          >\n                            {line}\n                          </MessageResponse>\n                        ))}\n\n                        {message.tools.map((tool, index) => (\n                          <Tool\n                            key={`${message.id}-tool-${index}`}\n                            defaultOpen={tool.state !== 'output-available'}\n                            className=\"mt-2 border-[var(--glass-stroke-base)] bg-[var(--glass-bg-soft)]\"\n                          >\n                            {tool.partType === 'dynamic-tool'\n                              ? <ToolHeader type={tool.partType} state={tool.state} toolName={tool.toolName} />\n                              : <ToolHeader type={tool.partType} state={tool.state} />}\n                            <ToolContent>\n                              {tool.input !== undefined && <ToolInput input={tool.input as ToolPart['input']} />}\n                              <ToolOutput\n                                output={tool.output as ToolPart['output']}\n                                errorText={tool.errorText as ToolPart['errorText']}\n                              />\n                            </ToolContent>\n                          </Tool>\n                        ))}\n                      </MessageContent>\n                    </Message>\n                  )\n                })}\n\n                {pending && !completed && (\n                  <Message from=\"assistant\">\n                    <MessageContent className=\"max-w-[84%] rounded-2xl rounded-bl-md border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-3 py-2\">\n                      <div className=\"mb-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--glass-text-tertiary)]\">\n                        {assistantLabel}\n                      </div>\n                      <MessageResponse>{pendingLabel}</MessageResponse>\n                    </MessageContent>\n                  </Message>\n                )}\n\n                {errorMessage && (\n                  <div className=\"rounded-xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-3 py-2 text-xs text-[var(--glass-text-secondary)]\">\n                    {errorMessage}\n                  </div>\n                )}\n              </ConversationContent>\n              <ConversationScrollButton />\n            </Conversation>\n          )}\n        </div>\n\n        <div className=\"border-t border-[var(--glass-stroke-base)] px-4 py-3\">\n          <div className=\"flex items-center gap-2\">\n            {completed ? (\n              <button\n                onClick={onClose}\n                className=\"glass-btn-base glass-btn-primary ml-auto px-3 py-2 text-sm font-medium\"\n              >\n                {closeLabel}\n              </button>\n            ) : (\n              <>\n                <input\n                  type=\"text\"\n                  value={input}\n                  onChange={(event) => onInputChange(event.target.value)}\n                  onKeyDown={(event) => onEnterSubmit(event, onSend)}\n                  placeholder={inputPlaceholder}\n                  className=\"glass-input-base flex-1 px-3 py-2 text-sm\"\n                  disabled={pending}\n                />\n                <button\n                  onClick={onSend}\n                  disabled={pending}\n                  className=\"glass-btn-base glass-btn-primary px-3 py-2 text-sm font-medium disabled:opacity-60\"\n                >\n                  {pending ? pendingLabel : sendLabel}\n                </button>\n              </>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/assistant/useAssistantChat.ts",
    "content": "'use client'\n\nimport { useChat } from '@ai-sdk/react'\nimport { DefaultChatTransport, type ChatStatus, type UIMessage } from 'ai'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport type { OpenAICompatMediaTemplate } from '@/lib/openai-compat-media-template'\n\nexport type AssistantChatId = 'api-config-template' | 'tutorial'\n\nexport interface AssistantDraftModel {\n  modelId: string\n  name: string\n  type: 'image' | 'video'\n  provider: string\n  compatMediaTemplate: OpenAICompatMediaTemplate\n}\n\nexport interface AssistantSavedEvent {\n  savedModelKey: string\n  draftModel?: AssistantDraftModel\n}\n\nexport interface UseAssistantChatParams {\n  assistantId: AssistantChatId\n  context: {\n    providerId?: string\n    locale?: string\n  }\n  enabled: boolean\n  onSaved?: (event: AssistantSavedEvent) => void\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction readNonEmptyStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value\n    .map((item) => readTrimmedString(item))\n    .filter((item) => item.length > 0)\n}\n\nfunction parseDraftModel(value: unknown): AssistantDraftModel | undefined {\n  if (!isRecord(value)) return undefined\n  const modelId = readTrimmedString(value.modelId)\n  const name = readTrimmedString(value.name)\n  const provider = readTrimmedString(value.provider)\n  const type = value.type\n  const template = value.compatMediaTemplate\n  if ((type !== 'image' && type !== 'video') || !modelId || !name || !provider) return undefined\n  if (!isRecord(template)) return undefined\n  return {\n    modelId,\n    name,\n    type,\n    provider,\n    compatMediaTemplate: template as unknown as OpenAICompatMediaTemplate,\n  }\n}\n\nfunction readSavedEventsFromPart(part: unknown): AssistantSavedEvent[] {\n  if (!isRecord(part)) return []\n  const partType = readTrimmedString(part.type)\n  const state = readTrimmedString(part.state)\n  const isSavePart = partType === 'tool-saveModelTemplate' || partType === 'tool-saveModelTemplates'\n  if (!isSavePart || state !== 'output-available') return []\n\n  const output = part.output\n  if (!isRecord(output)) return []\n  if (readTrimmedString(output.status) !== 'saved') return []\n\n  const events: AssistantSavedEvent[] = []\n  const savedModelKey = readTrimmedString(output.savedModelKey)\n  const draftModel = parseDraftModel(output.draftModel)\n  if (savedModelKey) {\n    events.push({\n      savedModelKey,\n      ...(draftModel ? { draftModel } : {}),\n    })\n  }\n\n  const savedModelKeys = readNonEmptyStringArray(output.savedModelKeys)\n  const draftModelsRaw = Array.isArray(output.draftModels) ? output.draftModels : []\n  const draftModels = draftModelsRaw\n    .map((item) => parseDraftModel(item))\n    .filter((item): item is AssistantDraftModel => Boolean(item))\n\n  for (let index = 0; index < savedModelKeys.length; index += 1) {\n    const itemSavedModelKey = savedModelKeys[index]\n    if (!itemSavedModelKey) continue\n    const matchedDraft = draftModels[index]\n      || draftModels.find((candidate) => itemSavedModelKey.endsWith(`::${candidate.modelId}`))\n    events.push({\n      savedModelKey: itemSavedModelKey,\n      ...(matchedDraft ? { draftModel: matchedDraft } : {}),\n    })\n  }\n\n  return events\n}\n\nexport function collectSavedEvents(messages: UIMessage[]): AssistantSavedEvent[] {\n  const events: AssistantSavedEvent[] = []\n  for (const message of messages) {\n    for (const part of message.parts) {\n      events.push(...readSavedEventsFromPart(part))\n    }\n  }\n  return events\n}\n\nexport interface UseAssistantChatResult {\n  messages: UIMessage[]\n  input: string\n  status: ChatStatus\n  pending: boolean\n  error: Error | undefined\n  setInput: (value: string) => void\n  send: (content?: string) => Promise<void>\n  clear: () => void\n}\n\nexport function useAssistantChat(params: UseAssistantChatParams): UseAssistantChatResult {\n  const [input, setInput] = useState('')\n  const [renderedMessages, setRenderedMessages] = useState<UIMessage[]>([])\n  const handledSavedKeysRef = useRef(new Set<string>())\n  const frameIdRef = useRef<number | null>(null)\n  const latestMessagesRef = useRef<UIMessage[]>([])\n  const onSaved = params.onSaved\n  const contextPayload = useMemo(() => ({\n    providerId: params.context.providerId,\n    locale: params.context.locale,\n  }), [params.context.locale, params.context.providerId])\n\n  const transport = useMemo(() => new DefaultChatTransport({\n    api: '/api/user/assistant/chat',\n    body: {\n      assistantId: params.assistantId,\n      context: contextPayload,\n    },\n  }), [contextPayload, params.assistantId])\n\n  const chat = useChat({\n    transport,\n  })\n\n  const pending = chat.status === 'submitted' || chat.status === 'streaming'\n\n  useEffect(() => {\n    latestMessagesRef.current = chat.messages\n    if (!pending) {\n      if (frameIdRef.current !== null) {\n        cancelAnimationFrame(frameIdRef.current)\n        frameIdRef.current = null\n      }\n      setRenderedMessages(chat.messages)\n      return\n    }\n    if (frameIdRef.current !== null) return\n    frameIdRef.current = requestAnimationFrame(() => {\n      frameIdRef.current = null\n      setRenderedMessages(latestMessagesRef.current)\n    })\n  }, [chat.messages, pending])\n\n  useEffect(() => {\n    return () => {\n      if (frameIdRef.current !== null) {\n        cancelAnimationFrame(frameIdRef.current)\n        frameIdRef.current = null\n      }\n    }\n  }, [])\n\n  useEffect(() => {\n    if (!onSaved) return\n    const events = collectSavedEvents(chat.messages)\n    for (const event of events) {\n      const dedupeKey = event.savedModelKey\n      if (handledSavedKeysRef.current.has(dedupeKey)) continue\n      handledSavedKeysRef.current.add(dedupeKey)\n      onSaved(event)\n    }\n  }, [chat.messages, onSaved])\n\n  const send = useCallback(async (content?: string): Promise<void> => {\n    if (!params.enabled || pending) return\n    const text = (content ?? input).trim()\n    if (!text) return\n    setInput('')\n    await chat.sendMessage({ text })\n  }, [chat, input, params.enabled, pending])\n\n  const clear = useCallback(() => {\n    chat.setMessages([])\n    chat.clearError()\n    setInput('')\n    setRenderedMessages([])\n    latestMessagesRef.current = []\n    if (frameIdRef.current !== null) {\n      cancelAnimationFrame(frameIdRef.current)\n      frameIdRef.current = null\n    }\n    handledSavedKeysRef.current.clear()\n  }, [chat])\n\n  return {\n    messages: renderedMessages,\n    input,\n    status: chat.status,\n    pending,\n    error: chat.error,\n    setInput,\n    send,\n    clear,\n  }\n}\n"
  },
  {
    "path": "src/components/auth/PasswordStrengthIndicator.tsx",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { useTranslations } from 'next-intl'\n\ninterface PasswordStrengthIndicatorProps {\n    password: string\n}\n\ntype StrengthLevel = 'weak' | 'fair' | 'good' | 'strong'\n\ninterface StrengthResult {\n    /** 0-4 分 */\n    score: number\n    level: StrengthLevel\n}\n\n/**\n * 评估密码强度。\n *\n * 判定维度：\n * - 字符类型多样性（小写、大写、数字、特殊字符）\n * - 长度\n * - 不是纯重复 / 纯顺序\n *\n * 规则：\n * - 只有 1 种字符类型（如纯数字、纯小写）→ weak\n * - 2 种字符类型 + 长度 ≥ 8 → fair\n * - 3 种字符类型 + 长度 ≥ 8 → good\n * - 4 种字符类型 + 长度 ≥ 10 → strong\n */\nfunction evaluateStrength(password: string): StrengthResult {\n    if (!password) return { score: 0, level: 'weak' }\n\n    // 统计字符类型\n    const hasLower = /[a-z]/.test(password)\n    const hasUpper = /[A-Z]/.test(password)\n    const hasDigit = /\\d/.test(password)\n    const hasSpecial = /[^a-zA-Z0-9]/.test(password)\n    const charTypes = [hasLower, hasUpper, hasDigit, hasSpecial].filter(Boolean).length\n\n    // 检查是否全是同一字符重复（如 aaaaaa、111111）\n    const isAllSame = new Set(password).size === 1\n\n    // 纯单类型 或 全重复 → 弱\n    if (charTypes <= 1 || isAllSame) {\n        return { score: 1, level: 'weak' }\n    }\n\n    // 长度不够 → 弱\n    if (password.length < 8) {\n        return { score: 1, level: 'weak' }\n    }\n\n    // 2 种字符类型 + 长度 ≥ 8 → 一般\n    if (charTypes === 2) {\n        return { score: 2, level: 'fair' }\n    }\n\n    // 3 种字符类型 + 长度 ≥ 8 → 良好\n    if (charTypes === 3) {\n        return { score: 3, level: 'good' }\n    }\n\n    // 4 种字符类型 + 长度 ≥ 10 → 强\n    if (charTypes >= 4 && password.length >= 10) {\n        return { score: 4, level: 'strong' }\n    }\n\n    // 4 种类型但长度不够 10 → 良好\n    return { score: 3, level: 'good' }\n}\n\nconst LEVEL_STYLES: Record<StrengthLevel, { color: string; bgActive: string }> = {\n    weak: {\n        color: 'var(--glass-tone-danger-fg)',\n        bgActive: 'var(--glass-tone-danger-fg)',\n    },\n    fair: {\n        color: 'var(--glass-tone-warning-fg)',\n        bgActive: 'var(--glass-tone-warning-fg)',\n    },\n    good: {\n        color: 'var(--glass-tone-info-fg)',\n        bgActive: 'var(--glass-tone-info-fg)',\n    },\n    strong: {\n        color: 'var(--glass-tone-success-fg)',\n        bgActive: 'var(--glass-tone-success-fg)',\n    },\n}\n\nexport default function PasswordStrengthIndicator({ password }: PasswordStrengthIndicatorProps) {\n    const t = useTranslations('auth')\n\n    const { score, level } = useMemo(() => evaluateStrength(password), [password])\n    const styles = LEVEL_STYLES[level]\n\n    if (!password) return null\n\n    return (\n        <div className=\"mt-2 space-y-1.5\">\n            {/* 强度条 */}\n            <div className=\"flex gap-1\">\n                {[1, 2, 3, 4].map((segment) => (\n                    <div\n                        key={segment}\n                        className=\"h-1 flex-1 rounded-full transition-all duration-300\"\n                        style={{\n                            backgroundColor: segment <= score\n                                ? styles.bgActive\n                                : 'color-mix(in srgb, var(--glass-text-tertiary) 30%, transparent)',\n                        }}\n                    />\n                ))}\n            </div>\n\n            {/* 文字提示 */}\n            <p\n                className=\"text-xs transition-colors duration-300\"\n                style={{ color: styles.color }}\n            >\n                {t(`passwordStrength.${level}`)}\n            </p>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/components/image-generation/ImageGenerationInlineCountButton.tsx",
    "content": "'use client'\n\nimport type { KeyboardEvent, MouseEvent, ReactNode } from 'react'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ImageGenerationInlineCountButtonProps {\n  prefix: ReactNode\n  suffix: ReactNode\n  value: number\n  options: number[]\n  onValueChange: (value: number) => void\n  onClick: () => void\n  disabled?: boolean\n  actionDisabled?: boolean\n  selectDisabled?: boolean\n  className?: string\n  selectClassName?: string\n  labelClassName?: string\n  ariaLabel: string\n}\n\nexport default function ImageGenerationInlineCountButton({\n  prefix,\n  suffix,\n  value,\n  options,\n  onValueChange,\n  onClick,\n  disabled = false,\n  actionDisabled,\n  selectDisabled,\n  className = '',\n  selectClassName = '',\n  labelClassName = '',\n  ariaLabel,\n}: ImageGenerationInlineCountButtonProps) {\n  const isActionDisabled = disabled || actionDisabled === true\n  const isSelectDisabled = disabled || selectDisabled === true\n  const rootStateClassName = isActionDisabled\n    ? 'opacity-60 cursor-not-allowed'\n    : 'cursor-pointer'\n  const selectStateClassName = isSelectDisabled\n    ? 'pointer-events-none opacity-70'\n    : 'cursor-pointer'\n\n  return (\n    <div\n      role=\"button\"\n      tabIndex={isActionDisabled ? -1 : 0}\n      onClick={() => {\n        if (isActionDisabled) return\n        onClick()\n      }}\n      onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {\n        if (isActionDisabled) return\n        if (event.key === 'Enter' || event.key === ' ') {\n          event.preventDefault()\n          onClick()\n        }\n      }}\n      aria-disabled={isActionDisabled}\n      className={`${className} ${rootStateClassName}`.trim()}\n    >\n      <span className={labelClassName}>{prefix}</span>\n      <span\n        className={`group relative inline-flex items-center rounded-md px-1.5 py-0.5 transition-colors ${\n          isSelectDisabled ? '' : 'hover:bg-white/12 focus-within:bg-white/14'\n        }`}\n        onClick={(event: MouseEvent<HTMLSpanElement>) => event.stopPropagation()}\n      >\n        <select\n          value={String(value)}\n          onChange={(event) => onValueChange(Number(event.target.value))}\n          aria-label={ariaLabel}\n          disabled={isSelectDisabled}\n          className={`${selectClassName} ${selectStateClassName}`.trim()}\n        >\n          {options.map((option) => (\n            <option key={option} value={option} className=\"text-black\">\n              {option}\n            </option>\n          ))}\n        </select>\n        <span className=\"pointer-events-none absolute inset-y-0 right-1 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100\">\n          <AppIcon name=\"chevronDown\" className=\"h-3 w-3\" />\n        </span>\n      </span>\n      <span className={labelClassName}>{suffix}</span>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/image-generation/ImageGenerationSlotOverlay.tsx",
    "content": "'use client'\n\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ImageGenerationSlotOverlayProps {\n  label: string\n}\n\nexport default function ImageGenerationSlotOverlay({ label }: ImageGenerationSlotOverlayProps) {\n  return (\n    <div className=\"absolute inset-0 flex flex-col items-center justify-center bg-[var(--glass-overlay)]\">\n      <AppIcon name=\"loader\" className=\"h-7 w-7 animate-spin text-white\" />\n      <span className=\"mt-2 text-xs text-white\">{label}</span>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/llm-console/LLMStageStreamCard.tsx",
    "content": "'use client'\n\nimport { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\n\nexport type LLMStageViewStatus =\n  | 'pending'\n  | 'queued'\n  | 'processing'\n  | 'completed'\n  | 'failed'\n  | 'blocked'\n  | 'stale'\n\nexport type LLMStageViewItem = {\n  id: string\n  title: string\n  subtitle?: string\n  status: LLMStageViewStatus\n  progress?: number\n  attempt?: number\n  retryable?: boolean\n}\n\nexport type LLMStageStreamCardProps = {\n  title: string\n  subtitle?: string\n  stages: LLMStageViewItem[]\n  activeStageId: string\n  selectedStageId?: string\n  onSelectStage?: (stageId: string) => void\n  onRetryStage?: (stageId: string) => void\n  outputText: string\n  placeholderText?: string\n  activeMessage?: string\n  overallProgress?: number\n  showCursor?: boolean\n  autoScroll?: boolean\n  smoothStreaming?: boolean\n  errorMessage?: string\n  topRightAction?: ReactNode\n}\n\nconst PROGRESS_KEY_PREFIX = 'progress.'\nconst REASONING_HEADER = '【思考过程】'\nconst FINAL_HEADER = '【最终结果】'\n\nfunction statusClass(status: LLMStageViewStatus): string {\n  if (status === 'completed') return 'glass-chip glass-chip-success'\n  if (status === 'stale') return 'glass-chip glass-chip-warning'\n  if (status === 'blocked') return 'glass-chip glass-chip-warning'\n  if (status === 'failed') return 'glass-chip glass-chip-danger'\n  if (status === 'processing') return 'glass-chip glass-chip-info'\n  if (status === 'queued') return 'glass-chip glass-chip-warning'\n  return 'glass-chip glass-chip-neutral'\n}\n\nfunction clampProgress(value: number): number {\n  if (!Number.isFinite(value)) return 0\n  return Math.max(0, Math.min(100, value))\n}\n\nfunction splitThinkTaggedContent(input: string): { text: string; reasoning: string } {\n  const thinkTagPattern = /<(think|thinking)\\b[^>]*>([\\s\\S]*?)<\\/\\1>/gi\n  const reasoningParts: string[] = []\n  let hadTag = false\n\n  let stripped = input.replace(thinkTagPattern, (_fullMatch, _tagName: string, inner: string) => {\n    hadTag = true\n    const trimmed = inner.trim()\n    if (trimmed) reasoningParts.push(trimmed)\n    return ''\n  })\n\n  const openTagMatch = stripped.match(/<(think|thinking)\\b[^>]*>/i)\n  if (openTagMatch && typeof openTagMatch.index === 'number') {\n    hadTag = true\n    const start = openTagMatch.index\n    const openTag = openTagMatch[0]\n    const tail = stripped\n      .slice(start + openTag.length)\n      .replace(/<\\/(think|thinking)\\s*>/gi, '')\n      .trim()\n    if (tail) reasoningParts.push(tail)\n    stripped = stripped.slice(0, start)\n  }\n\n  if (!hadTag) {\n    return {\n      text: input,\n      reasoning: '',\n    }\n  }\n\n  return {\n    text: stripped.trim(),\n    reasoning: reasoningParts.join('\\n\\n').trim(),\n  }\n}\n\nfunction mergeReasoning(base: string, incoming: string): string {\n  const next = incoming.trim()\n  if (!next) return base\n  const prev = base.trim()\n  if (!prev) return next\n  if (next.startsWith(prev)) return next\n  if (prev.includes(next)) return base\n  return `${prev}\\n\\n${next}`\n}\n\nexport function splitStructuredOutput(raw: string): {\n  hasStructured: boolean\n  showReasoning: boolean\n  showFinal: boolean\n  reasoning: string\n  finalText: string\n} {\n  const normalized = typeof raw === 'string' ? raw : ''\n  if (!normalized.startsWith(REASONING_HEADER) && !normalized.startsWith(FINAL_HEADER)) {\n    return {\n      hasStructured: false,\n      showReasoning: false,\n      showFinal: false,\n      reasoning: '',\n      finalText: '',\n    }\n  }\n\n  const finalIndex = normalized.indexOf(FINAL_HEADER)\n  if (normalized.startsWith(REASONING_HEADER) && finalIndex >= 0) {\n    const reasoningRaw = normalized\n      .slice(REASONING_HEADER.length, finalIndex)\n      .trim()\n    const finalRaw = normalized\n      .slice(finalIndex + FINAL_HEADER.length)\n      .trim()\n    const parsedFinal = splitThinkTaggedContent(finalRaw)\n    return {\n      hasStructured: true,\n      showReasoning: true,\n      showFinal: true,\n      reasoning: mergeReasoning(reasoningRaw, parsedFinal.reasoning),\n      finalText: parsedFinal.text,\n    }\n  }\n\n  if (normalized.startsWith(REASONING_HEADER)) {\n    return {\n      hasStructured: true,\n      showReasoning: true,\n      showFinal: true,\n      reasoning: normalized.slice(REASONING_HEADER.length).trim(),\n      finalText: '',\n    }\n  }\n\n  const finalRaw = normalized.slice(FINAL_HEADER.length).trim()\n  const parsedFinal = splitThinkTaggedContent(finalRaw)\n  return {\n    hasStructured: true,\n    showReasoning: true,\n    showFinal: true,\n    reasoning: parsedFinal.reasoning,\n    finalText: parsedFinal.text,\n  }\n}\n\nexport default function LLMStageStreamCard({\n  title,\n  subtitle,\n  stages,\n  activeStageId,\n  selectedStageId,\n  onSelectStage,\n  onRetryStage,\n  outputText,\n  placeholderText,\n  activeMessage,\n  overallProgress,\n  showCursor = false,\n  autoScroll = true,\n  smoothStreaming = true,\n  errorMessage,\n  topRightAction,\n}: LLMStageStreamCardProps) {\n  const t = useTranslations('progress')\n\n  const resolveProgressText = useCallback((value: string | undefined, fallbackKey: string): string => {\n    const raw = typeof value === 'string' ? value.trim() : ''\n    if (!raw) return t(fallbackKey as never)\n    if (!raw.startsWith(PROGRESS_KEY_PREFIX)) return raw\n    const key = raw.slice(PROGRESS_KEY_PREFIX.length)\n    try {\n      return t(key as never)\n    } catch {\n      return raw\n    }\n  }, [t])\n\n  const statusLabel = useCallback((status: LLMStageViewStatus): string => {\n    if (status === 'completed') return t('status.completed')\n    if (status === 'stale') return 'Stale'\n    if (status === 'blocked') return 'Blocked'\n    if (status === 'failed') return t('status.failed')\n    if (status === 'processing') return t('status.processing')\n    if (status === 'queued') return t('status.queued')\n    return t('status.pending')\n  }, [t])\n\n  const resolvedPlaceholderText = resolveProgressText(placeholderText, 'stageCard.waitingModelOutput')\n\n  const outputStageId = selectedStageId || activeStageId\n  const outputRef = useRef<HTMLDivElement | null>(null)\n  const renderFrameRef = useRef<number | null>(null)\n  const renderTargetRef = useRef(outputText)\n  const renderCurrentRef = useRef(outputText)\n  const latestOutputRef = useRef(outputText)\n  const [renderedOutputText, setRenderedOutputText] = useState(outputText)\n  const activeIndex = Math.max(\n    0,\n    stages.findIndex((stage) => stage.id === activeStageId),\n  )\n  const activeStage = stages[activeIndex] || stages[0]\n  const outputStage = stages.find((stage) => stage.id === outputStageId) || activeStage\n  const stageCount = stages.length\n  const completedCount = stages.filter((stage) => stage.status === 'completed' || stage.status === 'stale').length\n  const hasPendingWork = stages.some((stage) =>\n    stage.status === 'processing' ||\n    stage.status === 'queued' ||\n    stage.status === 'pending' ||\n    stage.status === 'blocked',\n  )\n  const currentStep = stageCount === 0\n    ? 0\n    : hasPendingWork\n      ? Math.min(stageCount, Math.max(1, completedCount))\n      : stageCount\n  const normalizedOverallProgress =\n    typeof overallProgress === 'number'\n      ? clampProgress(overallProgress)\n      : clampProgress(\n        stageCount === 0\n          ? 0\n          : ((stages.filter((item) => item.status === 'completed').length +\n            (activeStage?.status === 'processing' ? (activeStage.progress || 0) / 100 : 0)) /\n            stageCount) *\n          100,\n      )\n  const structuredOutput = splitStructuredOutput(renderedOutputText)\n\n  const stopRenderLoop = useCallback(() => {\n    if (renderFrameRef.current == null) return\n    cancelAnimationFrame(renderFrameRef.current)\n    renderFrameRef.current = null\n  }, [])\n\n  const renderNextFrame = useCallback(() => {\n    const current = renderCurrentRef.current\n    const target = renderTargetRef.current\n    if (current === target) {\n      renderFrameRef.current = null\n      return\n    }\n\n    if (!target.startsWith(current)) {\n      renderCurrentRef.current = target\n      setRenderedOutputText(target)\n      renderFrameRef.current = null\n      return\n    }\n\n    const remaining = target.length - current.length\n    const frameStep =\n      remaining > 1200\n        ? 18\n        : remaining > 700\n          ? 12\n          : remaining > 300\n            ? 8\n            : remaining > 120\n              ? 5\n              : 2\n    const next = target.slice(0, current.length + frameStep)\n    renderCurrentRef.current = next\n    setRenderedOutputText(next)\n    renderFrameRef.current = requestAnimationFrame(renderNextFrame)\n  }, [])\n\n  useEffect(() => {\n    latestOutputRef.current = outputText\n    renderTargetRef.current = outputText\n    const shouldSmooth = smoothStreaming && showCursor && outputStageId === activeStageId\n    if (!shouldSmooth) {\n      stopRenderLoop()\n      if (renderCurrentRef.current !== outputText) {\n        renderCurrentRef.current = outputText\n        setRenderedOutputText(outputText)\n      }\n      return\n    }\n\n    if (\n      outputText.length < renderCurrentRef.current.length ||\n      !outputText.startsWith(renderCurrentRef.current)\n    ) {\n      stopRenderLoop()\n      renderCurrentRef.current = outputText\n      setRenderedOutputText(outputText)\n      return\n    }\n\n    if (outputText.length === renderCurrentRef.current.length) return\n    if (renderFrameRef.current != null) return\n    renderFrameRef.current = requestAnimationFrame(renderNextFrame)\n  }, [\n    outputText,\n    showCursor,\n    outputStageId,\n    activeStageId,\n    smoothStreaming,\n    renderNextFrame,\n    stopRenderLoop,\n  ])\n\n  useEffect(() => {\n    stopRenderLoop()\n    const output = latestOutputRef.current\n    renderTargetRef.current = output\n    renderCurrentRef.current = output\n    setRenderedOutputText(output)\n  }, [outputStageId, stopRenderLoop])\n\n  useEffect(() => {\n    if (!activeStage || !autoScroll || !outputRef.current) return\n    const node = outputRef.current\n    node.scrollTop = node.scrollHeight\n  }, [activeStage, renderedOutputText, showCursor, autoScroll])\n\n  useEffect(() => {\n    return () => {\n      stopRenderLoop()\n    }\n  }, [stopRenderLoop])\n\n  if (!activeStage) return null\n\n  return (\n    <article className=\"glass-surface-modal flex h-full w-full flex-col overflow-hidden rounded-2xl text-[var(--glass-text-primary)]\">\n      <header className=\"border-b border-[var(--glass-stroke-base)] px-5 py-5 md:px-6\">\n        <div className=\"grid grid-cols-1 gap-3 md:grid-cols-[15rem_minmax(0,1fr)_auto] md:items-center\">\n          <div className=\"glass-surface-soft rounded-xl border border-[var(--glass-stroke-base)] p-3\">\n            <p className=\"text-[11px] uppercase tracking-[0.12em] text-[var(--glass-text-tertiary)]\">\n              {t('stageCard.stage')}\n            </p>\n            <p className=\"mt-1 text-2xl font-semibold text-[var(--glass-text-primary)]\">\n              {currentStep}/{stageCount}\n            </p>\n            <p className=\"mt-1 truncate text-sm text-[var(--glass-text-secondary)]\">\n              {resolveProgressText(activeStage.title, 'stageCard.currentStage')}\n            </p>\n          </div>\n\n          <div className=\"min-w-0 text-center\">\n            <p className=\"text-[11px] uppercase tracking-[0.12em] text-[var(--glass-text-tertiary)]\">\n              {resolveProgressText(subtitle, 'stageCard.realtimeStream')}\n            </p>\n            <h2 className=\"mt-1 text-xl font-semibold text-[var(--glass-text-primary)] md:text-2xl\">\n              {resolveProgressText(title, 'stageCard.currentStage')}\n            </h2>\n            <p className=\"mt-2 truncate text-sm text-[var(--glass-text-secondary)]\">\n              {resolveProgressText(activeMessage || activeStage.subtitle, 'runtime.llm.processing')}\n            </p>\n          </div>\n\n          <div className=\"flex shrink-0 items-center justify-start whitespace-nowrap md:justify-end\">{topRightAction || null}</div>\n        </div>\n\n        <div className=\"mt-4 h-2 overflow-hidden rounded-full bg-[var(--glass-bg-muted)]\">\n          <div\n            className=\"h-full rounded-full bg-[linear-gradient(120deg,var(--glass-accent-from),var(--glass-accent-to))] transition-[width] duration-200\"\n            style={{ width: `${Math.max(normalizedOverallProgress, 2)}%` }}\n          />\n        </div>\n\n        {errorMessage && (\n          <div className=\"mt-3 flex flex-col gap-2 rounded-lg bg-[var(--glass-tone-danger-bg)] px-4 py-2.5 text-[var(--glass-tone-danger-fg)]\">\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-base\">⚠️</span>\n              <span className=\"text-sm font-medium\">{errorMessage}</span>\n            </div>\n          </div>\n        )}\n      </header>\n\n      <div className=\"grid min-h-0 flex-1 grid-cols-1 gap-4 p-5 md:grid-cols-[17rem_1fr] md:gap-5 md:p-6\">\n        <aside className=\"glass-surface-soft min-h-0 rounded-xl border border-[var(--glass-stroke-base)] p-3\">\n          <ul className=\"max-h-[40vh] space-y-2 overflow-y-auto pr-1 md:h-full md:max-h-none\">\n            {stages.map((stage, index) => {\n              const isActive = stage.id === outputStageId\n              const progress = clampProgress(stage.progress || 0)\n              const attempt =\n                typeof stage.attempt === 'number' && Number.isFinite(stage.attempt)\n                  ? Math.max(1, Math.floor(stage.attempt))\n                  : 1\n              const showRetryButton =\n                stage.status === 'failed'\n                && stage.retryable !== false\n                && typeof onRetryStage === 'function'\n              return (\n                <li key={stage.id}>\n                  <div\n                    className={`rounded-lg border p-2.5 ${isActive\n                      ? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'\n                      : 'border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)]'\n                      }`}\n                  >\n                    <button\n                      type=\"button\"\n                      onClick={() => onSelectStage?.(stage.id)}\n                      className={`w-full text-left ${onSelectStage ? 'cursor-pointer' : 'cursor-default'}`}\n                    >\n                      <div className=\"flex items-center justify-between gap-2\">\n                        <div className=\"flex min-w-0 items-center gap-2\">\n                          <span className=\"glass-chip glass-chip-neutral min-w-6 justify-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold leading-none\">\n                            {index + 1}\n                          </span>\n                          {attempt > 1 && (\n                            <span className=\"glass-chip glass-chip-warning min-w-6 justify-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold leading-none\">\n                              ×{attempt}\n                            </span>\n                          )}\n                          <p className=\"truncate text-sm font-medium text-[var(--glass-text-primary)]\">\n                            {resolveProgressText(stage.title, 'stageCard.currentStage')}\n                          </p>\n                        </div>\n                        <span className={statusClass(stage.status)}>{statusLabel(stage.status)}</span>\n                      </div>\n                      <div className=\"mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--glass-bg-muted)]\">\n                        <div\n                          className=\"h-full rounded-full bg-[var(--glass-accent-from)] transition-[width] duration-200\"\n                          style={{ width: `${Math.max(progress, stage.status === 'completed' ? 100 : 2)}%` }}\n                        />\n                      </div>\n                    </button>\n                    {showRetryButton && (\n                      <div className=\"mt-2 flex justify-end\">\n                        <button\n                          type=\"button\"\n                          onClick={() => {\n                            void onRetryStage(stage.id)\n                          }}\n                          className=\"glass-btn-base glass-btn-primary rounded-md px-2.5 py-1 text-[11px]\"\n                        >\n                          重试\n                        </button>\n                      </div>\n                    )}\n                  </div>\n                </li>\n              )\n            })}\n          </ul>\n        </aside>\n\n        <section className=\"glass-surface-soft min-h-[320px] rounded-xl border border-[var(--glass-stroke-base)]\">\n          <div className=\"border-b border-[var(--glass-stroke-base)] px-4 py-3 text-sm font-medium text-[var(--glass-text-primary)]\">\n            {t('stageCard.outputTitle', {\n              stage: resolveProgressText(outputStage?.title, 'stageCard.currentStage'),\n            })}\n          </div>\n          <div\n            ref={outputRef}\n            className=\"h-[52vh] overflow-y-auto px-4 py-4\"\n          >\n            {structuredOutput.hasStructured ? (\n              <div className=\"space-y-4\">\n                {structuredOutput.showReasoning ? (\n                  <div className=\"rounded-lg border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)]\">\n                    <div className=\"border-b border-[var(--glass-stroke-base)] px-3 py-2 text-xs font-semibold text-[var(--glass-text-primary)]\">\n                      {REASONING_HEADER}\n                    </div>\n                    <pre className=\"min-h-[110px] whitespace-pre-wrap break-words px-3 py-3 font-mono text-[14px] leading-7 text-[var(--glass-text-secondary)]\">\n                      {structuredOutput.reasoning || (structuredOutput.finalText ? t('stageCard.reasoningNotProvided') : t('stageCard.waitingModelOutput'))}\n                      {showCursor && !structuredOutput.finalText ? <span className=\"animate-pulse text-[var(--glass-accent-from)]\">▋</span> : null}\n                    </pre>\n                  </div>\n                ) : null}\n                {structuredOutput.showFinal ? (\n                  <div className=\"rounded-lg border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)]\">\n                    <div className=\"border-b border-[var(--glass-stroke-base)] px-3 py-2 text-xs font-semibold text-[var(--glass-text-primary)]\">\n                      {FINAL_HEADER}\n                    </div>\n                    <pre className=\"min-h-[110px] whitespace-pre-wrap break-words px-3 py-3 font-mono text-[14px] leading-7 text-[var(--glass-text-secondary)]\">\n                      {structuredOutput.finalText || t('stageCard.waitingModelOutput')}\n                      {showCursor && !!structuredOutput.finalText ? <span className=\"animate-pulse text-[var(--glass-accent-from)]\">▋</span> : null}\n                    </pre>\n                  </div>\n                ) : null}\n              </div>\n            ) : (\n              <pre className=\"whitespace-pre-wrap break-words font-mono text-[14px] leading-7 text-[var(--glass-text-secondary)]\">\n                {renderedOutputText || resolvedPlaceholderText}\n                {showCursor ? <span className=\"animate-pulse text-[var(--glass-accent-from)]\">▋</span> : null}\n              </pre>\n            )}\n          </div>\n        </section>\n      </div>\n    </article>\n  )\n}\n"
  },
  {
    "path": "src/components/llm-console/index.ts",
    "content": "export { default as LLMStageStreamCard } from './LLMStageStreamCard'\nexport type { LLMStageViewItem, LLMStageViewStatus, LLMStageStreamCardProps } from './LLMStageStreamCard'\n"
  },
  {
    "path": "src/components/media/MediaImage.tsx",
    "content": "'use client'\n\nimport Image from 'next/image'\nimport type { CSSProperties, ImgHTMLAttributes, MouseEventHandler } from 'react'\n\nexport type MediaImageProps = {\n  src: string | null | undefined\n  alt: string\n  className?: string\n  style?: CSSProperties\n  onClick?: MouseEventHandler<HTMLImageElement>\n  fill?: boolean\n  width?: number\n  height?: number\n  sizes?: string\n  priority?: boolean\n} & Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'width' | 'height'>\n\nfunction isStableMediaRoute(src: string) {\n  return src.startsWith('/m/')\n}\n\nexport function MediaImage({\n  src,\n  alt,\n  className,\n  style,\n  onClick,\n  fill = false,\n  width = 1200,\n  height = 1200,\n  sizes,\n  priority = false,\n  ...imgProps\n}: MediaImageProps) {\n  if (!src) return null\n\n  if (isStableMediaRoute(src)) {\n    if (fill) {\n      return (\n        <Image\n          src={src}\n          alt={alt}\n          fill\n          sizes={sizes || '100vw'}\n          priority={priority}\n          className={className}\n          style={style}\n          onClick={onClick}\n          {...imgProps}\n        />\n      )\n    }\n\n    return (\n      <Image\n        src={src}\n        alt={alt}\n        width={width}\n        height={height}\n        sizes={sizes}\n        priority={priority}\n        className={className}\n        style={style}\n        onClick={onClick}\n        {...imgProps}\n      />\n    )\n  }\n\n  return (\n    // 外部 URL 兜底，避免 next/image 远程域名限制影响兼容链路\n    // eslint-disable-next-line @next/next/no-img-element\n    <img\n      src={src}\n      alt={alt}\n      className={className}\n      style={style}\n      onClick={onClick}\n      loading={priority ? 'eager' : 'lazy'}\n      {...imgProps}\n    />\n  )\n}\n"
  },
  {
    "path": "src/components/media/MediaImageWithLoading.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { MediaImage, type MediaImageProps } from './MediaImage'\n\ntype MediaImageWithLoadingProps = MediaImageProps & {\n  containerClassName?: string\n  skeletonClassName?: string\n  keepSkeletonOnError?: boolean\n  showLoadingIndicator?: boolean\n  loadingIndicatorClassName?: string\n}\n\nfunction mergeClassNames(...classNames: Array<string | undefined | false>): string {\n  return classNames.filter(Boolean).join(' ')\n}\n\nexport function MediaImageWithLoading({\n  src,\n  alt,\n  className,\n  containerClassName,\n  skeletonClassName,\n  keepSkeletonOnError = false,\n  showLoadingIndicator = true,\n  loadingIndicatorClassName,\n  onLoad,\n  onError,\n  ...restProps\n}: MediaImageWithLoadingProps) {\n  const [isLoaded, setIsLoaded] = useState(false)\n  const [isError, setIsError] = useState(false)\n\n  useEffect(() => {\n    setIsLoaded(false)\n    setIsError(false)\n  }, [src])\n\n  if (!src) return null\n\n  const shouldShowSkeleton = !isLoaded && (!isError || keepSkeletonOnError)\n\n  const imageClassName = mergeClassNames(\n    className,\n    'transition-opacity duration-200',\n    shouldShowSkeleton ? 'opacity-0' : 'opacity-100',\n  )\n\n  const handleLoad: NonNullable<MediaImageProps['onLoad']> = (event) => {\n    setIsLoaded(true)\n    onLoad?.(event)\n  }\n\n  const handleError: NonNullable<MediaImageProps['onError']> = (event) => {\n    setIsError(true)\n    setIsLoaded(true)\n    onError?.(event)\n  }\n\n  return (\n    <div className={mergeClassNames('relative overflow-hidden bg-[var(--glass-bg-muted)]', containerClassName)}>\n      {shouldShowSkeleton && (\n        <div\n          className={mergeClassNames(\n            'pointer-events-none absolute inset-0 z-0 animate-pulse bg-[var(--glass-bg-muted)]',\n            skeletonClassName,\n          )}\n        />\n      )}\n      {shouldShowSkeleton && showLoadingIndicator && (\n        <div\n          className={mergeClassNames(\n            'pointer-events-none absolute inset-0 z-[1] flex items-center justify-center',\n            loadingIndicatorClassName,\n          )}\n        >\n          <span className=\"h-5 w-5 animate-spin rounded-full border-2 border-[var(--glass-stroke-strong)] border-t-[var(--glass-tone-info-fg)]\" />\n          <span className=\"sr-only\">Loading</span>\n        </div>\n      )}\n      <MediaImage\n        src={src}\n        alt={alt}\n        className={imageClassName}\n        onLoad={handleLoad}\n        onError={handleError}\n        {...restProps}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/providers/QueryProvider.tsx",
    "content": "'use client'\n\nimport { QueryClientProvider } from '@tanstack/react-query'\nimport { queryClient } from '@/lib/query/client'\n\n/**\n * React Query Provider\n * 包装应用以提供全局缓存能力\n */\nexport function QueryProvider({ children }: { children: React.ReactNode }) {\n    return (\n        <QueryClientProvider client={queryClient}>\n            {children}\n        </QueryClientProvider>\n    )\n}\n"
  },
  {
    "path": "src/components/shared/assets/CharacterCreationModal.tsx",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport type { DragEvent, MouseEvent } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { useProjectAssets } from '@/lib/query/hooks'\nimport CharacterCreationForm from './character-creation/CharacterCreationForm'\nimport { useCharacterCreationSubmit } from './character-creation/hooks/useCharacterCreationSubmit'\nimport { AppIcon } from '@/components/ui/icons'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\nimport { getImageGenerationCountOptions } from '@/lib/image-generation/count'\n\nexport interface CharacterCreationModalProps {\n  mode: 'asset-hub' | 'project'\n  folderId?: string | null\n  projectId?: string\n  onClose: () => void\n  onSuccess: () => void\n}\n\nconst XMarkIcon = ({ className }: { className?: string }) => (\n  <AppIcon name=\"close\" className={className} />\n)\n\nexport function CharacterCreationModal({\n  mode,\n  folderId,\n  projectId,\n  onClose,\n  onSuccess,\n}: CharacterCreationModalProps) {\n  const t = useTranslations('assetModal')\n\n  const [createMode, setCreateMode] = useState<'reference' | 'description'>('description')\n  const [name, setName] = useState('')\n  const [description, setDescription] = useState('')\n  const [aiInstruction, setAiInstruction] = useState('')\n  const [artStyle, setArtStyle] = useState('american-comic')\n  const [referenceImagesBase64, setReferenceImagesBase64] = useState<string[]>([])\n  const [referenceSubMode, setReferenceSubMode] = useState<'direct' | 'extract'>('direct')\n  const [isSubAppearance, setIsSubAppearance] = useState(false)\n  const [selectedCharacterId, setSelectedCharacterId] = useState('')\n  const [changeReason, setChangeReason] = useState('')\n\n  const fileInputRef = useRef<HTMLInputElement>(null)\n\n  const projectAssets = useProjectAssets(mode === 'project' ? (projectId ?? null) : null)\n  const availableCharacters = useMemo(() => {\n    if (mode !== 'project') return []\n    const items = projectAssets.data?.characters || []\n    return items.map((c) => ({\n      id: c.id,\n      name: c.name,\n      appearances: c.appearances || [],\n    }))\n  }, [mode, projectAssets.data?.characters])\n\n  const {\n    isSubmitting,\n    isAiDesigning,\n    isExtracting,\n    characterGenerationCount,\n    setCharacterGenerationCount,\n    referenceCharacterGenerationCount,\n    setReferenceCharacterGenerationCount,\n    handleExtractDescription,\n    handleCreateWithReference,\n    handleAiDesign,\n    handleSubmit,\n    handleSubmitAndGenerate,\n  } = useCharacterCreationSubmit({\n    mode,\n    folderId,\n    projectId,\n    name,\n    description,\n    aiInstruction,\n    artStyle,\n    referenceImagesBase64,\n    referenceSubMode,\n    isSubAppearance,\n    selectedCharacterId,\n    changeReason,\n    setDescription,\n    setAiInstruction,\n    onSuccess,\n    onClose,\n  })\n\n  const handleFileSelect = useCallback(async (files: FileList | File[]) => {\n    const fileArray = Array.from(files).filter((f) => f.type.startsWith('image/'))\n    if (fileArray.length === 0) return\n\n    const remaining = 5 - referenceImagesBase64.length\n    const toAdd = fileArray.slice(0, remaining)\n\n    for (const file of toAdd) {\n      const reader = new FileReader()\n      reader.onload = (e) => {\n        const base64 = e.target?.result as string\n        setReferenceImagesBase64((prev) => {\n          if (prev.length >= 5) return prev\n          if (prev.includes(base64)) return prev\n          return [...prev, base64]\n        })\n      }\n      reader.readAsDataURL(file)\n    }\n  }, [referenceImagesBase64.length])\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape' && !isSubmitting && !isAiDesigning) {\n        onClose()\n      }\n    }\n    document.addEventListener('keydown', handleKeyDown)\n    return () => document.removeEventListener('keydown', handleKeyDown)\n  }, [isAiDesigning, isSubmitting, onClose])\n\n  useEffect(() => {\n    const handleGlobalPaste = (e: ClipboardEvent) => {\n      if (createMode !== 'reference') return\n\n      const target = e.target as HTMLElement\n      if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return\n\n      const items = e.clipboardData?.items\n      if (!items) return\n\n      for (let i = 0; i < items.length; i++) {\n        if (!items[i].type.startsWith('image/')) continue\n        const file = items[i].getAsFile()\n        if (!file) continue\n        e.preventDefault()\n        void handleFileSelect([file])\n        break\n      }\n    }\n\n    document.addEventListener('paste', handleGlobalPaste)\n    return () => document.removeEventListener('paste', handleGlobalPaste)\n  }, [createMode, handleFileSelect])\n\n  const handleDrop = (e: DragEvent<HTMLDivElement>) => {\n    e.preventDefault()\n    e.stopPropagation()\n    if (e.dataTransfer.files.length > 0) {\n      void handleFileSelect(e.dataTransfer.files)\n    }\n  }\n\n  const handleClearReference = (index?: number) => {\n    if (typeof index === 'number') {\n      setReferenceImagesBase64((prev) => prev.filter((_, i) => i !== index))\n      return\n    }\n    setReferenceImagesBase64([])\n  }\n\n  const handleBackdropClick = (e: MouseEvent<HTMLDivElement>) => {\n    if (e.target === e.currentTarget && !isSubmitting && !isAiDesigning) {\n      onClose()\n    }\n  }\n\n  return (\n    <div\n      className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4\"\n      onClick={handleBackdropClick}\n    >\n      <div className=\"glass-surface-modal max-w-lg w-full max-h-[85vh] flex flex-col\">\n        <div className=\"p-6 overflow-y-auto flex-1\">\n          <div className=\"flex items-center justify-between mb-4\">\n            <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n              {t('character.title')}\n            </h3>\n            <button\n              onClick={onClose}\n              className=\"glass-btn-base glass-btn-soft w-8 h-8 rounded-full flex items-center justify-center text-[var(--glass-text-tertiary)]\"\n            >\n              <XMarkIcon className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          <CharacterCreationForm\n            mode={mode}\n            createMode={createMode}\n            setCreateMode={(value) => setCreateMode(value)}\n            name={name}\n            setName={(value) => setName(value)}\n            description={description}\n            setDescription={(value) => setDescription(value)}\n            aiInstruction={aiInstruction}\n            setAiInstruction={(value) => setAiInstruction(value)}\n            artStyle={artStyle}\n            setArtStyle={(value) => setArtStyle(value)}\n            referenceImagesBase64={referenceImagesBase64}\n            referenceSubMode={referenceSubMode}\n            setReferenceSubMode={(value) => setReferenceSubMode(value)}\n            isSubAppearance={isSubAppearance}\n            setIsSubAppearance={(value) => setIsSubAppearance(value)}\n            selectedCharacterId={selectedCharacterId}\n            setSelectedCharacterId={(value) => setSelectedCharacterId(value)}\n            changeReason={changeReason}\n            setChangeReason={(value) => setChangeReason(value)}\n            availableCharacters={availableCharacters}\n            fileInputRef={fileInputRef}\n            handleDrop={handleDrop}\n            handleFileSelect={(files) => void handleFileSelect(files)}\n            handleClearReference={handleClearReference}\n            handleExtractDescription={() => { void handleExtractDescription() }}\n            handleAiDesign={() => { void handleAiDesign() }}\n            isSubmitting={isSubmitting}\n            isAiDesigning={isAiDesigning}\n            isExtracting={isExtracting}\n          />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-xl flex-shrink-0\">\n          <button\n            onClick={onClose}\n            className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm\"\n            disabled={isSubmitting}\n          >\n            {t('common.cancel')}\n          </button>\n          {createMode === 'reference' ? (\n            <ImageGenerationInlineCountButton\n              prefix={<span>{t('character.useReferenceGeneratePrefix')}</span>}\n              suffix={<span>{t('character.generateCountSuffix')}</span>}\n              value={referenceCharacterGenerationCount}\n              options={getImageGenerationCountOptions('reference-to-character')}\n              onValueChange={setReferenceCharacterGenerationCount}\n              onClick={() => { void handleCreateWithReference() }}\n              actionDisabled={!name.trim() || referenceImagesBase64.length === 0}\n              selectDisabled={isSubmitting}\n              ariaLabel={t('character.selectReferenceGenerateCount')}\n              className=\"glass-btn-base glass-btn-primary flex items-center justify-center gap-1 rounded-lg px-4 py-2 text-sm disabled:opacity-40 disabled:cursor-not-allowed\"\n              selectClassName=\"appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-current outline-none cursor-pointer leading-none transition-colors\"\n            />\n          ) : isSubAppearance ? (\n            <button\n              onClick={() => { void handleSubmit() }}\n              disabled={isSubmitting || !selectedCharacterId.trim() || !changeReason.trim() || !description.trim()}\n              className=\"glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed\"\n            >\n              {isSubmitting ? t('common.adding') : t('common.add')}\n            </button>\n          ) : (\n            <>\n              <button\n                onClick={() => { void handleSubmit() }}\n                disabled={isSubmitting || !name.trim() || !description.trim()}\n                className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed\"\n              >\n                {isSubmitting ? t('common.adding') : (mode === 'asset-hub' ? t('common.addOnlyToAssetHub') : t('common.addOnly'))}\n              </button>\n              <ImageGenerationInlineCountButton\n                prefix={<span>{t('common.addAndGeneratePrefix')}</span>}\n                suffix={<span>{t('common.generateCountSuffix')}</span>}\n                value={characterGenerationCount}\n                options={getImageGenerationCountOptions('character')}\n                onValueChange={setCharacterGenerationCount}\n                onClick={() => { void handleSubmitAndGenerate() }}\n                actionDisabled={!name.trim() || !description.trim()}\n                selectDisabled={isSubmitting}\n                ariaLabel={t('common.selectGenerateCount')}\n                className=\"glass-btn-base glass-btn-primary flex items-center justify-center gap-1 rounded-lg px-4 py-2 text-sm disabled:opacity-40 disabled:cursor-not-allowed\"\n                selectClassName=\"appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-current outline-none cursor-pointer leading-none transition-colors\"\n              />\n            </>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/shared/assets/CharacterEditModal.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport { shouldShowError } from '@/lib/error-utils'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport {\n    useAiModifyCharacterDescription,\n    useAiModifyProjectAppearanceDescription,\n    useUpdateCharacterAppearanceDescription,\n    useUpdateCharacterName,\n    useUpdateProjectAppearanceDescription,\n    useUpdateProjectCharacterIntroduction,\n    useUpdateProjectCharacterName,\n} from '@/lib/query/hooks'\n\nexport interface CharacterEditModalProps {\n    mode: 'asset-hub' | 'project'\n    characterId: string\n    characterName: string\n    description: string\n    appearanceIndex?: number\n    changeReason?: string\n    projectId?: string\n    appearanceId?: string\n    descriptionIndex?: number\n    isTaskRunning?: boolean\n    introduction?: string | null\n    onClose: () => void\n    onSave: (characterId: string, appearanceId: string) => void\n    onUpdate?: (newDescription: string) => void\n    onIntroductionUpdate?: (newIntroduction: string) => void\n    onNameUpdate?: (newName: string) => void\n    onRefresh?: () => void\n}\n\nexport function CharacterEditModal({\n    mode,\n    characterId,\n    characterName,\n    description,\n    appearanceIndex,\n    changeReason,\n    projectId,\n    appearanceId,\n    descriptionIndex,\n    isTaskRunning = false,\n    introduction,\n    onClose,\n    onSave,\n    onUpdate,\n    onIntroductionUpdate,\n    onNameUpdate,\n    onRefresh,\n}: CharacterEditModalProps) {\n    const t = useTranslations('assets')\n\n    const appearanceKey = mode === 'asset-hub'\n        ? String(appearanceIndex ?? 0)\n        : String(appearanceId ?? '')\n\n    const [editingName, setEditingName] = useState(characterName)\n    const [editingDescription, setEditingDescription] = useState(description)\n    const [editingIntroduction, setEditingIntroduction] = useState(introduction || '')\n    const [aiModifyInstruction, setAiModifyInstruction] = useState('')\n    const [isAiModifying, setIsAiModifying] = useState(false)\n    const [isSaving, setIsSaving] = useState(false)\n    const aiModifyingState = isAiModifying\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'modify',\n            resource: 'image',\n            hasOutput: true,\n        })\n        : null\n    const savingState = isSaving\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'process',\n            resource: 'text',\n            hasOutput: false,\n        })\n        : null\n    const taskRunningState = isTaskRunning\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'modify',\n            resource: 'image',\n            hasOutput: true,\n        })\n        : null\n\n    const updateAssetHubName = useUpdateCharacterName()\n    const updateProjectName = useUpdateProjectCharacterName(projectId ?? '')\n    const updateAssetHubAppearanceDesc = useUpdateCharacterAppearanceDescription()\n    const updateProjectAppearanceDesc = useUpdateProjectAppearanceDescription(projectId ?? '')\n    const updateProjectIntroduction = useUpdateProjectCharacterIntroduction(projectId ?? '')\n    const aiModifyAssetHub = useAiModifyCharacterDescription()\n    const aiModifyProject = useAiModifyProjectAppearanceDescription(projectId ?? '')\n\n    const getErrorMessage = (error: unknown, fallback: string) => {\n        if (error instanceof Error && error.message) return error.message\n        return fallback\n    }\n\n    const persistNameIfNeeded = async () => {\n        const nextName = editingName.trim()\n        if (!nextName || nextName === characterName) return\n\n        if (mode === 'asset-hub') {\n            await updateAssetHubName.mutateAsync({ characterId, name: nextName })\n        } else {\n            await updateProjectName.mutateAsync({ characterId, name: nextName })\n        }\n        onNameUpdate?.(nextName)\n    }\n\n    const persistDescription = async () => {\n        if (mode === 'asset-hub') {\n            await updateAssetHubAppearanceDesc.mutateAsync({\n                characterId,\n                appearanceIndex: appearanceIndex ?? 0,\n                description: editingDescription,\n            })\n            return\n        }\n\n        if (!appearanceId) {\n            throw new Error('Missing appearanceId')\n        }\n        await updateProjectAppearanceDesc.mutateAsync({\n            characterId,\n            appearanceId,\n            description: editingDescription,\n            descriptionIndex,\n        })\n    }\n\n    const persistIntroductionIfNeeded = async () => {\n        if (mode !== 'project' || !projectId) return\n        if (editingIntroduction === (introduction || '')) return\n\n        const nextIntro = editingIntroduction.trim()\n        await updateProjectIntroduction.mutateAsync({\n            characterId,\n            introduction: nextIntro,\n        })\n        onIntroductionUpdate?.(nextIntro)\n    }\n\n    const handleAiModify = async () => {\n        if (!aiModifyInstruction.trim()) return\n\n        try {\n            setIsAiModifying(true)\n\n            if (mode === 'asset-hub') {\n                const data = await aiModifyAssetHub.mutateAsync({\n                    characterId,\n                    appearanceIndex: appearanceIndex ?? 0,\n                    currentDescription: editingDescription,\n                    modifyInstruction: aiModifyInstruction,\n                })\n                if (data?.modifiedDescription) {\n                    setEditingDescription(data.modifiedDescription)\n                    onUpdate?.(data.modifiedDescription)\n                    setAiModifyInstruction('')\n                }\n                return\n            }\n\n            if (!appearanceId) throw new Error('Missing appearanceId')\n            const data = await aiModifyProject.mutateAsync({\n                characterId,\n                appearanceId,\n                currentDescription: editingDescription,\n                modifyInstruction: aiModifyInstruction,\n            })\n            if (data?.modifiedDescription) {\n                setEditingDescription(data.modifiedDescription)\n                onUpdate?.(data.modifiedDescription)\n                setAiModifyInstruction('')\n            }\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                alert(`${t('modal.modifyFailed')}: ${getErrorMessage(error, t('errors.failed'))}`)\n            }\n        } finally {\n            setIsAiModifying(false)\n        }\n    }\n\n    const handleSaveName = async () => {\n        try {\n            await persistNameIfNeeded()\n            onRefresh?.()\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                alert(t('modal.saveName') + t('errors.failed'))\n            }\n        }\n    }\n\n    const handleSaveOnly = async () => {\n        try {\n            setIsSaving(true)\n            await persistNameIfNeeded()\n            await persistDescription()\n            await persistIntroductionIfNeeded()\n\n            onUpdate?.(editingDescription)\n            onRefresh?.()\n            onClose()\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                alert(getErrorMessage(error, t('errors.saveFailed')))\n            }\n        } finally {\n            setIsSaving(false)\n        }\n    }\n\n    const handleSaveAndGenerate = async () => {\n        const savedDescription = editingDescription\n        const savedAppearanceKey = appearanceKey\n        onClose()\n\n        ; (async () => {\n            try {\n                await persistNameIfNeeded()\n                await persistDescription()\n                await persistIntroductionIfNeeded()\n\n                onUpdate?.(savedDescription)\n                onRefresh?.()\n                onSave(characterId, savedAppearanceKey)\n            } catch (error: unknown) {\n                if (shouldShowError(error)) {\n                    alert(getErrorMessage(error, t('errors.saveFailed')))\n                }\n            }\n        })()\n    }\n\n    return (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4\">\n            <div className=\"glass-surface-modal max-w-2xl w-full max-h-[80vh] flex flex-col\">\n                <div className=\"p-6 space-y-4 overflow-y-auto flex-1\">\n                    <div className=\"flex items-center justify-between\">\n                        <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                            {t('modal.editCharacter')} - {characterName}\n                        </h3>\n                        <button\n                            onClick={onClose}\n                            className=\"glass-btn-base glass-btn-soft w-9 h-9 rounded-full text-[var(--glass-text-tertiary)]\"\n                        >\n                            <AppIcon name=\"close\" className=\"w-6 h-6\" />\n                        </button>\n                    </div>\n\n                    <div className=\"space-y-2\">\n                        <label className=\"glass-field-label block\">\n                            {t('character.name')}\n                        </label>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={editingName}\n                                onChange={(e) => setEditingName(e.target.value)}\n                                className=\"glass-input-base flex-1 px-3 py-2\"\n                                placeholder={t('modal.namePlaceholder')}\n                            />\n                            {editingName !== characterName && (\n                                <button\n                                    onClick={handleSaveName}\n                                    disabled={updateAssetHubName.isPending || updateProjectName.isPending || !editingName.trim()}\n                                    className=\"glass-btn-base glass-btn-tone-success px-3 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm whitespace-nowrap\"\n                                >\n                                    {(updateAssetHubName.isPending || updateProjectName.isPending)\n                                        ? t('smartImport.preview.saving')\n                                        : t('modal.saveName')}\n                                </button>\n                            )}\n                        </div>\n                    </div>\n\n                    {mode === 'project' && (\n                        <div className=\"space-y-2\">\n                            <label className=\"glass-field-label block\">\n                                {t('modal.introduction')}\n                            </label>\n                            <textarea\n                                value={editingIntroduction}\n                                onChange={(e) => setEditingIntroduction(e.target.value)}\n                                rows={3}\n                                className=\"glass-textarea-base w-full px-3 py-2 resize-none\"\n                                placeholder={t('modal.introductionPlaceholder')}\n                            />\n                            <p className=\"glass-field-hint\">\n                                {t('modal.introductionTip')}\n                            </p>\n                        </div>\n                    )}\n\n                    {mode === 'asset-hub' && changeReason && (\n                        <div className=\"text-sm text-[var(--glass-text-secondary)]\">\n                            {t('character.appearance')}:\n                            <span className=\"ml-1 inline-flex items-center rounded-full px-2 py-0.5 bg-[var(--glass-tone-neutral-bg)] text-[var(--glass-tone-neutral-fg)]\">\n                                {changeReason}\n                            </span>\n                        </div>\n                    )}\n\n                    <div className=\"space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]\">\n                        <label className=\"block text-sm font-medium text-[var(--glass-tone-info-fg)] flex items-center gap-2\">\n                            <AppIcon name=\"bolt\" className=\"w-4 h-4\" />\n                            {t('modal.smartModify')}\n                        </label>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={aiModifyInstruction}\n                                onChange={(e) => setAiModifyInstruction(e.target.value)}\n                                placeholder={t('modal.modifyPlaceholderCharacter')}\n                                className=\"glass-input-base flex-1 px-3 py-2\"\n                                disabled={isAiModifying}\n                                onKeyDown={(e) => {\n                                    if (e.key === 'Enter' && !e.shiftKey) {\n                                        e.preventDefault()\n                                        handleAiModify()\n                                    }\n                                }}\n                            />\n                            <button\n                                onClick={handleAiModify}\n                                disabled={isAiModifying || !aiModifyInstruction.trim()}\n                                className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap\"\n                            >\n                                {isAiModifying ? (\n                                    <TaskStatusInline state={aiModifyingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                                ) : (\n                                    <>\n                                        <AppIcon name=\"bolt\" className=\"w-4 h-4\" />\n                                        {t('modal.smartModify')}\n                                    </>\n                                )}\n                            </button>\n                        </div>\n                        <p className=\"glass-field-hint\">\n                            {t('modal.aiTipSub')}\n                        </p>\n                    </div>\n\n                    <div className=\"space-y-2\">\n                        <label className=\"glass-field-label block\">\n                            {t('modal.appearancePrompt')}\n                        </label>\n                        <textarea\n                            value={editingDescription}\n                            onChange={(e) => setEditingDescription(e.target.value)}\n                            className=\"glass-textarea-base w-full h-64 px-3 py-2 resize-none\"\n                            placeholder={t('modal.descPlaceholder')}\n                            disabled={isAiModifying}\n                        />\n                    </div>\n                </div>\n\n                <div className=\"flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0\">\n                    <button\n                        onClick={onClose}\n                        className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg\"\n                        disabled={isSaving}\n                    >\n                        {t('common.cancel')}\n                    </button>\n                    <button\n                        onClick={handleSaveOnly}\n                        disabled={isSaving || !editingDescription.trim()}\n                        className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                    >\n                        {isSaving ? (\n                            <TaskStatusInline state={savingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                        ) : (\n                            t('modal.saveOnly')\n                        )}\n                    </button>\n                    <button\n                        onClick={handleSaveAndGenerate}\n                        disabled={isSaving || isTaskRunning || !editingDescription.trim()}\n                        className=\"glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                    >\n                        {isTaskRunning ? (\n                            <TaskStatusInline state={taskRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                        ) : (\n                            t('modal.saveAndGenerate')\n                        )}\n                    </button>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/components/shared/assets/GlobalAssetPicker.tsx",
    "content": "'use client'\nimport { useState, useEffect, useRef } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { useQuery } from '@tanstack/react-query'\nimport ImagePreviewModal from '@/components/ui/ImagePreviewModal'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { AppIcon } from '@/components/ui/icons'\nimport { apiFetch } from '@/lib/api-fetch'\n\ninterface GlobalAssetPickerProps {\n    isOpen: boolean\n    onClose: () => void\n    onSelect: (globalAssetId: string) => void\n    type: 'character' | 'location' | 'voice'\n    loading?: boolean\n}\n\ninterface GlobalCharacterAppearance {\n    id: string\n    imageUrl: string | null\n    imageUrls: string[]\n    selectedIndex: number | null\n}\n\ninterface GlobalCharacter {\n    id: string\n    name: string\n    folderId: string | null\n    customVoiceUrl: string | null\n    appearances: GlobalCharacterAppearance[]\n}\n\ninterface GlobalLocationImage {\n    id: string\n    imageIndex: number\n    imageUrl: string | null\n    isSelected: boolean\n}\n\ninterface GlobalLocation {\n    id: string\n    name: string\n    summary: string | null\n    folderId: string | null\n    images: GlobalLocationImage[]\n}\n\ninterface GlobalVoice {\n    id: string\n    name: string\n    description: string | null\n    folderId: string | null\n    customVoiceUrl: string | null\n    voiceId: string | null\n    voiceType: string\n    voicePrompt: string | null\n    gender: string | null\n    language: string\n}\n\n/** 从 appearances 中提取预览图 URL */\nfunction getCharacterPreview(char: GlobalCharacter): string | null {\n    const first = char.appearances?.[0]\n    if (!first) return null\n    // 优先使用 selectedIndex 指向的图\n    if (first.selectedIndex != null && first.imageUrls?.[first.selectedIndex]) {\n        return first.imageUrls[first.selectedIndex]\n    }\n    return first.imageUrl || first.imageUrls?.[0] || null\n}\n\n/** 从 images 中提取预览图 URL */\nfunction getLocationPreview(loc: GlobalLocation): string | null {\n    const selected = loc.images?.find(img => img.isSelected)\n    if (selected?.imageUrl) return selected.imageUrl\n    return loc.images?.[0]?.imageUrl || null\n}\n\n// 内联 SVG 图标组件\nconst XMarkIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"close\" className={className} />\n)\n\nconst MagnifyingGlassIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"search\" className={className} />\n)\n\nconst UserIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"userAlt\" className={className} />\n)\n\nconst PhotoIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"image\" className={className} />\n)\n\nconst CheckCircleIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"badgeCheck\" className={className} />\n)\n\n\nconst MicrophoneIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"mic\" className={className} />\n)\n\nexport default function GlobalAssetPicker({\n    isOpen,\n    onClose,\n    onSelect,\n    type,\n    loading: externalLoading\n}: GlobalAssetPickerProps) {\n    const t = useTranslations('assetPicker')\n\n    // 轻量级查询：只查询当前 type，不附带任务状态\n    const charactersQuery = useQuery({\n        queryKey: ['global-assets', 'characters'],\n        queryFn: async () => {\n            const res = await apiFetch('/api/asset-hub/characters')\n            if (!res.ok) throw new Error('Failed to fetch characters')\n            const data = await res.json()\n            return data.characters as GlobalCharacter[]\n        },\n        enabled: type === 'character',\n    })\n    const locationsQuery = useQuery({\n        queryKey: ['global-assets', 'locations'],\n        queryFn: async () => {\n            const res = await apiFetch('/api/asset-hub/locations')\n            if (!res.ok) throw new Error('Failed to fetch locations')\n            const data = await res.json()\n            return data.locations as GlobalLocation[]\n        },\n        enabled: type === 'location',\n    })\n    const voicesQuery = useQuery({\n        queryKey: ['global-assets', 'voices'],\n        queryFn: async () => {\n            const res = await apiFetch('/api/asset-hub/voices')\n            if (!res.ok) throw new Error('Failed to fetch voices')\n            const data = await res.json()\n            return data.voices as GlobalVoice[]\n        },\n        enabled: type === 'voice',\n    })\n\n    const characters = (charactersQuery.data || []) as GlobalCharacter[]\n    const locations = (locationsQuery.data || []) as GlobalLocation[]\n    const voices = (voicesQuery.data || []) as GlobalVoice[]\n    const isLoading = type === 'character'\n        ? charactersQuery.isFetching\n        : type === 'location'\n            ? locationsQuery.isFetching\n            : voicesQuery.isFetching\n    const loadingState = isLoading\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'process',\n            resource: type === 'voice' ? 'audio' : 'image',\n            hasOutput: false,\n        })\n        : null\n    const copyingState = externalLoading\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'process',\n            resource: type === 'voice' ? 'audio' : 'image',\n            hasOutput: false,\n        })\n        : null\n    const [selectedId, setSelectedId] = useState<string | null>(null)\n    const [searchQuery, setSearchQuery] = useState('')\n    const [previewImage, setPreviewImage] = useState<string | null>(null)\n    const [previewAudio, setPreviewAudio] = useState<string | null>(null)\n    const [isPlayingAudio, setIsPlayingAudio] = useState(false)\n    const audioRef = useRef<HTMLAudioElement | null>(null)\n\n    // 提取稳定的 refetch 引用，避免 useEffect 无限循环\n    const refetchCharacters = charactersQuery.refetch\n    const refetchLocations = locationsQuery.refetch\n    const refetchVoices = voicesQuery.refetch\n\n    // 停止音频播放的辅助函数\n    const stopAudio = () => {\n        if (audioRef.current) {\n            audioRef.current.pause()\n            audioRef.current.currentTime = 0\n            audioRef.current = null\n        }\n        setIsPlayingAudio(false)\n        setPreviewAudio(null)\n    }\n\n    useEffect(() => {\n        if (isOpen) {\n            setSelectedId(null)\n            setSearchQuery('')\n            if (type === 'character') {\n                refetchCharacters()\n            } else if (type === 'location') {\n                refetchLocations()\n            } else {\n                refetchVoices()\n            }\n        } else {\n            // 关闭对话框时停止播放\n            stopAudio()\n        }\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [isOpen, type])\n\n    const handleConfirm = () => {\n        if (selectedId) {\n            stopAudio()  // 确认复制时停止音频播放\n            onSelect(selectedId)\n        }\n    }\n\n    const filteredCharacters = characters.filter(c =>\n        c.name.toLowerCase().includes(searchQuery.toLowerCase())\n    )\n\n    const filteredLocations = locations.filter(l =>\n        l.name.toLowerCase().includes(searchQuery.toLowerCase())\n    )\n\n    const filteredVoices = voices.filter(v =>\n        v.name.toLowerCase().includes(searchQuery.toLowerCase()) ||\n        (v.description && v.description.toLowerCase().includes(searchQuery.toLowerCase()))\n    )\n\n    // 播放/暂停音频预览\n    const handlePlayAudio = (audioUrl: string, e: React.MouseEvent) => {\n        e.stopPropagation()\n\n        // 如果点击的是当前正在播放的音频，则暂停\n        if (previewAudio === audioUrl && isPlayingAudio) {\n            stopAudio()\n            return\n        }\n\n        // 停止之前的播放\n        stopAudio()\n\n        // 开始播放新音频\n        setIsPlayingAudio(true)\n        setPreviewAudio(audioUrl)\n        const audio = new Audio(audioUrl)\n        audioRef.current = audio\n        audio.play()\n        audio.onended = () => {\n            setIsPlayingAudio(false)\n            setPreviewAudio(null)\n            audioRef.current = null\n        }\n        audio.onerror = () => {\n            setIsPlayingAudio(false)\n            setPreviewAudio(null)\n            audioRef.current = null\n        }\n    }\n\n    if (!isOpen) return null\n\n    const items = type === 'character' ? filteredCharacters : type === 'location' ? filteredLocations : filteredVoices\n    const hasNoAssets = type === 'character' ? characters.length === 0 : type === 'location' ? locations.length === 0 : voices.length === 0\n\n    return (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50\">\n            <div className=\"glass-surface-modal w-[600px] max-h-[80vh] flex flex-col\">\n                {/* 头部 */}\n                <div className=\"flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)]\">\n                    <h2 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                        {type === 'character' ? t('selectCharacter') : type === 'location' ? t('selectLocation') : t('selectVoice')}\n                    </h2>\n                    <button onClick={onClose} className=\"glass-btn-base glass-btn-soft text-[var(--glass-text-tertiary)]\">\n                        <XMarkIcon className=\"w-5 h-5\" />\n                    </button>\n                </div>\n\n                {/* 搜索栏 */}\n                <div className=\"px-6 py-3 border-b border-[var(--glass-stroke-base)]\">\n                    <div className=\"relative\">\n                        <MagnifyingGlassIcon className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--glass-text-tertiary)]\" />\n                        <input\n                            type=\"text\"\n                            value={searchQuery}\n                            onChange={(e) => setSearchQuery(e.target.value)}\n                            placeholder={t('searchPlaceholder')}\n                            className=\"glass-input-base w-full pl-9 pr-4 py-2 text-sm\"\n                        />\n                    </div>\n                </div>\n\n                {/* 资产列表 */}\n                <div className=\"flex-1 overflow-y-auto p-4\">\n                    {isLoading ? (\n                        <div className=\"flex items-center justify-center h-40\">\n                            <TaskStatusInline state={loadingState} />\n                        </div>\n                    ) : hasNoAssets ? (\n                        <div className=\"flex flex-col items-center justify-center h-40 text-[var(--glass-text-tertiary)]\">\n                            {type === 'character' ? (\n                                <UserIcon className=\"w-12 h-12 mb-2\" />\n                            ) : type === 'location' ? (\n                                <PhotoIcon className=\"w-12 h-12 mb-2\" />\n                            ) : (\n                                <MicrophoneIcon className=\"w-12 h-12 mb-2\" />\n                            )}\n                            <p>{t('noAssets')}</p>\n                            <p className=\"text-sm mt-1\">{t('createInAssetHub')}</p>\n                        </div>\n                    ) : items.length === 0 ? (\n                        <div className=\"flex items-center justify-center h-40 text-[var(--glass-text-tertiary)]\">\n                            <p>{t('noSearchResults')}</p>\n                        </div>\n                    ) : (\n                        <div className=\"grid grid-cols-3 gap-3\">\n                            {type === 'character' ? (\n                                filteredCharacters.map((char) => {\n                                    const charPreview = getCharacterPreview(char)\n                                    return (\n                                        <div\n                                            key={char.id}\n                                            onClick={() => setSelectedId(char.id)}\n                                            className={`relative cursor-pointer rounded-xl border-2 p-2 transition-all hover:shadow-md ${selectedId === char.id\n                                                ? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'\n                                                : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'\n                                                }`}\n                                        >\n                                            {/* 选中标记 */}\n                                            {selectedId === char.id && (\n                                                <CheckCircleIcon className=\"absolute -top-2 -right-2 w-6 h-6 text-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface)] rounded-full\" />\n                                            )}\n\n                                            {/* 预览图 */}\n                                            <div className=\"aspect-square rounded-lg overflow-hidden bg-[var(--glass-bg-muted)] mb-2 relative\">\n                                                {charPreview ? (\n                                                    <MediaImageWithLoading\n                                                        src={charPreview}\n                                                        alt={char.name}\n                                                        containerClassName=\"w-full h-full\"\n                                                        className=\"w-full h-full object-cover cursor-zoom-in\"\n                                                        onClick={(e) => {\n                                                            e.stopPropagation()\n                                                            setPreviewImage(charPreview)\n                                                        }}\n                                                    />\n                                                ) : (\n                                                    <div className=\"w-full h-full flex items-center justify-center text-[var(--glass-text-tertiary)]\">\n                                                        <UserIcon className=\"w-12 h-12\" />\n                                                    </div>\n                                                )}\n                                            </div>\n\n                                            {/* 名称 */}\n                                            <div className=\"text-center\">\n                                                <p className=\"font-medium text-sm text-[var(--glass-text-primary)] truncate\">{char.name}</p>\n                                                <p className=\"text-xs text-[var(--glass-text-secondary)] mt-1\">\n                                                    {char.appearances?.length || 0} {t('appearances')}\n                                                    {char.customVoiceUrl && ' · Voice'}\n                                                </p>\n                                            </div>\n                                        </div>\n                                    )\n                                })\n                            ) : type === 'location' ? (\n                                filteredLocations.map((loc) => {\n                                    const locPreview = getLocationPreview(loc)\n                                    return (\n                                        <div\n                                            key={loc.id}\n                                            onClick={() => setSelectedId(loc.id)}\n                                            className={`relative cursor-pointer rounded-xl border-2 p-2 transition-all hover:shadow-md ${selectedId === loc.id\n                                                ? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'\n                                                : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'\n                                                }`}\n                                        >\n                                            {/* 选中标记 */}\n                                            {selectedId === loc.id && (\n                                                <CheckCircleIcon className=\"absolute -top-2 -right-2 w-6 h-6 text-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface)] rounded-full\" />\n                                            )}\n\n                                            {/* 预览图 */}\n                                            <div className=\"aspect-video rounded-lg overflow-hidden bg-[var(--glass-bg-muted)] mb-2 relative\">\n                                                {locPreview ? (\n                                                    <MediaImageWithLoading\n                                                        src={locPreview}\n                                                        alt={loc.name}\n                                                        containerClassName=\"w-full h-full\"\n                                                        className=\"w-full h-full object-cover cursor-zoom-in\"\n                                                        onClick={(e) => {\n                                                            e.stopPropagation()\n                                                            setPreviewImage(locPreview)\n                                                        }}\n                                                    />\n                                                ) : (\n                                                    <div className=\"w-full h-full flex items-center justify-center text-[var(--glass-text-tertiary)]\">\n                                                        <PhotoIcon className=\"w-12 h-12\" />\n                                                    </div>\n                                                )}\n                                            </div>\n\n                                            {/* 名称 */}\n                                            <div className=\"text-center\">\n                                                <p className=\"font-medium text-sm text-[var(--glass-text-primary)] truncate\">{loc.name}</p>\n                                                <p className=\"text-xs text-[var(--glass-text-secondary)] mt-1\">\n                                                    {loc.images?.length || 0} {t('images')}\n                                                </p>\n                                            </div>\n                                        </div>\n                                    )\n                                })\n                            ) : (\n                                // 音色列表渲染 - 与资产中心 VoiceCard 风格统一\n                                filteredVoices.map((voice) => {\n                                    const genderIcon = voice.gender === 'male' ? 'M' : voice.gender === 'female' ? 'F' : ''\n                                    const isVoicePlaying = previewAudio === voice.customVoiceUrl && isPlayingAudio\n                                    return (\n                                        <div\n                                            key={voice.id}\n                                            onClick={() => setSelectedId(voice.id)}\n                                            className={`relative cursor-pointer glass-surface overflow-hidden transition-all hover:shadow-md ${selectedId === voice.id\n                                                ? 'ring-2 ring-[var(--glass-stroke-focus)]'\n                                                : 'hover:ring-2 hover:ring-[var(--glass-focus-ring-strong)]'\n                                                }`}\n                                        >\n                                            {/* 选中标记 */}\n                                            {selectedId === voice.id && (\n                                                <div className=\"absolute top-2 right-2 w-6 h-6 glass-chip glass-chip-info rounded-full flex items-center justify-center z-10 p-0\">\n                                                    <AppIcon name=\"checkSolid\" className=\"w-4 h-4 text-white\" />\n                                                </div>\n                                            )}\n\n                                            {/* 音色图标区域 - 与 VoiceCard 统一 */}\n                                            <div className=\"relative bg-[var(--glass-bg-muted)] p-6 flex items-center justify-center\">\n                                                <div className=\"w-16 h-16 rounded-full glass-surface-soft flex items-center justify-center\">\n                                                    <MicrophoneIcon className=\"w-8 h-8 text-[var(--glass-tone-info-fg)]\" />\n                                                </div>\n\n                                                {/* 性别标签 */}\n                                                {genderIcon && (\n                                                    <div className=\"absolute top-2 left-2 glass-chip glass-chip-neutral text-xs px-2 py-0.5 rounded-full\">\n                                                        {genderIcon}\n                                                    </div>\n                                                )}\n\n                                                {/* 试听按钮 - 圆形，与 VoiceCard 统一 */}\n                                                {voice.customVoiceUrl && (\n                                                    <button\n                                                        onClick={(e) => handlePlayAudio(voice.customVoiceUrl!, e)}\n                                                        className={`absolute bottom-2 right-2 w-10 h-10 rounded-full glass-btn-base flex items-center justify-center transition-all ${isVoicePlaying\n                                                            ? 'glass-btn-tone-info animate-pulse'\n                                                            : 'glass-btn-secondary text-[var(--glass-tone-info-fg)]'\n                                                            }`}\n                                                    >\n                                                        {isVoicePlaying ? (\n                                                            <AppIcon name=\"pause\" className=\"w-5 h-5\" />\n                                                        ) : (\n                                                            <AppIcon name=\"play\" className=\"w-5 h-5\" />\n                                                        )}\n                                                    </button>\n                                                )}\n                                            </div>\n\n                                            {/* 信息区域 */}\n                                            <div className=\"p-3\">\n                                                <h3 className=\"font-medium text-[var(--glass-text-primary)] text-sm truncate\">{voice.name}</h3>\n                                                {voice.description && (\n                                                    <p className=\"mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2\">{voice.description}</p>\n                                                )}\n                                                {voice.voicePrompt && !voice.description && (\n                                                    <p className=\"mt-1 text-xs text-[var(--glass-text-tertiary)] line-clamp-2 italic\">{voice.voicePrompt}</p>\n                                                )}\n                                            </div>\n                                        </div>\n                                    )\n                                })\n                            )}\n                        </div>\n                    )}\n                </div>\n\n                {/* 底部按钮 */}\n                <div className=\"flex justify-end gap-3 px-6 py-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]\">\n                    <button\n                        onClick={onClose}\n                        className=\"glass-btn-base glass-btn-secondary px-4 py-2 text-sm\"\n                    >\n                        {t('cancel')}\n                    </button>\n                    <button\n                        onClick={handleConfirm}\n                        disabled={!selectedId || externalLoading}\n                        className=\"glass-btn-base glass-btn-primary px-4 py-2 text-sm rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                    >\n                        {externalLoading && <TaskStatusInline state={copyingState} className=\"text-white [&>span]:sr-only [&_svg]:text-white\" />}\n                        {t('confirmCopy')}\n                    </button>\n                </div>\n            </div>\n\n            {/* 图片放大预览弹窗 */}\n            {\n                previewImage && (\n                    <ImagePreviewModal\n                        imageUrl={previewImage}\n                        onClose={() => setPreviewImage(null)}\n                    />\n                )\n            }\n        </div >\n    )\n}\n"
  },
  {
    "path": "src/components/shared/assets/LocationCreationModal.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\n\nimport { useState, useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport { ART_STYLES } from '@/lib/constants'\nimport { shouldShowError } from '@/lib/error-utils'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport {\n    useAiCreateProjectLocation,\n    useAiDesignLocation,\n    useCreateAssetHubLocation,\n    useGenerateLocationImage,\n    useCreateProjectLocation,\n    useGenerateProjectLocationImage,\n} from '@/lib/query/hooks'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\nimport { getImageGenerationCountOptions } from '@/lib/image-generation/count'\n\nexport interface LocationCreationModalProps {\n    mode: 'asset-hub' | 'project'\n    // Asset Hub 模式使用\n    folderId?: string | null\n    // 项目模式使用\n    projectId?: string\n    onClose: () => void\n    onSuccess: () => void\n}\n\n// 内联 SVG 图标\nconst XMarkIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"close\" className={className} />\n)\n\nconst SparklesIcon = ({ className }: { className?: string }) => (\n    <AppIcon name=\"sparklesAlt\" className={className} />\n)\n\nexport function LocationCreationModal({\n    mode,\n    folderId,\n    projectId,\n    onClose,\n    onSuccess\n}: LocationCreationModalProps) {\n    const t = useTranslations('assetModal')\n    const aiDesignAssetHubLocation = useAiDesignLocation()\n    const createAssetHubLocation = useCreateAssetHubLocation()\n    const generateAssetHubLocation = useGenerateLocationImage()\n    const aiCreateProjectLocation = useAiCreateProjectLocation(projectId || '')\n    const createProjectLocation = useCreateProjectLocation(projectId || '')\n    const generateProjectLocation = useGenerateProjectLocationImage(projectId || '')\n    const {\n        count: locationGenerationCount,\n        setCount: setLocationGenerationCount,\n    } = useImageGenerationCount('location')\n\n    // 表单字段\n    const [name, setName] = useState('')\n    const [description, setDescription] = useState('')\n    const [aiInstruction, setAiInstruction] = useState('')\n    const [artStyle, setArtStyle] = useState('american-comic')\n\n    const [isSubmitting, setIsSubmitting] = useState(false)\n    const [isAiDesigning, setIsAiDesigning] = useState(false)\n    const aiDesigningState = isAiDesigning\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n    const submittingState = isSubmitting\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'generate',\n            resource: 'image',\n            hasOutput: false,\n        })\n        : null\n\n    const getErrorMessage = (error: unknown, fallback: string) => {\n        if (error instanceof Error && error.message) {\n            return error.message\n        }\n        return fallback\n    }\n\n    const getErrorStatus = (error: unknown): number | null => {\n        if (typeof error === 'object' && error !== null) {\n            const status = (error as { status?: unknown }).status\n            if (typeof status === 'number') return status\n        }\n        return null\n    }\n\n    // ESC 键关闭\n    useEffect(() => {\n        const handleKeyDown = (e: KeyboardEvent) => {\n            if (e.key === 'Escape' && !isSubmitting && !isAiDesigning) {\n                onClose()\n            }\n        }\n        document.addEventListener('keydown', handleKeyDown)\n        return () => document.removeEventListener('keydown', handleKeyDown)\n    }, [onClose, isSubmitting, isAiDesigning])\n\n    // AI 设计描述\n    const handleAiDesign = async () => {\n        if (!aiInstruction.trim()) return\n\n        try {\n            setIsAiDesigning(true)\n            const data = mode === 'asset-hub'\n                ? await aiDesignAssetHubLocation.mutateAsync(aiInstruction)\n                : await aiCreateProjectLocation.mutateAsync({ userInstruction: aiInstruction })\n            setDescription(data.prompt || '')\n            setAiInstruction('')\n        } catch (error: unknown) {\n            if (getErrorStatus(error) === 402) {\n                alert(getErrorMessage(error, t('errors.insufficientBalance')))\n            } else {\n                _ulogError('AI设计失败:', error)\n                if (shouldShowError(error)) {\n                    alert(getErrorMessage(error, t('errors.aiDesignFailed')))\n                }\n            }\n        } finally {\n            setIsAiDesigning(false)\n        }\n    }\n\n    type CreatedLocationResponse = {\n        location?: {\n            id: string\n        }\n    }\n\n    // 提交创建\n    const handleSubmit = async () => {\n        if (!name.trim() || !description.trim()) return\n\n        try {\n            setIsSubmitting(true)\n\n            const body: {\n                name: string\n                description: string\n                artStyle: string\n                folderId?: string | null\n            } = {\n                name: name.trim(),\n                description: description.trim(),\n                artStyle\n            }\n\n            if (mode === 'asset-hub') {\n                body.folderId = folderId\n            }\n\n            if (mode === 'asset-hub') {\n                await createAssetHubLocation.mutateAsync({\n                    name: body.name,\n                    summary: body.description,\n                    artStyle: body.artStyle,\n                    folderId: body.folderId ?? null,\n                })\n            } else {\n                await createProjectLocation.mutateAsync({\n                    name: body.name,\n                    description: body.description,\n                    artStyle: body.artStyle,\n                })\n            }\n\n            onSuccess()\n            onClose()\n        } catch (error: unknown) {\n            if (getErrorStatus(error) === 402) {\n                alert(getErrorMessage(error, t('errors.insufficientBalance')))\n            } else if (shouldShowError(error)) {\n                alert(getErrorMessage(error, t('errors.createFailed')))\n            }\n        } finally {\n            setIsSubmitting(false)\n        }\n    }\n\n    const handleSubmitAndGenerate = async () => {\n        if (!name.trim() || !description.trim()) return\n\n        try {\n            setIsSubmitting(true)\n\n            if (mode === 'asset-hub') {\n                const result = await createAssetHubLocation.mutateAsync({\n                    name: name.trim(),\n                    summary: description.trim(),\n                    artStyle,\n                    folderId: folderId ?? null,\n                    count: locationGenerationCount,\n                }) as CreatedLocationResponse\n                const createdLocationId = result.location?.id\n                if (!createdLocationId) {\n                    throw new Error(t('errors.createFailed'))\n                }\n                await generateAssetHubLocation.mutateAsync({\n                    locationId: createdLocationId,\n                    artStyle,\n                    count: locationGenerationCount,\n                })\n            } else {\n                const result = await createProjectLocation.mutateAsync({\n                    name: name.trim(),\n                    description: description.trim(),\n                    artStyle,\n                    count: locationGenerationCount,\n                }) as CreatedLocationResponse\n                const createdLocationId = result.location?.id\n                if (!createdLocationId) {\n                    throw new Error(t('errors.createFailed'))\n                }\n                await generateProjectLocation.mutateAsync({\n                    locationId: createdLocationId,\n                    artStyle,\n                    count: locationGenerationCount,\n                })\n            }\n\n            onSuccess()\n            onClose()\n        } catch (error: unknown) {\n            if (getErrorStatus(error) === 402) {\n                alert(getErrorMessage(error, t('errors.insufficientBalance')))\n            } else if (shouldShowError(error)) {\n                alert(getErrorMessage(error, t('errors.createFailed')))\n            }\n        } finally {\n            setIsSubmitting(false)\n        }\n    }\n\n    // 处理点击遮罩层关闭\n    const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {\n        if (e.target === e.currentTarget && !isSubmitting && !isAiDesigning) {\n            onClose()\n        }\n    }\n\n    return (\n        <div\n            className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4\"\n            onClick={handleBackdropClick}\n        >\n            <div className=\"glass-surface-modal max-w-2xl w-full max-h-[85vh] flex flex-col\">\n                <div className=\"p-6 overflow-y-auto flex-1\">\n                    {/* 标题 */}\n                    <div className=\"flex items-center justify-between mb-6\">\n                        <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                            {t('location.title')}\n                        </h3>\n                        <button\n                            onClick={onClose}\n                            className=\"glass-btn-base glass-btn-soft w-8 h-8 rounded-full flex items-center justify-center text-[var(--glass-text-tertiary)]\"\n                        >\n                            <XMarkIcon className=\"w-5 h-5\" />\n                        </button>\n                    </div>\n\n                    <div className=\"space-y-5\">\n                        {/* 场景名称 */}\n                        <div className=\"space-y-2\">\n                            <label className=\"glass-field-label block\">\n                                {t('location.name')} <span className=\"text-[var(--glass-tone-danger-fg)]\">*</span>\n                            </label>\n                            <input\n                                type=\"text\"\n                                value={name}\n                                onChange={(e) => setName(e.target.value)}\n                                placeholder={t('location.namePlaceholder')}\n                                className=\"glass-input-base w-full px-3 py-2 text-sm\"\n                            />\n                        </div>\n\n                        {mode === 'asset-hub' && (\n                            <div className=\"space-y-2\">\n                                <label className=\"glass-field-label block\">\n                                    {t('artStyle.title')}\n                                </label>\n                                <div className=\"grid grid-cols-2 gap-2\">\n                                    {ART_STYLES.map((style) => (\n                                        <button\n                                            key={style.value}\n                                            type=\"button\"\n                                            onClick={() => setArtStyle(style.value)}\n                                            className={`glass-btn-base px-3 py-2 rounded-lg text-sm border transition-all justify-start ${artStyle === style.value\n                                                ? 'glass-btn-tone-info border-[var(--glass-stroke-focus)]'\n                                                : 'glass-btn-soft border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)]'\n                                                }`}\n                                        >\n                                            <span>{style.label}</span>\n                                        </button>\n                                    ))}\n                                </div>\n                            </div>\n                        )}\n\n                        {/* AI 设计区域 */}\n                        <div className=\"glass-surface-soft rounded-xl p-4 space-y-3 border border-[var(--glass-stroke-base)]\">\n                            <div className=\"flex items-center gap-2 text-sm font-medium text-[var(--glass-tone-info-fg)]\">\n                                <SparklesIcon className=\"w-4 h-4\" />\n                                <span>{t('aiDesign.title')} {t('common.optional')}</span>\n                            </div>\n                            <div className=\"flex gap-2\">\n                                <input\n                                    type=\"text\"\n                                    value={aiInstruction}\n                                    onChange={(e) => setAiInstruction(e.target.value)}\n                                    placeholder={t('aiDesign.placeholderLocation')}\n                                    className=\"glass-input-base flex-1 px-3 py-2 text-sm\"\n                                    disabled={isAiDesigning}\n                                    onKeyDown={(e) => {\n                                        if (e.key === 'Enter' && !e.shiftKey) {\n                                            e.preventDefault()\n                                            handleAiDesign()\n                                        }\n                                    }}\n                                />\n                                <button\n                                    onClick={handleAiDesign}\n                                    disabled={isAiDesigning || !aiInstruction.trim()}\n                                    className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm whitespace-nowrap\"\n                                >\n                                    {isAiDesigning ? (\n                                        <TaskStatusInline state={aiDesigningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                                    ) : (\n                                        <>\n                                            <SparklesIcon className=\"w-4 h-4\" />\n                                            <span>{t('aiDesign.generate')}</span>\n                                        </>\n                                    )}\n                                </button>\n                            </div>\n                            <p className=\"glass-field-hint\">\n                                {t('aiDesign.tip')}\n                            </p>\n                        </div>\n\n                        {/* 场景描述 */}\n                        <div className=\"space-y-2\">\n                            <label className=\"glass-field-label block\">\n                                {t('location.description')} <span className=\"text-[var(--glass-tone-danger-fg)]\">*</span>\n                            </label>\n                            <textarea\n                                value={description}\n                                onChange={(e) => setDescription(e.target.value)}\n                                placeholder={t('location.descPlaceholder')}\n                                className=\"glass-textarea-base w-full h-36 px-3 py-2 text-sm resize-none\"\n                                disabled={isAiDesigning}\n                            />\n                        </div>\n                    </div>\n                </div>\n\n                {/* 固定底部按钮区 */}\n                <div className=\"flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-xl flex-shrink-0\">\n                    <button\n                        onClick={onClose}\n                        className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm\"\n                        disabled={isSubmitting}\n                    >\n                        {t('common.cancel')}\n                    </button>\n                    <button\n                        onClick={handleSubmit}\n                        disabled={isSubmitting || !name.trim() || !description.trim()}\n                        className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm flex items-center gap-2\"\n                    >\n                        {isSubmitting ? (\n                            <TaskStatusInline state={submittingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                        ) : (\n                            <span>{mode === 'asset-hub' ? t('common.addOnlyToAssetHubLocation') : t('common.addOnlyLocation')}</span>\n                        )}\n                    </button>\n                    <ImageGenerationInlineCountButton\n                        prefix={<span>{t('common.addAndGeneratePrefix')}</span>}\n                        suffix={<span>{t('common.generateCountSuffix')}</span>}\n                        value={locationGenerationCount}\n                        options={getImageGenerationCountOptions('location')}\n                        onValueChange={setLocationGenerationCount}\n                        onClick={handleSubmitAndGenerate}\n                        actionDisabled={!name.trim() || !description.trim()}\n                        selectDisabled={isSubmitting}\n                        ariaLabel={t('common.selectGenerateCount')}\n                        className=\"glass-btn-base glass-btn-primary flex items-center justify-center gap-1 rounded-lg px-4 py-2 text-sm disabled:opacity-40 disabled:cursor-not-allowed\"\n                        selectClassName=\"appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-current outline-none cursor-pointer leading-none transition-colors\"\n                    />\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/components/shared/assets/LocationEditModal.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport { shouldShowError } from '@/lib/error-utils'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport {\n    useAiModifyLocationDescription,\n    useAiModifyProjectLocationDescription,\n    useUpdateLocationName,\n    useUpdateLocationSummary,\n    useUpdateProjectLocationDescription,\n    useUpdateProjectLocationName,\n} from '@/lib/query/hooks'\n\nexport interface LocationEditModalProps {\n    mode: 'asset-hub' | 'project'\n    locationId: string\n    locationName: string\n    description: string\n    summary?: string\n    imageIndex?: number\n    projectId?: string\n    descriptionIndex?: number\n    isTaskRunning?: boolean\n    onClose: () => void\n    onSave: (locationId: string) => void\n    onUpdate?: (newDescription: string) => void\n    onNameUpdate?: (newName: string) => void\n    onRefresh?: () => void\n}\n\nexport function LocationEditModal({\n    mode,\n    locationId,\n    locationName,\n    description,\n    summary,\n    imageIndex,\n    projectId,\n    descriptionIndex,\n    isTaskRunning = false,\n    onClose,\n    onSave,\n    onUpdate,\n    onNameUpdate,\n    onRefresh,\n}: LocationEditModalProps) {\n    const t = useTranslations('assets')\n\n    const resolvedImageIndex = mode === 'asset-hub'\n        ? (imageIndex ?? 0)\n        : (descriptionIndex ?? 0)\n\n    const [editingName, setEditingName] = useState(locationName)\n    const [editingDescription, setEditingDescription] = useState(description || summary || '')\n    const [aiModifyInstruction, setAiModifyInstruction] = useState('')\n    const [isAiModifying, setIsAiModifying] = useState(false)\n    const [isSaving, setIsSaving] = useState(false)\n    const aiModifyingState = isAiModifying\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'modify',\n            resource: 'image',\n            hasOutput: true,\n        })\n        : null\n    const savingState = isSaving\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'process',\n            resource: 'text',\n            hasOutput: false,\n        })\n        : null\n    const taskRunningState = isTaskRunning\n        ? resolveTaskPresentationState({\n            phase: 'processing',\n            intent: 'modify',\n            resource: 'image',\n            hasOutput: true,\n        })\n        : null\n\n    const updateAssetHubName = useUpdateLocationName()\n    const updateProjectName = useUpdateProjectLocationName(projectId ?? '')\n    const updateAssetHubSummary = useUpdateLocationSummary()\n    const updateProjectDescription = useUpdateProjectLocationDescription(projectId ?? '')\n    const aiModifyAssetHub = useAiModifyLocationDescription()\n    const aiModifyProject = useAiModifyProjectLocationDescription(projectId ?? '')\n\n    const getErrorMessage = (error: unknown, fallback: string) => {\n        if (error instanceof Error && error.message) return error.message\n        return fallback\n    }\n\n    const persistNameIfNeeded = async () => {\n        const nextName = editingName.trim()\n        if (!nextName || nextName === locationName) return\n\n        if (mode === 'asset-hub') {\n            await updateAssetHubName.mutateAsync({ locationId, name: nextName })\n        } else {\n            await updateProjectName.mutateAsync({ locationId, name: nextName })\n        }\n        onNameUpdate?.(nextName)\n    }\n\n    const persistDescription = async () => {\n        if (mode === 'asset-hub') {\n            await updateAssetHubSummary.mutateAsync({\n                locationId,\n                summary: editingDescription,\n            })\n            return\n        }\n\n        await updateProjectDescription.mutateAsync({\n            locationId,\n            imageIndex: resolvedImageIndex,\n            description: editingDescription,\n        })\n    }\n\n    const handleAiModify = async () => {\n        if (!aiModifyInstruction.trim()) return\n\n        try {\n            setIsAiModifying(true)\n\n            if (mode === 'asset-hub') {\n                const data = await aiModifyAssetHub.mutateAsync({\n                    locationId,\n                    imageIndex: resolvedImageIndex,\n                    currentDescription: editingDescription,\n                    modifyInstruction: aiModifyInstruction,\n                })\n                if (data?.modifiedDescription) {\n                    setEditingDescription(data.modifiedDescription)\n                    onUpdate?.(data.modifiedDescription)\n                    setAiModifyInstruction('')\n                }\n                return\n            }\n\n            const data = await aiModifyProject.mutateAsync({\n                locationId,\n                imageIndex: resolvedImageIndex,\n                currentDescription: editingDescription,\n                modifyInstruction: aiModifyInstruction,\n            })\n            const nextDescription = data?.modifiedDescription || data?.prompt || ''\n            if (nextDescription) {\n                setEditingDescription(nextDescription)\n                onUpdate?.(nextDescription)\n                setAiModifyInstruction('')\n            }\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                alert(`${t('modal.modifyFailed')}: ${getErrorMessage(error, t('errors.failed'))}`)\n            }\n        } finally {\n            setIsAiModifying(false)\n        }\n    }\n\n    const handleSaveName = async () => {\n        try {\n            await persistNameIfNeeded()\n            onRefresh?.()\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                alert(t('modal.saveName') + t('errors.failed'))\n            }\n        }\n    }\n\n    const handleSaveOnly = async () => {\n        try {\n            setIsSaving(true)\n            await persistNameIfNeeded()\n            await persistDescription()\n\n            onUpdate?.(editingDescription)\n            onRefresh?.()\n            onClose()\n        } catch (error: unknown) {\n            if (shouldShowError(error)) {\n                alert(getErrorMessage(error, t('errors.saveFailed')))\n            }\n        } finally {\n            setIsSaving(false)\n        }\n    }\n\n    const handleSaveAndGenerate = async () => {\n        const savedDescription = editingDescription\n        onClose()\n\n        ; (async () => {\n            try {\n                await persistNameIfNeeded()\n                await persistDescription()\n                onUpdate?.(savedDescription)\n                onRefresh?.()\n                onSave(locationId)\n            } catch (error: unknown) {\n                if (shouldShowError(error)) {\n                    alert(getErrorMessage(error, t('errors.saveFailed')))\n                }\n            }\n        })()\n    }\n\n    return (\n        <div className=\"fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4\">\n            <div className=\"glass-surface-modal max-w-2xl w-full max-h-[80vh] flex flex-col\">\n                <div className=\"p-6 space-y-4 overflow-y-auto flex-1\">\n                    <div className=\"flex items-center justify-between\">\n                        <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                            {t('modal.editLocation')} - {locationName}\n                        </h3>\n                        <button\n                            onClick={onClose}\n                            className=\"glass-btn-base glass-btn-soft w-9 h-9 rounded-full text-[var(--glass-text-tertiary)]\"\n                        >\n                            <AppIcon name=\"close\" className=\"w-6 h-6\" />\n                        </button>\n                    </div>\n\n                    <div className=\"space-y-2\">\n                        <label className=\"glass-field-label block\">\n                            {t('location.name')}\n                        </label>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={editingName}\n                                onChange={(e) => setEditingName(e.target.value)}\n                                className=\"glass-input-base flex-1 px-3 py-2\"\n                                placeholder={t('modal.namePlaceholder')}\n                            />\n                            {editingName !== locationName && (\n                                <button\n                                    onClick={handleSaveName}\n                                    disabled={updateAssetHubName.isPending || updateProjectName.isPending || !editingName.trim()}\n                                    className=\"glass-btn-base glass-btn-tone-success px-3 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm whitespace-nowrap\"\n                                >\n                                    {(updateAssetHubName.isPending || updateProjectName.isPending)\n                                        ? t('smartImport.preview.saving')\n                                        : t('modal.saveName')}\n                                </button>\n                            )}\n                        </div>\n                    </div>\n\n                    <div className=\"space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]\">\n                        <label className=\"block text-sm font-medium text-[var(--glass-tone-info-fg)] flex items-center gap-2\">\n                            <AppIcon name=\"bolt\" className=\"w-4 h-4\" />\n                            {t('modal.smartModify')}\n                        </label>\n                        <div className=\"flex gap-2\">\n                            <input\n                                type=\"text\"\n                                value={aiModifyInstruction}\n                                onChange={(e) => setAiModifyInstruction(e.target.value)}\n                                placeholder={t('modal.modifyPlaceholder')}\n                                className=\"glass-input-base flex-1 px-3 py-2\"\n                                disabled={isAiModifying}\n                                onKeyDown={(e) => {\n                                    if (e.key === 'Enter' && !e.shiftKey) {\n                                        e.preventDefault()\n                                        handleAiModify()\n                                    }\n                                }}\n                            />\n                            <button\n                                onClick={handleAiModify}\n                                disabled={isAiModifying || !aiModifyInstruction.trim()}\n                                className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap\"\n                            >\n                                {isAiModifying ? (\n                                    <TaskStatusInline state={aiModifyingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                                ) : (\n                                    <>\n                                        <AppIcon name=\"bolt\" className=\"w-4 h-4\" />\n                                        {t('modal.smartModify')}\n                                    </>\n                                )}\n                            </button>\n                        </div>\n                        <p className=\"glass-field-hint\">\n                            {t('modal.aiLocationTip')}\n                        </p>\n                    </div>\n\n                    <div className=\"space-y-2\">\n                        <label className=\"glass-field-label block\">\n                            {t('location.description')}\n                        </label>\n                        <textarea\n                            value={editingDescription}\n                            onChange={(e) => setEditingDescription(e.target.value)}\n                            className=\"glass-textarea-base w-full h-48 px-3 py-2 resize-none\"\n                            placeholder={t('modal.descPlaceholder')}\n                            disabled={isAiModifying}\n                        />\n                    </div>\n                </div>\n\n                <div className=\"flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0\">\n                    <button\n                        onClick={onClose}\n                        className=\"glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg\"\n                        disabled={isSaving}\n                    >\n                        {t('common.cancel')}\n                    </button>\n                    <button\n                        onClick={handleSaveOnly}\n                        disabled={isSaving || !editingDescription.trim()}\n                        className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                    >\n                        {isSaving ? (\n                            <TaskStatusInline state={savingState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                        ) : (\n                            t('modal.saveOnly')\n                        )}\n                    </button>\n                    <button\n                        onClick={handleSaveAndGenerate}\n                        disabled={isSaving || isTaskRunning || !editingDescription.trim()}\n                        className=\"glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n                    >\n                        {isTaskRunning ? (\n                            <TaskStatusInline state={taskRunningState} className=\"text-white [&>span]:text-white [&_svg]:text-white\" />\n                        ) : (\n                            t('modal.saveAndGenerate')\n                        )}\n                    </button>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/components/shared/assets/character-creation/CharacterCreationForm.tsx",
    "content": "'use client'\n\nimport type { DragEvent, RefObject } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { ART_STYLES } from '@/lib/constants'\nimport CharacterCreationPreview from './CharacterCreationPreview'\nimport { AppIcon } from '@/components/ui/icons'\nimport { SegmentedControl } from '@/components/ui/SegmentedControl'\n\ntype Mode = 'asset-hub' | 'project'\n\ninterface AvailableCharacter {\n  id: string\n  name: string\n  appearances: unknown[]\n}\n\ninterface CharacterCreationFormProps {\n  mode: Mode\n  createMode: 'reference' | 'description'\n  setCreateMode: (mode: 'reference' | 'description') => void\n  name: string\n  setName: (value: string) => void\n  description: string\n  setDescription: (value: string) => void\n  aiInstruction: string\n  setAiInstruction: (value: string) => void\n  artStyle: string\n  setArtStyle: (value: string) => void\n  referenceImagesBase64: string[]\n  referenceSubMode: 'direct' | 'extract'\n  setReferenceSubMode: (mode: 'direct' | 'extract') => void\n  isSubAppearance: boolean\n  setIsSubAppearance: (value: boolean) => void\n  selectedCharacterId: string\n  setSelectedCharacterId: (value: string) => void\n  changeReason: string\n  setChangeReason: (value: string) => void\n  availableCharacters: AvailableCharacter[]\n  fileInputRef: RefObject<HTMLInputElement | null>\n  handleDrop: (event: DragEvent<HTMLDivElement>) => void\n  handleFileSelect: (files: FileList) => void\n  handleClearReference: (index?: number) => void\n  handleExtractDescription: () => void\n  handleAiDesign: () => void\n  isSubmitting: boolean\n  isAiDesigning: boolean\n  isExtracting: boolean\n}\n\nconst SparklesIcon = ({ className }: { className?: string }) => (\n  <AppIcon name=\"sparklesAlt\" className={className} />\n)\n\nconst PhotoIcon = ({ className }: { className?: string }) => (\n  <AppIcon name=\"image\" className={className} />\n)\n\nexport default function CharacterCreationForm({\n  mode,\n  createMode,\n  setCreateMode,\n  name,\n  setName,\n  description,\n  setDescription,\n  aiInstruction,\n  setAiInstruction,\n  artStyle,\n  setArtStyle,\n  referenceImagesBase64,\n  referenceSubMode,\n  setReferenceSubMode,\n  isSubAppearance,\n  setIsSubAppearance,\n  selectedCharacterId,\n  setSelectedCharacterId,\n  changeReason,\n  setChangeReason,\n  availableCharacters,\n  fileInputRef,\n  handleDrop,\n  handleFileSelect,\n  handleClearReference,\n  handleExtractDescription,\n  handleAiDesign,\n  isSubmitting,\n  isAiDesigning,\n  isExtracting,\n}: CharacterCreationFormProps) {\n  const t = useTranslations('assetModal')\n\n  return (\n    <div className=\"space-y-5\">\n      <div className=\"mb-5\">\n        <SegmentedControl\n          options={[\n            { value: 'description', label: <><SparklesIcon className=\"w-4 h-4\" /><span>{t('character.modeDescription')}</span></> },\n            { value: 'reference', label: <><PhotoIcon className=\"w-4 h-4\" /><span>{t('character.modeReference')}</span></> },\n          ]}\n          value={createMode}\n          onChange={(val) => setCreateMode(val as 'reference' | 'description')}\n        />\n      </div>\n\n      {mode === 'project' && availableCharacters.length > 0 && (\n        <div className=\"flex items-start gap-3 p-3 glass-surface-soft rounded-lg border border-[var(--glass-stroke-base)]\">\n          <input\n            type=\"checkbox\"\n            id=\"isSubAppearance\"\n            checked={isSubAppearance}\n            onChange={(e) => setIsSubAppearance(e.target.checked)}\n            className=\"mt-0.5 w-4 h-4 rounded border-[var(--glass-stroke-base)] text-[var(--glass-tone-info-fg)]\"\n          />\n          <label htmlFor=\"isSubAppearance\" className=\"flex-1 text-sm cursor-pointer\">\n            <span className=\"font-medium text-[var(--glass-text-primary)]\">{t('character.isSubAppearance')}</span>\n            <p className=\"text-xs text-[var(--glass-text-secondary)] mt-0.5\">{t('character.isSubAppearanceHint')}</p>\n          </label>\n        </div>\n      )}\n\n      {isSubAppearance && (\n        <div className=\"space-y-2\">\n          <label className=\"glass-field-label block\">\n            {t('character.selectMainCharacter')} <span className=\"text-[var(--glass-tone-danger-fg)]\">*</span>\n          </label>\n          <select\n            value={selectedCharacterId}\n            onChange={(e) => setSelectedCharacterId(e.target.value)}\n            className=\"glass-select-base w-full px-3 py-2 text-sm\"\n          >\n            <option value=\"\">{t('character.selectCharacterPlaceholder')}</option>\n            {availableCharacters.map((char) => (\n              <option key={char.id} value={char.id}>\n                {char.name} ({t('character.appearancesCount', { count: char.appearances.length })})\n              </option>\n            ))}\n          </select>\n        </div>\n      )}\n\n      {isSubAppearance && (\n        <div className=\"space-y-2\">\n          <label className=\"glass-field-label block\">\n            {t('character.changeReason')} <span className=\"text-[var(--glass-tone-danger-fg)]\">*</span>\n          </label>\n          <input\n            type=\"text\"\n            value={changeReason}\n            onChange={(e) => setChangeReason(e.target.value)}\n            placeholder={t('character.changeReasonPlaceholder')}\n            className=\"glass-input-base w-full px-3 py-2 text-sm\"\n          />\n        </div>\n      )}\n\n      {!isSubAppearance && (\n        <div className=\"space-y-2\">\n          <label className=\"glass-field-label block\">\n            {t('character.name')} <span className=\"text-[var(--glass-tone-danger-fg)]\">*</span>\n          </label>\n          <input\n            type=\"text\"\n            value={name}\n            onChange={(e) => setName(e.target.value)}\n            placeholder={t('character.namePlaceholder')}\n            className=\"glass-input-base w-full px-3 py-2 text-sm\"\n          />\n        </div>\n      )}\n\n      {mode === 'asset-hub' && !isSubAppearance && (\n        <div className=\"space-y-2\">\n          <label className=\"glass-field-label block\">\n            {t('artStyle.title')}\n          </label>\n          <div className=\"grid grid-cols-2 gap-2\">\n            {ART_STYLES.map((style) => (\n              <button\n                key={style.value}\n                type=\"button\"\n                onClick={() => setArtStyle(style.value)}\n                className={`glass-btn-base px-3 py-2 rounded-lg text-sm border transition-all justify-start ${artStyle === style.value\n                  ? 'glass-btn-tone-info border-[var(--glass-stroke-focus)]'\n                  : 'glass-btn-soft border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)]'\n                  }`}\n              >\n                <span>{style.label}</span>\n              </button>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {createMode === 'reference' && (\n        <div className=\"glass-surface-soft rounded-xl p-4 space-y-3 border border-[var(--glass-stroke-base)]\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2 text-sm font-medium text-[var(--glass-tone-info-fg)]\">\n              <PhotoIcon className=\"w-4 h-4\" />\n              <span>{t('character.uploadReference')}</span>\n            </div>\n            <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('character.pasteHint')}</span>\n          </div>\n\n          <div className=\"glass-surface flex items-center gap-2 p-2 rounded-lg\">\n            <span className=\"text-xs text-[var(--glass-text-secondary)] shrink-0\">{t('character.generationMode')}：</span>\n            <SegmentedControl\n              className=\"flex-1\"\n              options={[\n                { value: 'direct', label: t('character.directGenerate') },\n                { value: 'extract', label: t('character.extractPrompt') },\n              ]}\n              value={referenceSubMode}\n              onChange={(val) => setReferenceSubMode(val as 'direct' | 'extract')}\n            />\n          </div>\n\n          {referenceSubMode === 'extract' && (\n            <button\n              onClick={handleExtractDescription}\n              disabled={isExtracting || referenceImagesBase64.length === 0}\n              className=\"glass-btn-base glass-btn-tone-info w-full px-3 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm\"\n            >\n              {isExtracting ? t('aiDesign.generating') : t('character.extractFirst')}\n            </button>\n          )}\n\n          <CharacterCreationPreview\n            referenceImagesBase64={referenceImagesBase64}\n            fileInputRef={fileInputRef}\n            onDrop={handleDrop}\n            onFileSelect={handleFileSelect}\n            onClearReference={handleClearReference}\n          />\n\n        </div>\n      )}\n\n      {createMode === 'description' && (\n        <>\n          {!isSubAppearance && (\n            <div className=\"glass-surface-soft rounded-xl p-4 space-y-3 border border-[var(--glass-stroke-base)]\">\n              <div className=\"flex items-center gap-2 text-sm font-medium text-[var(--glass-tone-info-fg)]\">\n                <SparklesIcon className=\"w-4 h-4\" />\n                <span>{t('aiDesign.title')}</span>\n              </div>\n              <div className=\"flex gap-2\">\n                <input\n                  type=\"text\"\n                  value={aiInstruction}\n                  onChange={(e) => setAiInstruction(e.target.value)}\n                  placeholder={t('aiDesign.placeholder')}\n                  className=\"glass-input-base flex-1 px-3 py-2 text-sm\"\n                  disabled={isAiDesigning}\n                  onKeyDown={(e) => {\n                    if (e.key === 'Enter' && !e.shiftKey) {\n                      e.preventDefault()\n                      handleAiDesign()\n                    }\n                  }}\n                />\n                <button\n                  onClick={handleAiDesign}\n                  disabled={isAiDesigning || !aiInstruction.trim()}\n                  className=\"glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm whitespace-nowrap\"\n                >\n                  {isAiDesigning ? t('aiDesign.generating') : t('aiDesign.generate')}\n                </button>\n              </div>\n            </div>\n          )}\n\n          <div className=\"space-y-2\">\n            <label className=\"glass-field-label block\">\n              {isSubAppearance ? t('character.modifyDescription') : t('character.description')} <span className=\"text-[var(--glass-tone-danger-fg)]\">*</span>\n            </label>\n            <textarea\n              value={description}\n              onChange={(e) => setDescription(e.target.value)}\n              rows={4}\n              placeholder={isSubAppearance\n                ? t('character.modifyDescriptionPlaceholder')\n                : t('character.descPlaceholder')}\n              className=\"glass-textarea-base w-full px-3 py-2 text-sm resize-none\"\n            />\n          </div>\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/shared/assets/character-creation/CharacterCreationPreview.tsx",
    "content": "'use client'\n\nimport type { DragEvent, RefObject } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface CharacterCreationPreviewProps {\n  referenceImagesBase64: string[]\n  fileInputRef: RefObject<HTMLInputElement | null>\n  onDrop: (event: DragEvent<HTMLDivElement>) => void\n  onFileSelect: (files: FileList) => void\n  onClearReference: (index?: number) => void\n}\n\nconst PhotoIcon = ({ className }: { className?: string }) => (\n  <AppIcon name=\"image\" className={className} />\n)\n\nexport default function CharacterCreationPreview({\n  referenceImagesBase64,\n  fileInputRef,\n  onDrop,\n  onFileSelect,\n  onClearReference,\n}: CharacterCreationPreviewProps) {\n  const t = useTranslations('assetModal')\n\n  return (\n    <div\n      className=\"border-2 border-dashed border-[var(--glass-stroke-base)] rounded-lg p-4 flex flex-col items-center justify-center cursor-pointer hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-all relative min-h-[120px]\"\n      onDrop={onDrop}\n      onDragOver={(e) => { e.preventDefault(); e.stopPropagation() }}\n      onClick={() => fileInputRef.current?.click()}\n    >\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        accept=\"image/*\"\n        multiple\n        className=\"hidden\"\n        onChange={(e) => e.target.files && onFileSelect(e.target.files)}\n      />\n\n      {referenceImagesBase64.length > 0 ? (\n        <div className=\"w-full\">\n          <div className=\"grid grid-cols-3 gap-2 mb-2\">\n            {referenceImagesBase64.map((base64, index) => (\n              <div key={index} className=\"relative aspect-square\">\n                <MediaImageWithLoading\n                  src={base64}\n                  alt={`参考图 ${index + 1}`}\n                  containerClassName=\"w-full h-full rounded\"\n                  className=\"w-full h-full object-cover rounded\"\n                />\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    onClearReference(index)\n                  }}\n                  className=\"glass-btn-base glass-btn-tone-danger absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs\"\n                >\n                  ×\n                </button>\n              </div>\n            ))}\n          </div>\n          <p className=\"text-xs text-center text-[var(--glass-text-secondary)]\">\n            {t('character.selectedCount', { count: referenceImagesBase64.length })}\n          </p>\n        </div>\n      ) : (\n        <>\n          <PhotoIcon className=\"w-10 h-10 text-[var(--glass-text-tertiary)] mb-2\" />\n          <p className=\"text-sm text-[var(--glass-text-secondary)]\">{t('character.dropOrClick')}</p>\n          <p className=\"text-xs text-[var(--glass-text-tertiary)] mt-1\">{t('character.maxReferenceImages')}</p>\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/shared/assets/character-creation/hooks/useCharacterCreationSubmit.ts",
    "content": "'use client'\n\nimport { useCallback, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { shouldShowError } from '@/lib/error-utils'\nimport {\n  useAiCreateProjectCharacter,\n  useAiDesignCharacter,\n  useCreateAssetHubCharacter,\n  useCreateProjectCharacter,\n  useGenerateCharacterImage,\n  useGenerateProjectCharacterImage,\n  useCreateProjectCharacterAppearance,\n  useExtractAssetHubReferenceCharacterDescription,\n  useExtractProjectReferenceCharacterDescription,\n  useUploadAssetHubTempMedia,\n  useUploadProjectTempMedia,\n} from '@/lib/query/hooks'\nimport { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'\n\ntype Mode = 'asset-hub' | 'project'\n\ninterface UseCharacterCreationSubmitParams {\n  mode: Mode\n  folderId?: string | null\n  projectId?: string\n  name: string\n  description: string\n  aiInstruction: string\n  artStyle: string\n  referenceImagesBase64: string[]\n  referenceSubMode: 'direct' | 'extract'\n  isSubAppearance: boolean\n  selectedCharacterId: string\n  changeReason: string\n  setDescription: (value: string) => void\n  setAiInstruction: (value: string) => void\n  onSuccess: () => void\n  onClose: () => void\n}\n\nconst getErrorMessage = (error: unknown, fallback: string) => {\n  if (error instanceof Error && error.message) return error.message\n  return fallback\n}\n\nexport function useCharacterCreationSubmit({\n  mode,\n  folderId,\n  projectId,\n  name,\n  description,\n  aiInstruction,\n  artStyle,\n  referenceImagesBase64,\n  referenceSubMode,\n  isSubAppearance,\n  selectedCharacterId,\n  changeReason,\n  setDescription,\n  setAiInstruction,\n  onSuccess,\n  onClose,\n}: UseCharacterCreationSubmitParams) {\n  const t = useTranslations('assetModal')\n  const [isSubmitting, setIsSubmitting] = useState(false)\n  const [isAiDesigning, setIsAiDesigning] = useState(false)\n  const [isExtracting, setIsExtracting] = useState(false)\n\n  const uploadAssetHubTemp = useUploadAssetHubTempMedia()\n  const uploadProjectTemp = useUploadProjectTempMedia()\n  const aiDesignAssetHubCharacter = useAiDesignCharacter()\n  const aiCreateProjectCharacter = useAiCreateProjectCharacter(projectId ?? '')\n  const extractAssetHubDescription = useExtractAssetHubReferenceCharacterDescription()\n  const extractProjectDescription = useExtractProjectReferenceCharacterDescription(projectId ?? '')\n  const createAssetHubCharacter = useCreateAssetHubCharacter()\n  const createProjectCharacter = useCreateProjectCharacter(projectId ?? '')\n  const generateAssetHubCharacterImage = useGenerateCharacterImage()\n  const generateProjectCharacterImage = useGenerateProjectCharacterImage(projectId ?? '')\n  const createProjectAppearance = useCreateProjectCharacterAppearance(projectId ?? '')\n  const {\n    count: characterGenerationCount,\n    setCount: setCharacterGenerationCount,\n  } = useImageGenerationCount('character')\n  const {\n    count: referenceCharacterGenerationCount,\n    setCount: setReferenceCharacterGenerationCount,\n  } = useImageGenerationCount('reference-to-character')\n\n  type CreatedCharacterResponse = {\n    character?: {\n      id: string\n      appearances?: Array<{\n        id: string\n        appearanceIndex: number\n      }>\n    }\n  }\n\n  const uploadReferenceImages = useCallback(async () => {\n    const uploadMutation = mode === 'asset-hub' ? uploadAssetHubTemp : uploadProjectTemp\n    return Promise.all(\n      referenceImagesBase64.map(async (base64) => {\n        const data = await uploadMutation.mutateAsync({ imageBase64: base64 })\n        if (!data.url) throw new Error(t('errors.uploadFailed'))\n        return data.url\n      }),\n    )\n  }, [mode, referenceImagesBase64, t, uploadAssetHubTemp, uploadProjectTemp])\n\n  const handleExtractDescription = useCallback(async () => {\n    if (referenceImagesBase64.length === 0) return\n\n    try {\n      setIsExtracting(true)\n      const referenceImageUrls = await uploadReferenceImages()\n      const result = mode === 'asset-hub'\n        ? await extractAssetHubDescription.mutateAsync(referenceImageUrls)\n        : await extractProjectDescription.mutateAsync(referenceImageUrls)\n      if (result?.description) {\n        setDescription(result.description)\n      }\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(getErrorMessage(error, t('errors.extractDescriptionFailed')))\n      }\n    } finally {\n      setIsExtracting(false)\n    }\n  }, [\n    extractAssetHubDescription,\n    extractProjectDescription,\n    mode,\n    referenceImagesBase64.length,\n    setDescription,\n    t,\n    uploadReferenceImages,\n  ])\n\n  const handleCreateWithReference = useCallback(async () => {\n    if (!name.trim() || referenceImagesBase64.length === 0) return\n\n    try {\n      setIsSubmitting(true)\n      const referenceImageUrls = await uploadReferenceImages()\n\n      let finalDescription = description.trim()\n      if (referenceSubMode === 'extract') {\n        const result = mode === 'asset-hub'\n          ? await extractAssetHubDescription.mutateAsync(referenceImageUrls)\n          : await extractProjectDescription.mutateAsync(referenceImageUrls)\n        finalDescription = result?.description || finalDescription\n      }\n\n      if (mode === 'asset-hub') {\n        await createAssetHubCharacter.mutateAsync({\n          name: name.trim(),\n          description: finalDescription || t('character.defaultDescription', { name: name.trim() }),\n          folderId: folderId ?? null,\n          artStyle,\n          generateFromReference: true,\n          referenceImageUrls,\n          customDescription: referenceSubMode === 'extract' ? finalDescription : undefined,\n          count: referenceCharacterGenerationCount,\n        })\n      } else {\n        await createProjectCharacter.mutateAsync({\n          name: name.trim(),\n          description: finalDescription || t('character.defaultDescription', { name: name.trim() }),\n          generateFromReference: true,\n          referenceImageUrls,\n          customDescription: referenceSubMode === 'extract' ? finalDescription : undefined,\n          count: referenceCharacterGenerationCount,\n        })\n      }\n\n      onSuccess()\n      onClose()\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(getErrorMessage(error, t('errors.createFailed')))\n      }\n    } finally {\n      setIsSubmitting(false)\n    }\n  }, [\n    artStyle,\n    createAssetHubCharacter,\n    createProjectCharacter,\n    description,\n    extractAssetHubDescription,\n    extractProjectDescription,\n    folderId,\n    mode,\n    name,\n    onClose,\n    onSuccess,\n    referenceImagesBase64.length,\n    referenceSubMode,\n    t,\n    uploadReferenceImages,\n  ])\n\n  const handleAiDesign = useCallback(async () => {\n    if (!aiInstruction.trim()) return\n\n    try {\n      setIsAiDesigning(true)\n      const result = mode === 'asset-hub'\n        ? await aiDesignAssetHubCharacter.mutateAsync(aiInstruction)\n        : await aiCreateProjectCharacter.mutateAsync({ userInstruction: aiInstruction })\n\n      if (result?.prompt) {\n        setDescription(result.prompt)\n        setAiInstruction('')\n      }\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(getErrorMessage(error, t('errors.aiDesignFailed')))\n      }\n    } finally {\n      setIsAiDesigning(false)\n    }\n  }, [aiCreateProjectCharacter, aiDesignAssetHubCharacter, aiInstruction, mode, setAiInstruction, setDescription, t])\n\n  const handleSubmit = useCallback(async () => {\n    if (isSubAppearance) {\n      if (!selectedCharacterId.trim() || !changeReason.trim() || !description.trim()) return\n      try {\n        setIsSubmitting(true)\n        await createProjectAppearance.mutateAsync({\n          characterId: selectedCharacterId,\n          changeReason: changeReason.trim(),\n          description: description.trim(),\n        })\n        onSuccess()\n        onClose()\n      } catch (error: unknown) {\n        if (shouldShowError(error)) {\n          alert(getErrorMessage(error, t('errors.addSubAppearanceFailed')))\n        }\n      } finally {\n        setIsSubmitting(false)\n      }\n      return\n    }\n\n    if (!name.trim() || !description.trim()) return\n    try {\n      setIsSubmitting(true)\n      if (mode === 'asset-hub') {\n        await createAssetHubCharacter.mutateAsync({\n          name: name.trim(),\n          description: description.trim(),\n          folderId: folderId ?? null,\n          artStyle,\n        })\n      } else {\n        await createProjectCharacter.mutateAsync({\n          name: name.trim(),\n          description: description.trim(),\n        })\n      }\n      onSuccess()\n      onClose()\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(getErrorMessage(error, t('errors.createFailed')))\n      }\n    } finally {\n      setIsSubmitting(false)\n    }\n  }, [\n    artStyle,\n    changeReason,\n    createAssetHubCharacter,\n    createProjectAppearance,\n    createProjectCharacter,\n    description,\n    folderId,\n    isSubAppearance,\n    mode,\n    name,\n    onClose,\n    onSuccess,\n    selectedCharacterId,\n    t,\n  ])\n\n  const handleSubmitAndGenerate = useCallback(async () => {\n    if (isSubAppearance) {\n      await handleSubmit()\n      return\n    }\n\n    if (!name.trim() || !description.trim()) return\n\n    try {\n      setIsSubmitting(true)\n\n      if (mode === 'asset-hub') {\n        const result = await createAssetHubCharacter.mutateAsync({\n          name: name.trim(),\n          description: description.trim(),\n          folderId: folderId ?? null,\n          artStyle,\n        }) as CreatedCharacterResponse\n        const createdCharacterId = result.character?.id\n        const createdAppearanceIndex = result.character?.appearances?.[0]?.appearanceIndex\n        if (!createdCharacterId || createdAppearanceIndex === undefined) {\n          throw new Error(t('errors.createFailed'))\n        }\n        await generateAssetHubCharacterImage.mutateAsync({\n          characterId: createdCharacterId,\n          appearanceIndex: createdAppearanceIndex,\n          artStyle,\n          count: characterGenerationCount,\n        })\n      } else {\n        const result = await createProjectCharacter.mutateAsync({\n          name: name.trim(),\n          description: description.trim(),\n        }) as CreatedCharacterResponse\n        const createdCharacterId = result.character?.id\n        const createdAppearanceId = result.character?.appearances?.[0]?.id\n        if (!createdCharacterId || !createdAppearanceId) {\n          throw new Error(t('errors.createFailed'))\n        }\n        await generateProjectCharacterImage.mutateAsync({\n          characterId: createdCharacterId,\n          appearanceId: createdAppearanceId,\n          count: characterGenerationCount,\n        })\n      }\n\n      onSuccess()\n      onClose()\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(getErrorMessage(error, t('errors.createFailed')))\n      }\n    } finally {\n      setIsSubmitting(false)\n    }\n  }, [\n    artStyle,\n    characterGenerationCount,\n    createAssetHubCharacter,\n    createProjectCharacter,\n    description,\n    folderId,\n    generateAssetHubCharacterImage,\n    generateProjectCharacterImage,\n    handleSubmit,\n    isSubAppearance,\n    mode,\n    name,\n    onClose,\n    onSuccess,\n    t,\n  ])\n\n  return {\n    isSubmitting,\n    isAiDesigning,\n    isExtracting,\n    characterGenerationCount,\n    setCharacterGenerationCount,\n    referenceCharacterGenerationCount,\n    setReferenceCharacterGenerationCount,\n    handleExtractDescription,\n    handleCreateWithReference,\n    handleAiDesign,\n    handleSubmit,\n    handleSubmitAndGenerate,\n  }\n}\n"
  },
  {
    "path": "src/components/shared/assets/index.ts",
    "content": "export { CharacterCreationModal } from './CharacterCreationModal'\nexport { LocationCreationModal } from './LocationCreationModal'\nexport { CharacterEditModal } from './CharacterEditModal'\nexport { LocationEditModal } from './LocationEditModal'\nexport type { CharacterCreationModalProps } from './CharacterCreationModal'\nexport type { LocationCreationModalProps } from './LocationCreationModal'\nexport type { CharacterEditModalProps } from './CharacterEditModal'\nexport type { LocationEditModalProps } from './LocationEditModal'\n\n"
  },
  {
    "path": "src/components/task/TaskStatusInline.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ntype TaskStatusInlineProps = {\n  state: TaskPresentationState | null\n  className?: string\n}\n\nexport default function TaskStatusInline({ state, className }: TaskStatusInlineProps) {\n  const t = useTranslations('common')\n  if (!state) return null\n  if (!state.isRunning && !state.isError) return null\n  const label = state.labelKey ? t(state.labelKey) : t('loading')\n\n  return (\n    <div className={['inline-flex items-center gap-1 text-xs', className || ''].join(' ').trim()}>\n      {state.isError ? (\n        <span className=\"text-[var(--glass-tone-danger-fg)]\">{label}</span>\n      ) : (\n        <>\n          <AppIcon name=\"loader\" className=\"h-3.5 w-3.5 animate-spin text-[var(--glass-tone-info-fg)]\" />\n          <span className=\"text-[var(--glass-text-secondary)]\">{label}</span>\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/task/TaskStatusOverlay.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\n\ntype TaskStatusOverlayProps = {\n  state: TaskPresentationState | null\n  className?: string\n}\n\nexport default function TaskStatusOverlay({ state, className }: TaskStatusOverlayProps) {\n  const t = useTranslations('common')\n  if (!state) return null\n  if (state.mode !== 'overlay' && state.mode !== 'placeholder') return null\n  const label = state.labelKey ? t(state.labelKey) : t('loading')\n\n  return (\n    <div\n      className={[\n        'absolute inset-0 flex flex-col items-center justify-center',\n        'bg-[var(--glass-overlay)]',\n        className || '',\n      ].join(' ').trim()}\n    >\n      {state.isError ? (\n        <AppIcon name=\"alertSolid\" className=\"h-7 w-7 text-[var(--glass-tone-danger-fg)]\" />\n      ) : (\n        <AppIcon name=\"loader\" className=\"h-7 w-7 animate-spin text-white\" />\n      )}\n      <span className=\"mt-2 text-xs text-white\">{label}</span>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/CapsuleNav.tsx",
    "content": "'use client'\n\nimport { useState, useRef, useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\n\ntype StepStatus = 'empty' | 'active' | 'processing' | 'ready'\n\ninterface NavItemData {\n    id: string\n    icon: string\n    label: string\n    status: StepStatus\n    href?: string  // 可选的链接地址\n    disabled?: boolean  // 是否禁用（开发中）\n    disabledLabel?: string  // 禁用时显示的提示文字\n}\n\ninterface CapsuleNavProps {\n    items: NavItemData[]\n    activeId: string\n    onItemClick: (id: string) => void\n    projectId?: string  // 用于构建链接\n    episodeId?: string  // 用于构建链接\n}\n\n/**\n * NavItem - 胶囊导航单项\n * 支持左键点击切换、中键/Ctrl+点击在新标签页打开\n */\nfunction NavItem({\n    active,\n    onClick,\n    label,\n    status,\n    href,\n    disabled,\n    disabledLabel\n}: {\n    active: boolean\n    onClick: () => void\n    label: string\n    status: StepStatus\n    href?: string\n    disabled?: boolean\n    disabledLabel?: string\n}) {\n    const handleClick = (e: React.MouseEvent) => {\n        if (disabled) return\n        if (e.button === 1 || e.ctrlKey || e.metaKey) {\n            if (href) {\n                window.open(href, '_blank')\n            }\n            return\n        }\n        onClick()\n    }\n\n    const handleAuxClick = (e: React.MouseEvent) => {\n        if (disabled) return\n        if (e.button === 1 && href) {\n            e.preventDefault()\n            window.open(href, '_blank')\n        }\n    }\n\n    return (\n        <div className=\"relative group\">\n            <button\n                onClick={handleClick}\n                onAuxClick={handleAuxClick}\n                disabled={disabled}\n                className={`\n                    relative flex min-h-[52px] items-center gap-1 px-6 pt-3.5 pb-4 transition-all duration-300 ease-out\n                    ${disabled\n                        ? 'cursor-not-allowed'\n                        : active\n                            ? 'text-[var(--glass-tone-info-fg)]'\n                            : 'text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-primary)]'}\n                    ${!disabled && 'active:scale-[0.98]'}\n                `}\n            >\n                {disabled ? (\n                    <span className=\"text-base font-medium text-[var(--glass-text-tertiary)] opacity-80\">\n                        {label}\n                    </span>\n                ) : (\n                    <span className=\"text-base font-semibold\">{label}</span>\n                )}\n                {/* 底部指示条 */}\n                <span className={`absolute bottom-1.5 left-1/2 -translate-x-1/2 h-[3px] rounded-full transition-all duration-300 ease-out\n                    ${active\n                        ? 'w-6 bg-gradient-to-r from-[var(--glass-accent-from)] to-[var(--glass-accent-to)] shadow-[0_2px_8px_var(--glass-accent-shadow-soft)]'\n                        : 'w-0 bg-transparent'\n                    }`}\n                />\n                {status === 'ready' && !disabled && (\n                    <span className={`absolute top-2 right-2 w-1.5 h-1.5 rounded-full transition-colors\n                        ${active ? 'bg-[var(--glass-tone-info-fg)]' : 'bg-[var(--glass-tone-success-fg)]'}`}\n                    />\n                )}\n                {status === 'processing' && !disabled && (\n                    <span className=\"absolute top-2 right-2 w-1.5 h-1.5 rounded-full bg-[var(--glass-accent-from)] animate-pulse\" />\n                )}\n            </button>\n            {disabled && disabledLabel && (\n                <div className=\"absolute left-1/2 -translate-x-1/2 top-full mt-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10\">\n                    <div className=\"glass-surface-soft text-xs px-3 py-2 whitespace-nowrap text-[var(--glass-text-primary)]\">\n                        {disabledLabel}\n                    </div>\n                    <div className=\"absolute left-1/2 -translate-x-1/2 -top-1 w-2 h-2 bg-[var(--glass-bg-surface-strong)] rotate-45 border-l border-t border-[var(--glass-stroke-base)]\" />\n                </div>\n            )}\n        </div>\n    )\n}\n\n\n/**\n * CapsuleNav - 胶囊形态悬浮导航\n * 支持中键和Ctrl+点击在新标签页打开\n */\nexport function CapsuleNav({ items, activeId, onItemClick, projectId, episodeId }: CapsuleNavProps) {\n    // 构建每个导航项的链接地址\n    const buildHref = (stageId: string): string | undefined => {\n        if (!projectId) return undefined\n        const params = new URLSearchParams()\n        params.set('stage', stageId)\n        if (episodeId) {\n            params.set('episode', episodeId)\n        }\n        return `/workspace/${projectId}?${params.toString()}`\n    }\n\n    return (\n        <nav className=\"fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-fadeInDown\">\n            <div\n                className=\"flex rounded-full px-2 py-1\"\n                style={{\n                    background: 'rgba(255,255,255,0.55)',\n                    backdropFilter: 'blur(24px) saturate(1.6)',\n                    WebkitBackdropFilter: 'blur(24px) saturate(1.6)',\n                    border: '1px solid rgba(255,255,255,0.45)',\n                    boxShadow: '0 8px 32px rgba(0,0,0,0.06), 0 1.5px 6px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.7)',\n                }}\n            >\n                {items.map((item) => (\n                    <NavItem\n                        key={item.id}\n                        active={activeId === item.id}\n                        onClick={() => onItemClick(item.id)}\n                        label={item.label}\n                        status={item.status}\n                        href={buildHref(item.id)}\n                        disabled={item.disabled}\n                        disabledLabel={item.disabledLabel}\n                    />\n                ))}\n            </div>\n        </nav>\n    )\n}\n\n/**\n * EpisodeSelector - 剧集选择器\n */\ninterface Episode {\n    id: string\n    title: string\n    summary?: string\n    status?: {\n        story?: StepStatus\n        script?: StepStatus\n        visual?: StepStatus\n    }\n}\n\ninterface EpisodeSelectorProps {\n    episodes: Episode[]\n    currentId: string\n    onSelect: (id: string) => void\n    onAdd?: () => void\n    onRename?: (id: string, newName: string) => void\n    onDelete?: (id: string) => void\n    projectName?: string  // 项目名称，显示在左上角\n}\n\nexport function EpisodeSelector({\n    episodes,\n    currentId,\n    onSelect,\n    onAdd,\n    onRename,\n    onDelete,\n    projectName\n}: EpisodeSelectorProps) {\n    const t = useTranslations('common')\n    const [isOpen, setIsOpen] = useState(false)\n    const [editingId, setEditingId] = useState<string | null>(null)\n    const [editingName, setEditingName] = useState('')\n    const [deletingId, setDeletingId] = useState<string | null>(null)\n    const currentEp = episodes.find(e => e.id === currentId) || episodes[0]\n    const menuRef = useRef<HTMLDivElement>(null)\n\n    useEffect(() => {\n        const handleClickOutside = (event: MouseEvent) => {\n            if (menuRef.current && !menuRef.current.contains(event.target as Node)) {\n                setIsOpen(false)\n            }\n        }\n        document.addEventListener('mousedown', handleClickOutside)\n        return () => document.removeEventListener('mousedown', handleClickOutside)\n    }, [])\n\n    if (!currentEp) return null\n\n    return (\n        <div className=\"fixed top-20 left-6 z-[60]\" ref={menuRef}>\n            <button\n                onClick={() => setIsOpen(!isOpen)}\n                className=\"glass-btn-base glass-btn-secondary flex items-center gap-3 px-4 py-3 transition-all group\"\n                style={{ borderRadius: '1.5rem' }}\n            >\n                <div className=\"glass-surface-soft flex h-10 w-10 items-center justify-center rounded-xl text-xs font-bold text-[var(--glass-tone-info-fg)]\">\n                    {t('episode')}\n                </div>\n                <div className=\"flex flex-col items-start text-left mr-2\">\n                    <span className=\"text-sm font-bold text-[var(--glass-text-primary)] line-clamp-1 max-w-[160px]\">\n                        {projectName || t('project')}\n                    </span>\n                    <span className=\"text-sm text-[var(--glass-text-secondary)] line-clamp-1 max-w-[160px]\">\n                        {currentEp.title}\n                    </span>\n                </div>\n                <AppIcon name=\"chevronDown\" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n            </button>\n\n            {isOpen && (\n                <div className=\"glass-surface-modal absolute left-0 top-full mt-2 w-72 origin-top-left p-2 animate-fadeIn\">\n                    <div className=\"max-h-[300px] overflow-y-auto custom-scrollbar space-y-1\">\n                        {episodes.map(ep => {\n                            const statusColor = ep.status?.visual === 'ready'\n                                ? 'bg-[var(--glass-tone-success-fg)]'\n                                : ep.status?.script === 'ready'\n                                    ? 'bg-[var(--glass-accent-from)]'\n                                    : 'bg-[var(--glass-stroke-strong)]'\n\n                            // 编辑模式\n                            if (editingId === ep.id) {\n                                return (\n                                    <div key={ep.id} className=\"flex items-center gap-2 p-3 rounded-xl bg-[var(--glass-tone-info-bg)] border border-[var(--glass-stroke-focus)]\">\n                                        <div className={`w-2 h-10 rounded-full ${statusColor}`} />\n                                        <input\n                                            type=\"text\"\n                                            value={editingName}\n                                            onChange={(e) => setEditingName(e.target.value)}\n                                            onKeyDown={(e) => {\n                                                if (e.key === 'Enter' && editingName.trim()) {\n                                                    onRename?.(ep.id, editingName.trim())\n                                                    setEditingId(null)\n                                                } else if (e.key === 'Escape') {\n                                                    setEditingId(null)\n                                                }\n                                            }}\n                                            className=\"flex-1 px-2 py-1 text-sm border border-[var(--glass-stroke-focus)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--glass-focus-ring-strong)]\"\n                                            autoFocus\n                                        />\n                                        <button\n                                            onClick={() => {\n                                                if (editingName.trim()) {\n                                                    onRename?.(ep.id, editingName.trim())\n                                                }\n                                                setEditingId(null)\n                                            }}\n                                            className=\"w-7 h-7 rounded-lg bg-[var(--glass-accent-from)] text-white hover:bg-[var(--glass-accent-to)] flex items-center justify-center\"\n                                        >\n                                            <AppIcon name=\"check\" className=\"w-4 h-4\" />\n                                        </button>\n                                        <button\n                                            onClick={() => setEditingId(null)}\n                                            className=\"w-7 h-7 rounded-lg bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-surface-strong)] flex items-center justify-center\"\n                                        >\n                                            <AppIcon name=\"close\" className=\"w-4 h-4\" />\n                                        </button>\n                                    </div>\n                                )\n                            }\n\n                            // 删除确认模式\n                            if (deletingId === ep.id) {\n                                return (\n                                    <div key={ep.id} className=\"flex items-center gap-2 p-3 rounded-xl bg-[var(--glass-tone-danger-bg)] border border-[var(--glass-tone-danger-fg)]/30\">\n                                        <div className=\"flex-1 text-sm font-medium text-[var(--glass-tone-danger-fg)] truncate\">\n                                            {t('deleteEpisode')}：{ep.title}\n                                        </div>\n                                        <button\n                                            onClick={() => {\n                                                onDelete?.(ep.id)\n                                                setDeletingId(null)\n                                                setIsOpen(false)\n                                            }}\n                                            className=\"px-2 py-1 rounded-lg bg-[var(--glass-tone-danger-fg)] text-white text-xs font-medium hover:opacity-90 transition-opacity\"\n                                        >\n                                            {t('deleteEpisodeConfirm')}\n                                        </button>\n                                        <button\n                                            onClick={() => setDeletingId(null)}\n                                            className=\"w-7 h-7 rounded-lg bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-surface-strong)] flex items-center justify-center\"\n                                        >\n                                            <AppIcon name=\"close\" className=\"w-4 h-4\" />\n                                        </button>\n                                    </div>\n                                )\n                            }\n\n                            return (\n                                <div\n                                    key={ep.id}\n                                    className={`w-full flex items-center gap-3 p-3 rounded-xl transition-all ${ep.id === currentId\n                                        ? 'bg-[var(--glass-tone-info-bg)] border border-[var(--glass-stroke-focus)]'\n                                        : 'hover:bg-[var(--glass-bg-muted)] border border-transparent'\n                                        }`}\n                                >\n                                    <button\n                                        onClick={() => { onSelect(ep.id); setIsOpen(false); }}\n                                        className=\"flex-1 flex items-center gap-3 text-left\"\n                                    >\n                                        <div className={`w-2 h-10 rounded-full ${statusColor}`} />\n                                        <div className=\"flex-1\">\n                                            <div className=\"font-bold text-[var(--glass-text-primary)] text-sm truncate\">{ep.title}</div>\n                                            {ep.summary && (\n                                                <div className=\"text-xs text-[var(--glass-text-tertiary)] truncate\">{ep.summary}</div>\n                                            )}\n                                        </div>\n                                        {ep.id === currentId && (\n                                            <span className=\"flex h-4 w-4 items-center justify-center rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]\">\n                                                <AppIcon name=\"checkDot\" className=\"h-2.5 w-2.5\" />\n                                            </span>\n                                        )}\n                                    </button>\n                                    {onRename && (\n                                        <button\n                                            onClick={(e) => {\n                                                e.stopPropagation()\n                                                setEditingId(ep.id)\n                                                setEditingName(ep.title)\n                                            }}\n                                            className=\"w-7 h-7 rounded-lg hover:bg-[var(--glass-bg-surface-strong)] flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors\"\n                                            title={t('editEpisodeName')}\n                                        >\n                                            <AppIcon name=\"edit\" className=\"w-4 h-4\" />\n                                        </button>\n                                    )}\n                                    {onDelete && (\n                                        <button\n                                            onClick={(e) => {\n                                                e.stopPropagation()\n                                                setDeletingId(ep.id)\n                                            }}\n                                            className=\"w-7 h-7 rounded-lg hover:bg-[var(--glass-tone-danger-bg)] flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-danger-fg)] transition-colors\"\n                                            title={t('deleteEpisode')}\n                                        >\n                                            <AppIcon name=\"trash\" className=\"w-4 h-4\" />\n                                        </button>\n                                    )}\n                                </div>\n                            )\n                        })}\n                    </div>\n                    {onAdd && (\n                        <>\n                            <div className=\"h-px bg-[var(--glass-bg-muted)] my-2 mx-2\" />\n                            <button\n                                onClick={() => { onAdd(); setIsOpen(false); }}\n                                className=\"w-full flex items-center justify-center gap-2 p-2 rounded-xl text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)] font-medium text-sm transition-colors\"\n                            >\n                                <span className=\"text-lg\">+</span> {t('newEpisode')}\n                            </button>\n                        </>\n                    )}\n                </div>\n            )}\n        </div>\n    )\n}\n\nexport default CapsuleNav\n"
  },
  {
    "path": "src/components/ui/ConfigModals.tsx",
    "content": "export { ConfigConfirmModal } from './config-modals/ConfigConfirmModal'\nexport { ConfigDeleteModal } from './config-modals/ConfigDeleteModal'\nexport { ConfigEditModal, SettingsModal, WorldContextModal } from './config-modals/ConfigEditModal'\n"
  },
  {
    "path": "src/components/ui/ImagePreviewModal.tsx",
    "content": "'use client'\n\nimport { useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { resolveOriginalImageUrl, toDisplayImageUrl } from '@/lib/media/image-url'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ImagePreviewModalProps {\n  imageUrl: string | null\n  onClose: () => void\n}\n\nexport default function ImagePreviewModal({ imageUrl, onClose }: ImagePreviewModalProps) {\n  const t = useTranslations('common')\n\n  useEffect(() => {\n    // 禁用body滚动\n    document.body.style.overflow = 'hidden'\n\n    const handleEscape = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        onClose()\n      }\n    }\n\n    document.addEventListener('keydown', handleEscape)\n\n    return () => {\n      document.body.style.overflow = 'unset'\n      document.removeEventListener('keydown', handleEscape)\n    }\n  }, [onClose])\n\n  if (!imageUrl) return null\n  const displayImageUrl = toDisplayImageUrl(imageUrl)\n  const originalImageUrl = resolveOriginalImageUrl(imageUrl) || displayImageUrl\n  if (!displayImageUrl) return null\n\n  return (\n    <div\n      className=\"fixed inset-0 z-[9999] flex items-center justify-center bg-[var(--glass-overlay)] backdrop-blur-sm\"\n      onClick={onClose}\n      style={{ margin: 0, padding: 0 }}\n    >\n      <div className=\"relative max-w-7xl max-h-[90vh] p-4\">\n        {/* 关闭按钮 */}\n        <button\n          onClick={onClose}\n          className=\"absolute top-6 right-6 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-[var(--glass-overlay)] hover:bg-[var(--glass-overlay)] text-white transition-colors\"\n        >\n          <AppIcon name=\"close\" className=\"w-6 h-6\" />\n        </button>\n        {originalImageUrl && (\n          <a\n            href={originalImageUrl}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            onClick={(e) => e.stopPropagation()}\n            className=\"absolute top-6 right-20 z-10 px-3 h-10 inline-flex items-center rounded-full bg-[var(--glass-overlay)] hover:bg-[var(--glass-overlay)] text-white text-sm transition-colors\"\n          >\n            {t('viewOriginal')}\n          </a>\n        )}\n\n        {/* 图片 */}\n        <MediaImageWithLoading\n          src={displayImageUrl}\n          alt={t('preview')}\n          containerClassName=\"max-w-full max-h-[90vh]\"\n          className=\"max-w-full max-h-[90vh] object-contain rounded-lg shadow-2xl\"\n          onClick={(e) => e.stopPropagation()}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/SegmentedControl.tsx",
    "content": "'use client'\n\nimport type { ReactNode } from 'react'\n\n// ─── Types ────────────────────────────────────────────\n\nexport interface SegmentedControlOption<T extends string = string> {\n    value: T\n    label: ReactNode\n}\n\ninterface SegmentedControlProps<T extends string = string> {\n    options: SegmentedControlOption<T>[]\n    value: T\n    onChange: (value: T) => void\n    /** Extra className on the outer container */\n    className?: string\n}\n\n// ─── Component ────────────────────────────────────────\n\n/**\n * Unified iOS-style segmented control.\n *\n * Single source of truth for all tab/segment UIs across the app.\n * Uses per-button selected styling (not a sliding indicator) for\n * pixel-perfect equal padding on all four sides.\n */\nexport function SegmentedControl<T extends string = string>({\n    options,\n    value,\n    onChange,\n    className = '',\n}: SegmentedControlProps<T>) {\n    return (\n        <div className={`rounded-lg p-[3px] bg-[#f2f2f7] dark:bg-[#1c1c1e] shadow-inner ${className}`}>\n            <div\n                className=\"grid\"\n                style={{ gridTemplateColumns: `repeat(${Math.max(1, options.length)}, minmax(0, 1fr))` }}\n            >\n                {options.map((opt) => (\n                    <button\n                        key={opt.value}\n                        type=\"button\"\n                        onClick={() => onChange(opt.value)}\n                        className={`flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition-all cursor-pointer ${value === opt.value\n                            ? 'bg-white text-[var(--glass-text-primary)] dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold'\n                            : 'text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]'\n                            }`}\n                    >\n                        {opt.label}\n                    </button>\n                ))}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/components/ui/SharedComponents.tsx",
    "content": "'use client'\n\n/**\n * AnimatedBackground - 流光极光背景动画\n * 用于页面全局背景\n */\nexport function AnimatedBackground() {\n    return (\n        <div className=\"fixed inset-0 -z-10 overflow-hidden bg-[var(--glass-bg-canvas)]\">\n            <div className=\"absolute top-[-50%] left-[-50%] w-[200%] h-[200%] opacity-40 animate-aurora filter blur-[100px]\">\n                <div className=\"absolute top-0 left-0 w-1/2 h-1/2 bg-[var(--glass-bg-surface)] rounded-full mix-blend-multiply animate-blob\" />\n                <div className=\"absolute top-0 right-0 w-1/2 h-1/2 bg-[var(--glass-bg-muted)] rounded-full mix-blend-multiply animate-blob animation-delay-2000\" />\n                <div className=\"absolute bottom-0 left-0 w-1/2 h-1/2 bg-[var(--glass-bg-surface-strong)] rounded-full mix-blend-multiply animate-blob animation-delay-4000\" />\n            </div>\n            <div className=\"absolute inset-0 bg-white/60 backdrop-blur-3xl\" />\n        </div>\n    )\n}\n\n/**\n * GlassPanel - 毛玻璃卡片容器\n */\nexport function GlassPanel({\n    children,\n    className = ''\n}: {\n    children: React.ReactNode\n    className?: string\n}) {\n    return (\n        <div className={`\n      glass-surface-elevated\n      ${className}\n    `}>\n            {children}\n        </div>\n    )\n}\n\n/**\n * Button - 通用按钮组件\n */\nexport function Button({\n    children,\n    primary = false,\n    onClick,\n    disabled = false,\n    icon,\n    className = ''\n}: {\n    children: React.ReactNode\n    primary?: boolean\n    onClick?: () => void\n    disabled?: boolean\n    icon?: React.ReactNode\n    className?: string\n}) {\n    return (\n        <button\n            onClick={onClick}\n            disabled={disabled}\n            className={`\n        glass-btn-base px-6 py-2.5\n        ${primary\n                    ? 'glass-btn-primary text-white'\n                    : 'glass-btn-secondary'}\n        disabled:opacity-50 disabled:cursor-not-allowed\n        ${className}\n      `}\n        >\n            {icon && <span>{icon}</span>}\n            {children}\n        </button>\n    )\n}\n"
  },
  {
    "path": "src/components/ui/ai-edit-style.ts",
    "content": "export const AI_EDIT_BUTTON_CLASS = 'bg-[var(--glass-bg-surface-strong)] border border-[var(--glass-stroke-base)] shadow-sm hover:bg-[var(--glass-bg-surface)]'\n\nexport const AI_EDIT_ICON_CLASS = ''\n"
  },
  {
    "path": "src/components/ui/config-modals/ConfigConfirmModal.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\n\ninterface ConfigConfirmModalProps {\n  isOpen: boolean\n  onClose: () => void\n  onConfirm: () => void\n  title: string\n  description?: string\n  confirmText?: string\n  cancelText?: string\n  danger?: boolean\n  confirmDisabled?: boolean\n}\n\nexport function ConfigConfirmModal({\n  isOpen,\n  onClose,\n  onConfirm,\n  title,\n  description,\n  confirmText,\n  cancelText,\n  danger = false,\n  confirmDisabled = false,\n}: ConfigConfirmModalProps) {\n  const t = useTranslations('configModal')\n  if (!isOpen) return null\n\n  return (\n    <div\n      className=\"fixed inset-0 z-[100] flex items-center justify-center glass-overlay animate-fadeIn\"\n      onClick={(event) => {\n        if (event.target === event.currentTarget) onClose()\n      }}\n    >\n      <div className=\"glass-surface-modal w-full max-w-md p-6\">\n        <div className=\"mb-4\">\n          <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">{title}</h3>\n          {description && (\n            <p className=\"mt-2 text-sm text-[var(--glass-text-secondary)]\">{description}</p>\n          )}\n        </div>\n\n        <div className=\"flex justify-end gap-2\">\n          <button onClick={onClose} className=\"glass-btn-base glass-btn-secondary px-3 py-1.5 text-sm\">\n            {cancelText || t('cancel')}\n          </button>\n          <button\n            onClick={onConfirm}\n            disabled={confirmDisabled}\n            className={`glass-btn-base px-3 py-1.5 text-sm ${danger ? 'glass-btn-tone-danger' : 'glass-btn-primary'} disabled:pointer-events-none disabled:opacity-50`}\n          >\n            {confirmText || t('confirm')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/config-modals/ConfigDeleteModal.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { ConfigConfirmModal } from './ConfigConfirmModal'\n\ninterface ConfigDeleteModalProps {\n  isOpen: boolean\n  onClose: () => void\n  onDelete: () => void\n  title: string\n  description?: string\n  deleteText?: string\n  cancelText?: string\n  deleteDisabled?: boolean\n}\n\nexport function ConfigDeleteModal({\n  isOpen,\n  onClose,\n  onDelete,\n  title,\n  description,\n  deleteText,\n  cancelText,\n  deleteDisabled = false,\n}: ConfigDeleteModalProps) {\n  const t = useTranslations('configModal')\n  return (\n    <ConfigConfirmModal\n      isOpen={isOpen}\n      onClose={onClose}\n      onConfirm={onDelete}\n      title={title}\n      description={description}\n      confirmText={deleteText || t('delete')}\n      cancelText={cancelText || t('cancel')}\n      danger\n      confirmDisabled={deleteDisabled}\n    />\n  )\n}\n"
  },
  {
    "path": "src/components/ui/config-modals/ConfigEditModal.tsx",
    "content": "'use client'\n\nimport { useEffect, useMemo, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport {\n    ART_STYLES,\n    VIDEO_RATIOS,\n} from '@/lib/constants'\nimport type {\n    CapabilitySelections,\n    CapabilityValue,\n    ModelCapabilities,\n} from '@/lib/model-config-contract'\nimport { filterNormalVideoModelOptions } from '@/lib/model-capabilities/video-model-options'\nimport { RatioSelector, StyleSelector } from './config-modal-selectors'\nimport { ModelCapabilityDropdown } from './ModelCapabilityDropdown'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface ModelOption {\n    value: string\n    label: string\n    provider?: string\n    providerName?: string\n    capabilities?: ModelCapabilities\n}\n\ninterface UserModels {\n    llm: ModelOption[]\n    image: ModelOption[]\n    video: ModelOption[]\n    audio: ModelOption[]\n}\n\ninterface CapabilityFieldDefinition {\n    field: string\n    options: CapabilityValue[]\n    label: string\n}\n\ninterface SettingsModalProps {\n    isOpen: boolean\n    onClose: () => void\n    availableModels?: Partial<UserModels>\n    modelsLoaded?: boolean\n    artStyle?: string\n    analysisModel?: string\n    characterModel?: string\n    locationModel?: string\n    imageModel?: string\n    editModel?: string\n\n    videoModel?: string\n    audioModel?: string\n    videoRatio?: string\n    capabilityOverrides?: CapabilitySelections\n    ttsRate?: string\n    onArtStyleChange?: (value: string) => void\n    onAnalysisModelChange?: (value: string) => void\n    onCharacterModelChange?: (value: string) => void\n    onLocationModelChange?: (value: string) => void\n    onImageModelChange?: (value: string) => void\n    onEditModelChange?: (value: string) => void\n\n    onVideoModelChange?: (value: string) => void\n    onAudioModelChange?: (value: string) => void\n    onVideoRatioChange?: (value: string) => void\n    onCapabilityOverridesChange?: (value: CapabilitySelections) => void\n    onTTSRateChange?: (value: string) => void\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n    return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isCapabilityValue(value: unknown): value is CapabilityValue {\n    return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction toFieldLabel(field: string): string {\n    return field.replace(/([A-Z])/g, ' $1').replace(/^./, (char) => char.toUpperCase())\n}\n\nfunction parseBySample(input: string, sample: CapabilityValue): CapabilityValue {\n    if (typeof sample === 'number') return Number(input)\n    if (typeof sample === 'boolean') return input === 'true'\n    return input\n}\n\nfunction extractCapabilityFields(\n    capabilities: ModelCapabilities | undefined,\n    namespace: 'llm' | 'image' | 'video' | 'audio',\n): CapabilityFieldDefinition[] {\n    const rawNamespace = capabilities?.[namespace]\n    if (!isRecord(rawNamespace)) return []\n\n    return Object.entries(rawNamespace)\n        .filter(([key, value]) => key.endsWith('Options') && Array.isArray(value) && value.every(isCapabilityValue) && value.length > 0)\n        .map(([key, value]) => {\n            const field = key.slice(0, -'Options'.length)\n            return {\n                field,\n                options: value as CapabilityValue[],\n                label: toFieldLabel(field),\n            }\n        })\n}\n\nfunction readCapabilitySelectionForModel(\n    overrides: CapabilitySelections | undefined,\n    modelKey: string | undefined,\n): Record<string, CapabilityValue> {\n    if (!modelKey || !overrides) return {}\n    const raw = overrides[modelKey]\n    if (!isRecord(raw)) return {}\n\n    const normalized: Record<string, CapabilityValue> = {}\n    for (const [field, value] of Object.entries(raw)) {\n        if (isCapabilityValue(value)) {\n            normalized[field] = value\n        }\n    }\n    return normalized\n}\n\nexport function SettingsModal({\n    isOpen,\n    onClose,\n    availableModels,\n    modelsLoaded = false,\n    artStyle = 'american-comic',\n    analysisModel,\n    characterModel,\n    locationModel,\n    imageModel,\n    editModel,\n    videoModel,\n    audioModel,\n    videoRatio = '9:16',\n    capabilityOverrides,\n    ttsRate,\n    onArtStyleChange,\n    onAnalysisModelChange,\n    onCharacterModelChange,\n    onLocationModelChange,\n    onImageModelChange,\n    onEditModelChange,\n    onVideoModelChange,\n    onAudioModelChange,\n    onVideoRatioChange,\n    onCapabilityOverridesChange,\n    onTTSRateChange,\n}: SettingsModalProps) {\n    const t = useTranslations('configModal')\n    const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle')\n    const userModels = useMemo<UserModels>(() => ({\n        llm: Array.isArray(availableModels?.llm) ? availableModels.llm : [],\n        image: Array.isArray(availableModels?.image) ? availableModels.image : [],\n        video: Array.isArray(availableModels?.video) ? availableModels.video : [],\n        audio: Array.isArray(availableModels?.audio) ? availableModels.audio : [],\n    }), [availableModels])\n    const normalVideoModels = useMemo<ModelOption[]>(\n        () => filterNormalVideoModelOptions(userModels.video),\n        [userModels.video],\n    )\n\n    const selectedVideoModelOption = useMemo(\n        () => normalVideoModels.find((model) => model.value === videoModel) || null,\n        [normalVideoModels, videoModel],\n    )\n    const selectedAnalysisModelOption = useMemo(\n        () => userModels.llm.find((model) => model.value === analysisModel) || null,\n        [userModels.llm, analysisModel],\n    )\n    const selectedAudioModelOption = useMemo(\n        () => userModels.audio.find((model) => model.value === audioModel) || null,\n        [userModels.audio, audioModel],\n    )\n\n    const videoCapabilityFields = useMemo(\n        () => extractCapabilityFields(selectedVideoModelOption?.capabilities, 'video'),\n        [selectedVideoModelOption],\n    )\n    const analysisCapabilityFields = useMemo(\n        () => extractCapabilityFields(selectedAnalysisModelOption?.capabilities, 'llm'),\n        [selectedAnalysisModelOption],\n    )\n    const audioCapabilityFields = useMemo(\n        () => extractCapabilityFields(selectedAudioModelOption?.capabilities, 'audio'),\n        [selectedAudioModelOption],\n    )\n    const selectedCharacterModelOption = useMemo(\n        () => userModels.image.find((model) => model.value === characterModel) || null,\n        [userModels.image, characterModel],\n    )\n    const selectedLocationModelOption = useMemo(\n        () => userModels.image.find((model) => model.value === locationModel) || null,\n        [userModels.image, locationModel],\n    )\n    const selectedStoryboardModelOption = useMemo(\n        () => userModels.image.find((model) => model.value === imageModel) || null,\n        [userModels.image, imageModel],\n    )\n    const selectedEditModelOption = useMemo(\n        () => userModels.image.find((model) => model.value === editModel) || null,\n        [userModels.image, editModel],\n    )\n    const characterCapabilityFields = useMemo(\n        () => extractCapabilityFields(selectedCharacterModelOption?.capabilities, 'image'),\n        [selectedCharacterModelOption],\n    )\n    const locationCapabilityFields = useMemo(\n        () => extractCapabilityFields(selectedLocationModelOption?.capabilities, 'image'),\n        [selectedLocationModelOption],\n    )\n    const storyboardCapabilityFields = useMemo(\n        () => extractCapabilityFields(selectedStoryboardModelOption?.capabilities, 'image'),\n        [selectedStoryboardModelOption],\n    )\n    const editCapabilityFields = useMemo(\n        () => extractCapabilityFields(selectedEditModelOption?.capabilities, 'image'),\n        [selectedEditModelOption],\n    )\n\n    const selectedVideoOverrides = useMemo<Record<string, CapabilityValue>>(() => {\n        return readCapabilitySelectionForModel(capabilityOverrides, videoModel)\n    }, [capabilityOverrides, videoModel])\n    const selectedAnalysisOverrides = useMemo<Record<string, CapabilityValue>>(() => {\n        return readCapabilitySelectionForModel(capabilityOverrides, analysisModel)\n    }, [capabilityOverrides, analysisModel])\n    const selectedAudioOverrides = useMemo<Record<string, CapabilityValue>>(() => {\n        return readCapabilitySelectionForModel(capabilityOverrides, audioModel)\n    }, [capabilityOverrides, audioModel])\n    const selectedCharacterOverrides = useMemo<Record<string, CapabilityValue>>(() => {\n        return readCapabilitySelectionForModel(capabilityOverrides, characterModel)\n    }, [capabilityOverrides, characterModel])\n    const selectedLocationOverrides = useMemo<Record<string, CapabilityValue>>(() => {\n        return readCapabilitySelectionForModel(capabilityOverrides, locationModel)\n    }, [capabilityOverrides, locationModel])\n    const selectedStoryboardOverrides = useMemo<Record<string, CapabilityValue>>(() => {\n        return readCapabilitySelectionForModel(capabilityOverrides, imageModel)\n    }, [capabilityOverrides, imageModel])\n    const selectedEditOverrides = useMemo<Record<string, CapabilityValue>>(() => {\n        return readCapabilitySelectionForModel(capabilityOverrides, editModel)\n    }, [capabilityOverrides, editModel])\n\n    const applyCapabilityOverride = (modelKey: string | undefined, field: string, value: string, sample: CapabilityValue) => {\n        if (!modelKey || !onCapabilityOverridesChange) return\n\n        const nextOverrides: CapabilitySelections = {\n            ...(capabilityOverrides || {}),\n        }\n        const currentSelection = isRecord(nextOverrides[modelKey])\n            ? { ...(nextOverrides[modelKey] as Record<string, CapabilityValue>) }\n            : {}\n\n        if (!value) {\n            delete currentSelection[field]\n        } else {\n            currentSelection[field] = parseBySample(value, sample)\n        }\n\n        if (Object.keys(currentSelection).length === 0) {\n            delete nextOverrides[modelKey]\n        } else {\n            nextOverrides[modelKey] = currentSelection\n        }\n\n        onCapabilityOverridesChange(nextOverrides)\n        showSaved()\n    }\n\n    /**\n     * 切换模型时，自动将该模型所有 capability fields 的第一个 option 写入 overrides\n     * 解决 UI 视觉上显示默认选中（第一项高亮）但 DB 实际为空，导致 requireAllFields 报错的问题\n     */\n    const handleModelChange = (\n        modelKey: string,\n        modelOptions: ModelOption[],\n        namespace: 'llm' | 'image' | 'video' | 'audio',\n        onModelChangeFn?: (v: string) => void,\n    ) => {\n        onModelChangeFn?.(modelKey)\n        showSaved()\n        if (!onCapabilityOverridesChange) return\n        // 用新选中的模型的 capabilities 计算 fields，而不是旧模型的\n        const newModel = modelOptions.find((m) => m.value === modelKey)\n        const capabilityFieldsForModel = extractCapabilityFields(newModel?.capabilities, namespace)\n        if (capabilityFieldsForModel.length === 0) return\n        const nextOverrides: CapabilitySelections = { ...(capabilityOverrides || {}) }\n        const existing = isRecord(nextOverrides[modelKey])\n            ? { ...(nextOverrides[modelKey] as Record<string, CapabilityValue>) }\n            : {}\n        // 只对尚未配置的 field 设置默认值（不覆盖已有配置）\n        let changed = false\n        for (const def of capabilityFieldsForModel) {\n            if (existing[def.field] === undefined && def.options.length > 0) {\n                existing[def.field] = def.options[0]\n                changed = true\n            }\n        }\n        if (changed) {\n            nextOverrides[modelKey] = existing\n            onCapabilityOverridesChange(nextOverrides)\n        }\n    }\n\n    void ttsRate\n    void onTTSRateChange\n\n    useEffect(() => {\n        if (!isOpen) return\n        const handleKeyDown = (e: KeyboardEvent) => {\n            if (e.key === 'Escape') onClose()\n        }\n        document.addEventListener('keydown', handleKeyDown)\n        return () => document.removeEventListener('keydown', handleKeyDown)\n    }, [isOpen, onClose])\n\n    const showSaved = () => {\n        setSaveStatus('saved')\n        setTimeout(() => setSaveStatus('idle'), 2000)\n    }\n\n    const handleChange = (callback?: (value: string) => void) => (value: string) => {\n        callback?.(value)\n        showSaved()\n    }\n\n    if (!isOpen) return null\n\n    return (\n        <div\n            className=\"fixed inset-0 z-[100] flex items-center justify-center glass-overlay animate-fadeIn\"\n            onClick={(e) => {\n                if (e.target === e.currentTarget) onClose()\n            }}\n        >\n            <div className=\"glass-surface-modal p-7 w-full max-w-3xl transform transition-all scale-100 max-h-[90vh] flex flex-col\">\n                <div className=\"flex justify-between items-center mb-2\">\n                    <h2 className=\"text-2xl font-bold text-[var(--glass-text-primary)]\">{t('title')}</h2>\n                    <div className=\"flex items-center gap-3\">\n                        <div className={`glass-chip text-xs transition-all duration-300 ${saveStatus === 'saved'\n                            ? 'glass-chip-success'\n                            : 'glass-chip-neutral'\n                            }`}>\n                            {saveStatus === 'saved' ? (\n                                <>\n                                    <AppIcon name=\"check\" className=\"w-3.5 h-3.5\" />\n                                    {t('saved')}\n                                </>\n                            ) : (\n                                <>\n                                    <span className=\"w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full\"></span>\n                                    {t('autoSave')}\n                                </>\n                            )}\n                        </div>\n                        <button\n                            onClick={onClose}\n                            className=\"glass-btn-base glass-btn-soft rounded-full p-2 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]\"\n                        >\n                            <AppIcon name=\"close\" className=\"w-6 h-6\" />\n                        </button>\n                    </div>\n                </div>\n                <p className=\"text-[12px] text-[var(--glass-text-tertiary)] mb-6\">{t('subtitle')}</p>\n                <div className=\"space-y-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar\">\n                    <div className=\"glass-surface-soft p-5 sm:p-6 space-y-4\">\n                        <h3 className=\"text-sm font-semibold text-[var(--glass-text-tertiary)]\">{t('visualStyle')}</h3>\n                        <div className=\"max-w-xs\">\n                            <StyleSelector\n                                value={artStyle}\n                                onChange={(value) => handleChange(onArtStyleChange)(value)}\n                                options={ART_STYLES}\n                            />\n                        </div>\n                    </div>\n\n                    <div className=\"glass-surface-soft p-5 sm:p-6 space-y-4\">\n                        <h3 className=\"text-sm font-semibold text-[var(--glass-text-tertiary)]\">{t('modelParams')}</h3>\n                        {!modelsLoaded && (\n                            <div className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('loadingModels')}</div>\n                        )}\n                        <div className=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n                            <div className=\"space-y-2\">\n                                <label className=\"text-sm font-medium text-[var(--glass-text-secondary)]\">{t('analysisModel')}</label>\n                                <ModelCapabilityDropdown\n                                    models={userModels.llm}\n                                    value={analysisModel}\n                                    onModelChange={(v) => handleChange(onAnalysisModelChange)(v)}\n                                    capabilityFields={analysisCapabilityFields}\n                                    placementMode=\"downward\"\n                                    capabilityOverrides={selectedAnalysisOverrides}\n                                    onCapabilityChange={(field, rawValue, sample) => {\n                                        applyCapabilityOverride(analysisModel, field, rawValue, sample)\n                                    }}\n                                    placeholder={t('pleaseSelect')}\n                                />\n                            </div>\n\n                            <div className=\"space-y-2\">\n                                <label className=\"text-sm font-medium text-[var(--glass-text-secondary)]\">{t('characterModel')}</label>\n                                <ModelCapabilityDropdown\n                                    models={userModels.image}\n                                    value={characterModel}\n                                    onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onCharacterModelChange)}\n                                    capabilityFields={characterCapabilityFields}\n                                    placementMode=\"downward\"\n                                    capabilityOverrides={selectedCharacterOverrides}\n                                    onCapabilityChange={(field, rawValue, sample) => {\n                                        applyCapabilityOverride(characterModel, field, rawValue, sample)\n                                    }}\n                                />\n                            </div>\n\n                            <div className=\"space-y-2\">\n                                <label className=\"text-sm font-medium text-[var(--glass-text-secondary)]\">{t('locationModel')}</label>\n                                <ModelCapabilityDropdown\n                                    models={userModels.image}\n                                    value={locationModel}\n                                    onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onLocationModelChange)}\n                                    capabilityFields={locationCapabilityFields}\n                                    placementMode=\"downward\"\n                                    capabilityOverrides={selectedLocationOverrides}\n                                    onCapabilityChange={(field, rawValue, sample) => {\n                                        applyCapabilityOverride(locationModel, field, rawValue, sample)\n                                    }}\n                                />\n                            </div>\n\n                            <div className=\"space-y-2\">\n                                <label className=\"text-sm font-medium text-[var(--glass-text-secondary)]\">{t('storyboardModel')}</label>\n                                <ModelCapabilityDropdown\n                                    models={userModels.image}\n                                    value={imageModel}\n                                    onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onImageModelChange)}\n                                    capabilityFields={storyboardCapabilityFields}\n                                    placementMode=\"downward\"\n                                    capabilityOverrides={selectedStoryboardOverrides}\n                                    onCapabilityChange={(field, rawValue, sample) => {\n                                        applyCapabilityOverride(imageModel, field, rawValue, sample)\n                                    }}\n                                />\n                            </div>\n\n                            <div className=\"space-y-2\">\n                                <label className=\"text-sm font-medium text-[var(--glass-text-secondary)]\">{t('editModel')}</label>\n                                <ModelCapabilityDropdown\n                                    models={userModels.image}\n                                    value={editModel}\n                                    onModelChange={(v) => handleModelChange(v, userModels.image, 'image', onEditModelChange)}\n                                    capabilityFields={editCapabilityFields}\n                                    placementMode=\"downward\"\n                                    capabilityOverrides={selectedEditOverrides}\n                                    onCapabilityChange={(field, rawValue, sample) => {\n                                        applyCapabilityOverride(editModel, field, rawValue, sample)\n                                    }}\n                                />\n                            </div>\n\n                            <div className=\"space-y-2\">\n                                <label className=\"text-sm font-medium text-[var(--glass-text-secondary)]\">{t('videoModel')}</label>\n                                <ModelCapabilityDropdown\n                                    models={normalVideoModels}\n                                    value={videoModel}\n                                    onModelChange={(v) => handleModelChange(v, normalVideoModels, 'video', onVideoModelChange)}\n                                    capabilityFields={videoCapabilityFields}\n                                    placementMode=\"downward\"\n                                    capabilityOverrides={selectedVideoOverrides}\n                                    onCapabilityChange={(field, rawValue, sample) => {\n                                        applyCapabilityOverride(videoModel, field, rawValue, sample)\n                                    }}\n                                />\n                            </div>\n\n                            <div className=\"space-y-2\">\n                                <label className=\"text-sm font-medium text-[var(--glass-text-secondary)]\">{t('audioModel')}</label>\n                                <ModelCapabilityDropdown\n                                    models={userModels.audio}\n                                    value={audioModel}\n                                    onModelChange={(v) => handleModelChange(v, userModels.audio, 'audio', onAudioModelChange)}\n                                    capabilityFields={audioCapabilityFields}\n                                    placementMode=\"downward\"\n                                    capabilityOverrides={selectedAudioOverrides}\n                                    onCapabilityChange={(field, rawValue, sample) => {\n                                        applyCapabilityOverride(audioModel, field, rawValue, sample)\n                                    }}\n                                    placeholder={t('pleaseSelect')}\n                                />\n                            </div>\n                        </div>\n                    </div>\n\n                    <div className=\"glass-surface-soft p-5 sm:p-6 space-y-4\">\n                        <h3 className=\"text-sm font-semibold text-[var(--glass-text-tertiary)]\">{t('aspectRatio')}</h3>\n                        <div className=\"max-w-xs\">\n                            <RatioSelector\n                                value={videoRatio}\n                                onChange={(value) => { handleChange(onVideoRatioChange)(value) }}\n                                options={VIDEO_RATIOS}\n                            />\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    )\n}\n\nexport { SettingsModal as ConfigEditModal }\nexport { WorldContextModal } from './WorldContextModal'\n"
  },
  {
    "path": "src/components/ui/config-modals/ModelCapabilityDropdown.tsx",
    "content": "'use client'\n\n/**\n * ModelCapabilityDropdown - 方案 A 经典分区式\n * 自定义下拉组件：上半区选模型，分割线，下半区配参数\n * 触发器显示模型名 + provider + 参数摘要\n *\n * 用于：\n *  - 项目配置中心 (ConfigEditModal / SettingsModal)\n *  - 系统级设置中心 (ApiConfigTabContainer)\n */\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport { useTranslations } from 'next-intl'\nimport type { CapabilityValue } from '@/lib/model-config-contract'\nimport { AppIcon, RatioPreviewIcon } from '@/components/ui/icons'\n\n// ─── Types ────────────────────────────────────────────\n\nexport interface ModelCapabilityOption {\n    /** Composite key e.g. \"ark::doubao-seedance-1-0-pro-250528\" */\n    value: string\n    /** Display name */\n    label: string\n    /** Raw provider id */\n    provider?: string\n    /** Friendly provider name */\n    providerName?: string\n    /** Whether this model is disabled in current context */\n    disabled?: boolean\n}\n\nexport interface CapabilityFieldDefinition {\n    field: string\n    label: string\n    options: CapabilityValue[]\n    disabledOptions?: CapabilityValue[]\n}\n\nexport interface CapabilityBooleanToggle {\n    key: string\n    label: string\n    value: boolean\n    onChange: (next: boolean) => void\n    onLabel?: string\n    offLabel?: string\n}\n\nexport interface ModelCapabilityDropdownProps {\n    /** Available model options */\n    models: ModelCapabilityOption[]\n    /** Currently selected model key */\n    value: string | undefined\n    /** Callback when model selection changes */\n    onModelChange: (modelKey: string) => void\n    /** Capability fields for the currently selected model */\n    capabilityFields: CapabilityFieldDefinition[]\n    /** Current capability override values keyed by field name */\n    capabilityOverrides: Record<string, CapabilityValue>\n    /** Callback when a capability value changes. Pass empty string to reset. */\n    onCapabilityChange: (field: string, rawValue: string, sample: CapabilityValue) => void\n    /** Optional: label text to show when no model is selected */\n    placeholder?: string\n    /** Optional: compact mode for smaller card contexts */\n    compact?: boolean\n    /** Optional: extra boolean toggles rendered in param section */\n    booleanToggles?: CapabilityBooleanToggle[]\n    /** Optional: control dropdown placement strategy. Defaults to 'auto'. */\n    placementMode?: 'auto' | 'downward'\n}\n\nconst DEFAULT_PANEL_MAX_HEIGHT = 520\nconst VIEWPORT_EDGE_GAP = 16\n\n// ─── Helpers ──────────────────────────────────────────\n\nfunction RatioIcon({ ratio, size = 12, selected = false }: { ratio: string; size?: number; selected?: boolean }) {\n    return (\n        <RatioPreviewIcon\n            ratio={ratio}\n            size={size}\n            selected={selected}\n            radiusClassName=\"rounded-[3px]\"\n        />\n    )\n}\n\nfunction isRatioLike(field: string, options: CapabilityValue[]): boolean {\n    const normalizedField = field.toLowerCase().replace(/[_\\-\\s]/g, '')\n    if (normalizedField === 'ratio' || normalizedField === 'aspectratio') return true\n    return options.every((o) => typeof o === 'string' && /^\\d+:\\d+$/.test(o))\n}\n\nfunction isValidRatioText(value: string): boolean {\n    return /^\\d+:\\d+$/.test(value)\n}\n\nfunction shouldUseSelectControl(field: string, options: CapabilityValue[]): boolean {\n    if (options.length <= 3) return false\n    if (field.toLowerCase().includes('duration')) return true\n    if (field.toLowerCase().includes('fps')) return true\n    return options.every((item) => typeof item === 'number')\n}\n\n\nfunction isOptionDisabled(def: CapabilityFieldDefinition, option: CapabilityValue): boolean {\n    if (!Array.isArray(def.disabledOptions) || def.disabledOptions.length === 0) return false\n    return def.disabledOptions.includes(option)\n}\n\n// ─── Component ────────────────────────────────────────\n\nexport function ModelCapabilityDropdown({\n    models,\n    value,\n    onModelChange,\n    capabilityFields,\n    capabilityOverrides,\n    onCapabilityChange,\n    placeholder,\n    compact = false,\n    booleanToggles = [],\n    placementMode = 'auto',\n}: ModelCapabilityDropdownProps) {\n    const t = useTranslations('configModal')\n    const tv = useTranslations('video')\n    const [isOpen, setIsOpen] = useState(false)\n    const triggerRef = useRef<HTMLDivElement>(null)\n    const panelRef = useRef<HTMLDivElement>(null)\n    const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})\n\n    const updateDropdownPlacement = useCallback(() => {\n        const trigger = triggerRef.current\n        if (!trigger) return\n\n        const rect = trigger.getBoundingClientRect()\n        const viewportHeight = window.innerHeight || document.documentElement.clientHeight\n        const spaceAbove = Math.max(0, rect.top - VIEWPORT_EDGE_GAP)\n        const spaceBelow = Math.max(0, viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP)\n        const preferAutoPlacement = placementMode === 'auto'\n        const shouldOpenUpward = preferAutoPlacement\n            ? (spaceBelow < DEFAULT_PANEL_MAX_HEIGHT && spaceAbove > spaceBelow)\n            : false\n        const availableSpace = shouldOpenUpward ? spaceAbove : spaceBelow\n        const clampedMaxHeight = Math.max(0, Math.min(DEFAULT_PANEL_MAX_HEIGHT, Math.floor(availableSpace)))\n\n\n\n        const viewportWidth = window.innerWidth || document.documentElement.clientWidth\n        const minWidth = compact ? 340 : 400\n        const panelWidth = Math.max(minWidth, rect.width)\n        // Ensure panel doesn't overflow the right edge of viewport\n        const maxLeft = viewportWidth - panelWidth - VIEWPORT_EDGE_GAP\n        const panelLeft = Math.max(VIEWPORT_EDGE_GAP, Math.min(rect.left, maxLeft))\n\n        setPanelStyle({\n            position: 'fixed' as const,\n            left: `${panelLeft}px`,\n            width: `${panelWidth}px`,\n            maxHeight: `${clampedMaxHeight}px`,\n            ...(shouldOpenUpward\n                ? { bottom: `${viewportHeight - rect.top + 4}px` }\n                : { top: `${rect.bottom + 4}px` }\n            ),\n        })\n    }, [compact, placementMode])\n\n    useEffect(() => {\n        function handleClickOutside(e: MouseEvent) {\n            const target = e.target as Node\n            if (triggerRef.current?.contains(target)) return\n            if (panelRef.current?.contains(target)) return\n            setIsOpen(false)\n        }\n        document.addEventListener('mousedown', handleClickOutside)\n        return () => document.removeEventListener('mousedown', handleClickOutside)\n    }, [])\n\n    useLayoutEffect(() => {\n        if (!isOpen) return\n\n        updateDropdownPlacement()\n        window.addEventListener('resize', updateDropdownPlacement)\n        window.addEventListener('scroll', updateDropdownPlacement, true)\n\n        return () => {\n            window.removeEventListener('resize', updateDropdownPlacement)\n            window.removeEventListener('scroll', updateDropdownPlacement, true)\n        }\n    }, [isOpen, updateDropdownPlacement])\n\n    const handleToggleOpen = () => {\n        if (isOpen) {\n            setIsOpen(false)\n            return\n        }\n        updateDropdownPlacement()\n        setIsOpen(true)\n    }\n\n    const selectedModel = models.find((m) => m.value === value)\n    const visibleCapabilityFields = capabilityFields.filter((field) => field.field !== 'generationMode')\n\n    const resolveCapabilityLabel = useCallback((field: CapabilityFieldDefinition): string => {\n        try {\n            return tv(`capability.${field.field}` as never)\n        } catch {\n            return field.label\n        }\n    }, [tv])\n\n    /** Format option value for display — converts booleans to localized On/Off */\n    const formatOptionLabel = useCallback((val: CapabilityValue): string => {\n        if (val === true || val === 'true') return t('boolOn')\n        if (val === false || val === 'false') return t('boolOff')\n        return String(val)\n    }, [t])\n\n    // Build summary text from capability overrides + defaults\n    const paramSummary = visibleCapabilityFields\n        .map((def) => {\n            const val = capabilityOverrides[def.field] !== undefined\n                ? capabilityOverrides[def.field]\n                : (def.options.length > 0 ? def.options[0] : '')\n            return formatOptionLabel(val)\n        })\n        .concat(\n            booleanToggles.map((toggle) => {\n                if (toggle.value) return `${toggle.label}:${toggle.onLabel || 'On'}`\n                return ''\n            }),\n        )\n        .filter(Boolean)\n        .join(' · ')\n\n    const triggerPy = compact ? 'py-1' : 'py-2.5'\n    const triggerPx = compact ? 'px-1.5' : 'px-3'\n    const textSize = compact ? 'text-[11px]' : 'text-sm'\n    const modelOptionTextSize = compact ? 'text-[12px]' : 'text-sm'\n\n    return (\n        <div ref={triggerRef}>\n            {/* ─── Trigger (Deep Glass Glow Style) ─── */}\n            <button\n                type=\"button\"\n                onClick={handleToggleOpen}\n                className={`glass-input-base w-full ${triggerPx} ${triggerPy} rounded-[14px] transition-all duration-200 cursor-pointer ${isOpen\n                    ? '!border-[var(--glass-tone-info-fg)] shadow-[0_0_0_3px_var(--glass-tone-info-bg)]'\n                    : 'hover:border-[var(--glass-stroke-active)]'\n                    }`}\n            >\n                <div className=\"flex items-center justify-between gap-2\">\n                    <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                        {selectedModel ? (\n                            <>\n                                <span className={`${textSize} text-[var(--glass-text-primary)] font-semibold truncate`}>\n                                    {selectedModel.label}\n                                </span>\n                            </>\n                        ) : (\n                            <span className={`${textSize} text-[var(--glass-text-tertiary)]`}>\n                                {placeholder || t('pleaseSelect')}\n                            </span>\n                        )}\n                    </div>\n                    <div className=\"flex items-center gap-1.5 shrink-0\">\n                        {selectedModel && (paramSummary || selectedModel.providerName || selectedModel.provider) && (\n                            <span className=\"relative group/info\">\n                                <AppIcon name=\"info\" className=\"w-4 h-4 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors cursor-help\" />\n                                <span className=\"pointer-events-none absolute right-0 bottom-full mb-2 whitespace-nowrap rounded-lg bg-[var(--glass-text-primary)] px-3 py-1.5 text-[12px] text-white opacity-0 transition-opacity group-hover/info:opacity-100 z-50 shadow-lg\">\n                                    {[selectedModel.providerName || selectedModel.provider, paramSummary].filter(Boolean).join(' · ')}\n                                </span>\n                            </span>\n                        )}\n                        <AppIcon name=\"chevronDown\" className={`w-4 h-4 transition-transform duration-300 shrink-0 ${isOpen ? 'rotate-180 text-[var(--glass-text-primary)]' : 'text-[var(--glass-text-tertiary)]'}`} />\n                    </div>\n                </div>\n            </button>\n\n            {/* ─── Dropdown Panel (Portal · Deep Glass Glow) ─── */}\n            {isOpen && createPortal(\n                <div\n                    ref={panelRef}\n                    className=\"glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-[20px] shadow-[0_8px_32px_rgba(0,0,0,0.1)]\"\n                    style={panelStyle}\n                >\n                    {/* Model list */}\n                    <div className=\"px-2 pb-2 min-h-0 flex-1 overflow-y-auto custom-scrollbar\">\n                        {(() => {\n                            // Group models by provider\n                            const grouped = new Map<string, ModelCapabilityOption[]>()\n                            for (const m of models) {\n                                const key = m.providerName || m.provider || 'Other'\n                                if (!grouped.has(key)) grouped.set(key, [])\n                                grouped.get(key)!.push(m)\n                            }\n                            return Array.from(grouped.entries()).map(([providerLabel, groupModels]) => (\n                                <div key={providerLabel} className=\"mb-1\">\n                                    <div className=\"sticky top-0 z-10 px-2 pt-2 pb-1 bg-white/80 dark:bg-[#1c1c1e]/80 backdrop-blur-md\">\n                                        <span className=\"text-[11px] font-bold text-[var(--glass-text-tertiary)] tracking-wide\">\n                                            {providerLabel}\n                                        </span>\n                                    </div>\n                                    <div className=\"space-y-0.5\">\n                                        {groupModels.map((m) => (\n                                            <button\n                                                key={m.value}\n                                                type=\"button\"\n                                                onClick={() => {\n                                                    if (m.disabled) return\n                                                    onModelChange(m.value)\n                                                }}\n                                                disabled={m.disabled}\n                                                className={`w-full text-left px-4 py-2 transition-all border-l-[3px] ${value === m.value\n                                                    ? 'border-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface-strong)] font-bold'\n                                                    : m.disabled\n                                                        ? 'border-transparent text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'\n                                                        : 'border-transparent hover:bg-[var(--glass-bg-hover)]'\n                                                    }`}\n                                            >\n                                                <span className={value === m.value\n                                                    ? `${modelOptionTextSize} font-bold text-[var(--glass-text-primary)]`\n                                                    : `${modelOptionTextSize} font-medium text-[var(--glass-text-secondary)]`\n                                                }>\n                                                    {m.label}\n                                                </span>\n                                            </button>\n                                        ))}\n                                    </div>\n                                </div>\n                            ))\n                        })()}\n                    </div>\n\n                    {/* Capability params: fixed at panel bottom */}\n                    {(visibleCapabilityFields.length > 0 || booleanToggles.length > 0) && (\n                        <div data-capability-params className=\"shrink-0 bg-[var(--glass-bg-surface)]\">\n                            <div className=\"px-4 py-3\">\n                                <div className=\"text-[10px] font-bold text-[#8e8e93] uppercase tracking-wider mb-2.5\">\n                                    {t('paramConfig')}\n                                </div>\n                                <div className=\"max-h-[156px] overflow-y-auto custom-scrollbar pr-1\">\n                                    <div className=\"space-y-3\">\n                                        {visibleCapabilityFields.map((def) => {\n                                            const currentVal = capabilityOverrides[def.field] !== undefined\n                                                ? String(capabilityOverrides[def.field])\n                                                : ''\n                                            const isR = isRatioLike(def.field, def.options)\n                                            const useSelect = shouldUseSelectControl(def.field, def.options)\n                                            const fallbackOption = def.options[0]\n                                            const selectValue = currentVal || String(fallbackOption)\n\n                                            return (\n                                                <div key={def.field} className=\"flex items-center justify-between gap-3\">\n                                                    <span className=\"text-[13px] text-[var(--glass-text-secondary)] font-semibold shrink-0\">\n                                                        {resolveCapabilityLabel(def)}\n                                                    </span>\n                                                    {def.options.length === 1 ? (\n                                                        <span className=\"text-[11px] font-medium px-2.5 py-1 rounded-md bg-[var(--glass-bg-surface-strong)] border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] flex items-center gap-1\">\n                                                            {(() => {\n                                                                const ratioValue = String(def.options[0])\n                                                                return isR && isValidRatioText(ratioValue) ? <RatioIcon ratio={ratioValue} size={10} /> : null\n                                                            })()}\n                                                            {formatOptionLabel(def.options[0])}\n                                                            <span className=\"text-[var(--glass-text-tertiary)] text-[10px]\">({t('fixed')})</span>\n                                                        </span>\n                                                    ) : useSelect ? (\n                                                        <div className=\"relative group\">\n                                                            <select\n                                                                value={selectValue}\n                                                                onChange={(event) => onCapabilityChange(def.field, event.target.value, def.options[0])}\n                                                                className=\"appearance-none bg-transparent hover:bg-[#f2f2f7] dark:hover:bg-[#1c1c1e] text-[13px] font-bold text-[var(--glass-text-primary)] pl-3 pr-7 py-1 rounded-md transition-colors outline-none cursor-pointer border border-transparent\"\n                                                            >\n                                                                {def.options.map((opt) => {\n                                                                    const s = String(opt)\n                                                                    return (\n                                                                        <option key={s} value={s}>\n                                                                            {formatOptionLabel(opt)}\n                                                                        </option>\n                                                                    )\n                                                                })}\n                                                            </select>\n                                                            <AppIcon name=\"chevronDown\" className=\"w-3.5 h-3.5 text-[var(--glass-text-tertiary)] absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none group-hover:text-[var(--glass-text-primary)] transition-colors\" />\n                                                        </div>\n                                                    ) : (\n                                                        <div className=\"flex bg-[#f2f2f7] dark:bg-[#1c1c1e] p-[3px] rounded-lg shadow-inner\">\n                                                            {def.options.map((opt) => {\n                                                                const s = String(opt)\n                                                                const disabled = isOptionDisabled(def, opt)\n                                                                const on = currentVal ? s === currentVal : s === String(fallbackOption)\n                                                                return (\n                                                                    <button\n                                                                        key={s}\n                                                                        type=\"button\"\n                                                                        onClick={() => onCapabilityChange(def.field, s, def.options[0])}\n                                                                        className={`px-3 py-1.5 text-[12px] font-medium rounded-md transition-all flex items-center gap-1 cursor-pointer ${on\n                                                                            ? 'bg-white text-black dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold'\n                                                                            : disabled\n                                                                                ? 'text-[#8e8e93] opacity-75 hover:opacity-95'\n                                                                                : 'text-[#8e8e93] hover:text-[#3a3a3c] dark:hover:text-[#ebebf5]'\n                                                                            }`}\n                                                                    >\n                                                                        {isR && isValidRatioText(s) && <RatioIcon ratio={s} size={10} selected={on} />}\n                                                                        {formatOptionLabel(opt)}\n                                                                    </button>\n                                                                )\n                                                            })}\n                                                        </div>\n                                                    )}\n                                                </div>\n                                            )\n                                        })}\n                                        {booleanToggles.map((toggle) => (\n                                            <div key={toggle.key} className=\"flex items-center justify-between gap-3\">\n                                                <span className=\"text-[13px] text-[var(--glass-text-secondary)] font-semibold shrink-0\">\n                                                    {toggle.label}\n                                                </span>\n                                                <div className=\"flex bg-[#f2f2f7] dark:bg-[#1c1c1e] p-[3px] rounded-lg shadow-inner\">\n                                                    <button\n                                                        type=\"button\"\n                                                        onClick={() => toggle.onChange(true)}\n                                                        className={`px-3 py-1.5 text-[12px] font-medium rounded-md transition-all ${toggle.value\n                                                            ? 'bg-white text-black dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold'\n                                                            : 'text-[#8e8e93] hover:text-[#3a3a3c] dark:hover:text-[#ebebf5]'\n                                                            }`}\n                                                    >\n                                                        {toggle.onLabel || 'On'}\n                                                    </button>\n                                                    <button\n                                                        type=\"button\"\n                                                        onClick={() => toggle.onChange(false)}\n                                                        className={`px-3 py-1.5 text-[12px] font-medium rounded-md transition-all ${!toggle.value\n                                                            ? 'bg-white text-black dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold'\n                                                            : 'text-[#8e8e93] hover:text-[#3a3a3c] dark:hover:text-[#ebebf5]'\n                                                            }`}\n                                                    >\n                                                        {toggle.offLabel || 'Off'}\n                                                    </button>\n                                                </div>\n                                            </div>\n                                        ))}\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                    )}\n                </div>,\n                document.body,\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/components/ui/config-modals/WorldContextModal.tsx",
    "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\n\ninterface WorldContextModalProps {\n  isOpen: boolean\n  onClose: () => void\n  text: string\n  onChange: (value: string) => void\n}\n\nexport function WorldContextModal({ isOpen, onClose, text, onChange }: WorldContextModalProps) {\n  const t = useTranslations('worldContextModal')\n  const tc = useTranslations('common')\n  const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle')\n  const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n  const handleTextChange = (value: string) => {\n    onChange(value)\n    if (saveTimeoutRef.current) {\n      clearTimeout(saveTimeoutRef.current)\n    }\n    saveTimeoutRef.current = setTimeout(() => {\n      setSaveStatus('saved')\n      setTimeout(() => setSaveStatus('idle'), 2000)\n    }, 500)\n  }\n\n  useEffect(() => {\n    if (!isOpen) return\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') onClose()\n    }\n    document.addEventListener('keydown', handleKeyDown)\n    return () => document.removeEventListener('keydown', handleKeyDown)\n  }, [isOpen, onClose])\n\n  useEffect(() => {\n    return () => {\n      if (saveTimeoutRef.current) {\n        clearTimeout(saveTimeoutRef.current)\n      }\n    }\n  }, [])\n\n  if (!isOpen) return null\n\n  return (\n    <div\n      className=\"fixed inset-0 z-[100] flex items-center justify-center glass-overlay animate-fadeIn\"\n      onClick={(event) => {\n        if (event.target === event.currentTarget) onClose()\n      }}\n    >\n      <div className=\"glass-surface-modal p-7 w-full max-w-3xl transform transition-all scale-100 h-[80vh] flex flex-col\">\n        <div className=\"flex justify-between items-center mb-6 flex-shrink-0\">\n          <div className=\"flex items-center gap-3\">\n            <div>\n              <h2 className=\"text-2xl font-bold text-[var(--glass-text-primary)]\">{t('title')}</h2>\n              <p className=\"text-[var(--glass-text-tertiary)] text-sm\">{t('description')}</p>\n            </div>\n          </div>\n          <div className=\"flex items-center gap-3\">\n            <div\n              className={`glass-chip text-xs transition-all duration-300 ${\n                saveStatus === 'saved' ? 'glass-chip-success' : 'glass-chip-neutral'\n              }`}\n            >\n              {saveStatus === 'saved' ? (\n                <>\n                  <AppIcon name=\"check\" className=\"w-3.5 h-3.5\" />\n                  {tc('saved')}\n                </>\n              ) : (\n                <>\n                  <span className=\"w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full\"></span>\n                  {tc('autoSave')}\n                </>\n              )}\n            </div>\n            <button\n              onClick={onClose}\n              className=\"glass-btn-base glass-btn-soft rounded-full p-2 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]\"\n            >\n              <AppIcon name=\"close\" className=\"w-6 h-6\" />\n            </button>\n          </div>\n        </div>\n\n        <div className=\"flex-1 glass-surface-soft p-4 overflow-hidden flex flex-col\">\n          <textarea\n            value={text}\n            onChange={(event) => handleTextChange(event.target.value)}\n            placeholder={t('placeholder')}\n            className=\"glass-textarea-base flex-1 text-base resize-none leading-relaxed placeholder:text-[var(--glass-text-tertiary)]/70 custom-scrollbar p-4\"\n          />\n        </div>\n\n        <div className=\"mt-6 pt-0 flex justify-start items-center flex-shrink-0\">\n          <span className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('hint')}</span>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/config-modals/config-modal-selectors.tsx",
    "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { AppIcon, RatioPreviewIcon } from '@/components/ui/icons'\n\ninterface RatioIconProps {\n  ratio: string\n  size?: number\n  selected?: boolean\n}\n\ninterface RatioSelectorProps {\n  value: string\n  onChange: (value: string) => void\n  options: Array<{ value: string; label: string }>\n}\n\ninterface StyleSelectorProps {\n  value: string\n  onChange: (value: string) => void\n  options: Array<{ value: string; label: string }>\n}\n\nfunction RatioIcon({ ratio, size = 24, selected = false }: RatioIconProps) {\n  // 始终以选中态渲染图标，保证所有比例选项的图标统一为蓝色\n  return (\n    <RatioPreviewIcon\n      ratio={ratio}\n      size={size}\n      selected={selected || true}\n      variant=\"surface\"\n    />\n  )\n}\n\nexport function RatioSelector({ value, onChange, options }: RatioSelectorProps) {\n  const [isOpen, setIsOpen] = useState(false)\n  const dropdownRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    function handleClickOutside(event: MouseEvent) {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n        setIsOpen(false)\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => document.removeEventListener('mousedown', handleClickOutside)\n  }, [])\n\n  const selectedOption = options.find((option) => option.value === value)\n\n  return (\n    <div className=\"relative\" ref={dropdownRef}>\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(!isOpen)}\n        className=\"glass-input-base h-11 px-3 flex items-center justify-between gap-2 cursor-pointer transition-colors\"\n      >\n        <div className=\"flex items-center gap-3\">\n          <RatioIcon ratio={value} size={20} selected />\n          <span className=\"text-sm text-[var(--glass-text-primary)] font-medium\">\n            {selectedOption?.label || value}\n          </span>\n        </div>\n        <AppIcon name=\"chevronDown\" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n      </button>\n\n      {isOpen && (\n        <div\n          className=\"glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar\"\n          style={{ minWidth: '280px' }}\n        >\n          <div className=\"grid grid-cols-5 gap-2\">\n            {options.map((option) => (\n              <button\n                key={option.value}\n                type=\"button\"\n                onClick={() => {\n                  onChange(option.value)\n                  setIsOpen(false)\n                }}\n                className={`flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors ${\n                  value === option.value\n                    ? 'bg-[var(--glass-tone-info-bg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'\n                    : ''\n                }`}\n              >\n                <RatioIcon ratio={option.value} size={28} selected={value === option.value} />\n                <span\n                  className={`text-xs ${\n                    value === option.value\n                      ? 'text-[var(--glass-tone-info-fg)] font-medium'\n                      : 'text-[var(--glass-text-secondary)]'\n                  }`}\n                >\n                  {option.label}\n                </span>\n              </button>\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport function StyleSelector({ value, onChange, options }: StyleSelectorProps) {\n  const [isOpen, setIsOpen] = useState(false)\n  const dropdownRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    function handleClickOutside(event: MouseEvent) {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n        setIsOpen(false)\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => document.removeEventListener('mousedown', handleClickOutside)\n  }, [])\n\n  const selectedOption = options.find((option) => option.value === value) || options[0]\n\n  return (\n    <div className=\"relative\" ref={dropdownRef}>\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(!isOpen)}\n        className=\"glass-input-base h-11 px-3 flex items-center justify-between gap-2 cursor-pointer transition-colors\"\n      >\n        <div className=\"flex items-center\">\n          <span className=\"text-sm text-[var(--glass-text-primary)] font-medium\">{selectedOption.label}</span>\n        </div>\n        <AppIcon name=\"chevronDown\" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n      </button>\n\n      {isOpen && (\n        <div className=\"glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3\">\n          <div className=\"grid grid-cols-2 gap-2\">\n            {options.map((option) => (\n              <button\n                key={option.value}\n                type=\"button\"\n                onClick={() => {\n                  onChange(option.value)\n                  setIsOpen(false)\n                }}\n                className={`flex items-center p-3 rounded-lg text-left transition-all ${\n                  value === option.value\n                    ? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'\n                    : 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'\n                }`}\n              >\n                <span className=\"font-medium text-sm\">{option.label}</span>\n              </button>\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/icons/AISparklesIcon.tsx",
    "content": "import { Sparkles } from 'lucide-react'\nimport { useId } from 'react'\n\ninterface AISparklesIconProps {\n  className?: string\n}\n\nexport default function AISparklesIcon({ className }: AISparklesIconProps) {\n  const gradientId = useId().replace(/:/g, '')\n\n  return (\n    <Sparkles className={className} stroke={`url(#${gradientId})`}>\n      <defs>\n        <linearGradient id={gradientId} x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">\n          <stop offset=\"0%\" stopColor=\"#06b6d4\" />\n          <stop offset=\"52%\" stopColor=\"#3b82f6\" />\n          <stop offset=\"100%\" stopColor=\"#8b5cf6\" />\n        </linearGradient>\n      </defs>\n    </Sparkles>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/icons/AppIcon.tsx",
    "content": "import { iconRegistry, type AppIconName } from './registry'\nimport type { LucideProps } from 'lucide-react'\n\nexport interface AppIconProps extends Omit<LucideProps, 'ref'> {\n  name: AppIconName\n}\n\nexport function AppIcon({ name, ...props }: AppIconProps) {\n  const IconComponent = iconRegistry[name]\n  if (!IconComponent) {\n    throw new Error(`Unknown AppIcon name: ${String(name)}`)\n  }\n  return <IconComponent {...props} />\n}\n"
  },
  {
    "path": "src/components/ui/icons/RatioPreviewIcon.tsx",
    "content": "import type { CSSProperties } from 'react'\n\ntype RatioPreviewVariant = 'surface' | 'surfaceStrong'\n\nexport interface RatioPreviewIconProps {\n  ratio: string\n  size?: number\n  selected?: boolean\n  variant?: RatioPreviewVariant\n  radiusClassName?: string\n}\n\nfunction resolveUnselectedClass(variant: RatioPreviewVariant): string {\n  if (variant === 'surface') {\n    return 'bg-[var(--glass-bg-surface)] shadow-[0_0_0_1px_rgba(163,181,214,0.25)]'\n  }\n  return 'bg-[var(--glass-bg-surface-strong)] shadow-[0_0_0_1px_rgba(163,181,214,0.24)]'\n}\n\nexport function RatioPreviewIcon({\n  ratio,\n  size = 24,\n  selected = false,\n  variant = 'surfaceStrong',\n  radiusClassName = 'rounded-[6px]',\n}: RatioPreviewIconProps) {\n  const [widthRatio, heightRatio] = ratio.split(':').map(Number)\n  if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio) || widthRatio <= 0 || heightRatio <= 0) {\n    throw new Error(`Invalid ratio for RatioPreviewIcon: ${ratio}`)\n  }\n\n  const maxDimension = size\n  let width = maxDimension\n  let height = maxDimension\n\n  if (widthRatio >= heightRatio) {\n    height = Math.round((maxDimension * heightRatio) / widthRatio)\n  } else {\n    width = Math.round((maxDimension * widthRatio) / heightRatio)\n  }\n\n  const style: CSSProperties = {\n    width,\n    height,\n    minWidth: width,\n    minHeight: height,\n  }\n\n  const toneClass = selected\n    ? 'bg-[var(--glass-tone-info-bg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'\n    : resolveUnselectedClass(variant)\n\n  return <span aria-hidden=\"true\" className={`${radiusClassName} block transition-all ${toneClass}`} style={style} />\n}\n"
  },
  {
    "path": "src/components/ui/icons/custom.tsx",
    "content": "import { forwardRef, type ForwardRefExoticComponent, type RefAttributes, type SVGProps } from 'react'\nimport { createLucideIcon as createLucideIconBase, type IconNode, type LucideProps } from 'lucide-react'\n\ntype CustomIconComponent = ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>\n\nfunction createLucideIcon(name: string, iconNode: IconNode) {\n  const keyedIconNode: IconNode = iconNode.map(([tag, attrs], index) => [\n    tag,\n    {\n      ...attrs,\n      key: `${name}-${index}`,\n    },\n  ])\n  return createLucideIconBase(name, keyedIconNode)\n}\n\nconst CustomIcon001Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M6 18L18 6M6 6l12 12\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon001Base = createLucideIcon('CustomIcon001', CustomIcon001Node)\nexport const CustomIcon001: CustomIconComponent = CustomIcon001Base\n\nconst CustomIcon002Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon002Base = createLucideIcon('CustomIcon002', CustomIcon002Node)\nexport const CustomIcon002: CustomIconComponent = CustomIcon002Base\n\nconst CustomIcon003Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon003Base = createLucideIcon('CustomIcon003', CustomIcon003Node)\nexport const CustomIcon003: CustomIconComponent = CustomIcon003Base\n\nconst CustomIcon004Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M5 13l4 4L19 7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon004Base = createLucideIcon('CustomIcon004', CustomIcon004Node)\nexport const CustomIcon004: CustomIconComponent = CustomIcon004Base\n\nconst CustomIcon005Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon005Base = createLucideIcon('CustomIcon005', CustomIcon005Node)\nexport const CustomIcon005: CustomIconComponent = CustomIcon005Base\n\nconst CustomIcon006Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 4v16m8-8H4\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon006Base = createLucideIcon('CustomIcon006', CustomIcon006Node)\nexport const CustomIcon006: CustomIconComponent = CustomIcon006Base\n\nconst CustomIcon007Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M19 9l-7 7-7-7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon007Base = createLucideIcon('CustomIcon007', CustomIcon007Node)\nexport const CustomIcon007: CustomIconComponent = CustomIcon007Base\n\nconst CustomIcon008Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon008Base = createLucideIcon('CustomIcon008', CustomIcon008Node)\nexport const CustomIcon008: CustomIconComponent = CustomIcon008Base\n\nconst CustomIcon009Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M13 10V3L4 14h7v7l9-11h-7z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon009Base = createLucideIcon('CustomIcon009', CustomIcon009Node)\nexport const CustomIcon009: CustomIconComponent = CustomIcon009Base\n\nconst CustomIcon010Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon010Base = createLucideIcon('CustomIcon010', CustomIcon010Node)\nexport const CustomIcon010: CustomIconComponent = CustomIcon010Base\n\nconst CustomIcon011Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon011Base = createLucideIcon('CustomIcon011', CustomIcon011Node)\nexport const CustomIcon011: CustomIconComponent = CustomIcon011Base\n\nconst CustomIcon012Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2.2\", d: \"M6 18L18 6M6 6l12 12\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon012Base = createLucideIcon('CustomIcon012', CustomIcon012Node)\nexport const CustomIcon012: CustomIconComponent = CustomIcon012Base\n\nconst CustomIcon013Node: IconNode = [\n  ['path', { d: \"M8 5v14l11-7z\", fill: \"currentColor\" }],\n]\nconst CustomIcon013Base = createLucideIcon('CustomIcon013', CustomIcon013Node)\nexport const CustomIcon013: CustomIconComponent = CustomIcon013Base\n\nconst CustomIcon014Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon014Base = createLucideIcon('CustomIcon014', CustomIcon014Node)\nexport const CustomIcon014: CustomIconComponent = CustomIcon014Base\n\nconst CustomIcon015Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M9 5l7 7-7 7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon015Base = createLucideIcon('CustomIcon015', CustomIcon015Node)\nexport const CustomIcon015: CustomIconComponent = CustomIcon015Base\n\nconst CustomIcon016Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon016Base = createLucideIcon('CustomIcon016', CustomIcon016Node)\nexport const CustomIcon016: CustomIconComponent = CustomIcon016Base\n\nconst CustomIcon017Node: IconNode = [\n  ['rect', { x: \"6\", y: \"5\", width: \"4\", height: \"14\", rx: \"1\", fill: \"currentColor\" }],\n  ['rect', { x: \"14\", y: \"5\", width: \"4\", height: \"14\", rx: \"1\", fill: \"currentColor\" }],\n]\nconst CustomIcon017Base = createLucideIcon('CustomIcon017', CustomIcon017Node)\nexport const CustomIcon017: CustomIconComponent = CustomIcon017Base\n\nconst CustomIcon018Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon018Base = createLucideIcon('CustomIcon018', CustomIcon018Node)\nexport const CustomIcon018: CustomIconComponent = CustomIcon018Base\n\nconst CustomIcon019Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon019Base = createLucideIcon('CustomIcon019', CustomIcon019Node)\nexport const CustomIcon019: CustomIconComponent = CustomIcon019Base\n\nconst CustomIcon020Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.8\", d: \"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon020Base = createLucideIcon('CustomIcon020', CustomIcon020Node)\nexport const CustomIcon020: CustomIconComponent = CustomIcon020Base\n\nconst CustomIcon021Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.8\", d: \"M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon021Base = createLucideIcon('CustomIcon021', CustomIcon021Node)\nexport const CustomIcon021: CustomIconComponent = CustomIcon021Base\n\nconst CustomIcon022Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon022Base = createLucideIcon('CustomIcon022', CustomIcon022Node)\nexport const CustomIcon022: CustomIconComponent = CustomIcon022Base\n\nconst CustomIcon023Node: IconNode = [\n  ['path', { fillRule: \"evenodd\", d: \"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\", clipRule: \"evenodd\", fill: \"currentColor\" }],\n]\nconst CustomIcon023Base = createLucideIcon('CustomIcon023', CustomIcon023Node)\nexport const CustomIcon023: CustomIconComponent = forwardRef<SVGSVGElement, LucideProps>(function CustomIcon023(props, ref) {\n  return <CustomIcon023Base ref={ref} {...props} viewBox=\"0 0 20 20\" />\n})\n\nconst CustomIcon024Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon024Base = createLucideIcon('CustomIcon024', CustomIcon024Node)\nexport const CustomIcon024: CustomIconComponent = CustomIcon024Base\n\nconst CustomIcon025Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"3\", d: \"M5 13l4 4L19 7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon025Base = createLucideIcon('CustomIcon025', CustomIcon025Node)\nexport const CustomIcon025: CustomIconComponent = CustomIcon025Base\n\nconst CustomIcon026Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 6v6m0 0v6m0-6h6m-6 0H6\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon026Base = createLucideIcon('CustomIcon026', CustomIcon026Node)\nexport const CustomIcon026: CustomIconComponent = CustomIcon026Base\n\nconst CustomIcon027Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon027Base = createLucideIcon('CustomIcon027', CustomIcon027Node)\nexport const CustomIcon027: CustomIconComponent = CustomIcon027Base\n\nconst CustomIcon028Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2.2\", d: \"M5 13l4 4L19 7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon028Base = createLucideIcon('CustomIcon028', CustomIcon028Node)\nexport const CustomIcon028: CustomIconComponent = CustomIcon028Base\n\nconst CustomIcon029Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon029Base = createLucideIcon('CustomIcon029', CustomIcon029Node)\nexport const CustomIcon029: CustomIconComponent = CustomIcon029Base\n\nconst CustomIcon030Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon030Base = createLucideIcon('CustomIcon030', CustomIcon030Node)\nexport const CustomIcon030: CustomIconComponent = CustomIcon030Base\n\nconst CustomIcon031Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2.3\", d: \"M5 13l4 4L19 7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon031Base = createLucideIcon('CustomIcon031', CustomIcon031Node)\nexport const CustomIcon031: CustomIconComponent = CustomIcon031Base\n\nconst CustomIcon032Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M4 6h16M4 12h16m-7 6h7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon032Base = createLucideIcon('CustomIcon032', CustomIcon032Node)\nexport const CustomIcon032: CustomIconComponent = CustomIcon032Base\n\nconst CustomIcon033Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\", fill: \"none\", stroke: \"currentColor\" }],\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon033Base = createLucideIcon('CustomIcon033', CustomIcon033Node)\nexport const CustomIcon033: CustomIconComponent = CustomIcon033Base\n\nconst CustomIcon034Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon034Base = createLucideIcon('CustomIcon034', CustomIcon034Node)\nexport const CustomIcon034: CustomIconComponent = CustomIcon034Base\n\nconst CustomIcon035Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2.4\", d: \"M5 13l4 4L19 7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon035Base = createLucideIcon('CustomIcon035', CustomIcon035Node)\nexport const CustomIcon035: CustomIconComponent = CustomIcon035Base\n\nconst CustomIcon036Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon036Base = createLucideIcon('CustomIcon036', CustomIcon036Node)\nexport const CustomIcon036: CustomIconComponent = CustomIcon036Base\n\nconst CustomIcon037Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M15 19l-7-7 7-7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon037Base = createLucideIcon('CustomIcon037', CustomIcon037Node)\nexport const CustomIcon037: CustomIconComponent = CustomIcon037Base\n\nconst CustomIcon038Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.7\", d: \"M4 8l8-4 8 4-8 4-8-4z\", fill: \"none\", stroke: \"currentColor\" }],\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.7\", d: \"M4 8v8l8 4 8-4V8\", fill: \"none\", stroke: \"currentColor\" }],\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.7\", d: \"M12 12v8\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon038Base = createLucideIcon('CustomIcon038', CustomIcon038Node)\nexport const CustomIcon038: CustomIconComponent = CustomIcon038Base\n\nconst CustomIcon039Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon039Base = createLucideIcon('CustomIcon039', CustomIcon039Node)\nexport const CustomIcon039: CustomIconComponent = CustomIcon039Base\n\nconst CustomIcon040Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon040Base = createLucideIcon('CustomIcon040', CustomIcon040Node)\nexport const CustomIcon040: CustomIconComponent = CustomIcon040Base\n\nconst CustomIcon041Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon041Base = createLucideIcon('CustomIcon041', CustomIcon041Node)\nexport const CustomIcon041: CustomIconComponent = CustomIcon041Base\n\nconst CustomIcon042Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2.5\", d: \"M5 13l4 4L19 7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon042Base = createLucideIcon('CustomIcon042', CustomIcon042Node)\nexport const CustomIcon042: CustomIconComponent = CustomIcon042Base\n\nconst CustomIcon043Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.5\", d: \"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon043Base = createLucideIcon('CustomIcon043', CustomIcon043Node)\nexport const CustomIcon043: CustomIconComponent = CustomIcon043Base\n\nconst CustomIcon044Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon044Base = createLucideIcon('CustomIcon044', CustomIcon044Node)\nexport const CustomIcon044: CustomIconComponent = CustomIcon044Base\n\nconst CustomIcon045Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 1v11m0 0a4 4 0 004-4V5a4 4 0 00-8 0v3a4 4 0 004 4zm-7 3a7 7 0 0014 0M9 21h6\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon045Base = createLucideIcon('CustomIcon045', CustomIcon045Node)\nexport const CustomIcon045: CustomIconComponent = CustomIcon045Base\n\nconst CustomIcon046Node: IconNode = [\n  ['circle', { cx: \"12\", cy: \"12\", r: \"10\", stroke: \"currentColor\", strokeWidth: \"4\", fill: \"none\" }],\n  ['path', { fill: \"currentColor\", d: \"M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z\" }],\n]\nconst CustomIcon046Base = createLucideIcon('CustomIcon046', CustomIcon046Node)\nexport const CustomIcon046: CustomIconComponent = CustomIcon046Base\n\nconst CustomIcon047Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M9 5v10l8 4V1L9 5zM5 9v6M3 10v4\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon047Base = createLucideIcon('CustomIcon047', CustomIcon047Node)\nexport const CustomIcon047: CustomIconComponent = CustomIcon047Base\n\nconst CustomIcon048Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M9 12h6M9 16h6M8 7h8a2 2 0 012 2v10l-3-2-3 2-3-2-3 2V9a2 2 0 012-2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon048Base = createLucideIcon('CustomIcon048', CustomIcon048Node)\nexport const CustomIcon048: CustomIconComponent = CustomIcon048Base\n\nconst CustomIcon049Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\", fill: \"none\", stroke: \"currentColor\" }],\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon049Base = createLucideIcon('CustomIcon049', CustomIcon049Node)\nexport const CustomIcon049: CustomIconComponent = CustomIcon049Base\n\nconst CustomIcon050Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M15.536 8.464a5 5 0 010 7.072M12 6v12m0 0l-4-4m4 4l4-4M19.07 4.93a10 10 0 010 14.14M5 12a7 7 0 0114 0\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon050Base = createLucideIcon('CustomIcon050', CustomIcon050Node)\nexport const CustomIcon050: CustomIconComponent = CustomIcon050Base\n\nconst CustomIcon051Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon051Base = createLucideIcon('CustomIcon051', CustomIcon051Node)\nexport const CustomIcon051: CustomIconComponent = CustomIcon051Base\n\nconst CustomIcon052Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.5\", d: \"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\", fill: \"none\", stroke: \"currentColor\" }],\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.5\", d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon052Base = createLucideIcon('CustomIcon052', CustomIcon052Node)\nexport const CustomIcon052: CustomIconComponent = CustomIcon052Base\n\nconst CustomIcon053Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.5\", d: \"M9 14l6-6m-5.5.5h.01m4.99 5h.01M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16l3.5-2 3.5 2 3.5-2 3.5 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon053Base = createLucideIcon('CustomIcon053', CustomIcon053Node)\nexport const CustomIcon053: CustomIconComponent = CustomIcon053Base\n\nconst CustomIcon054Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon054Base = createLucideIcon('CustomIcon054', CustomIcon054Node)\nexport const CustomIcon054: CustomIconComponent = CustomIcon054Base\n\nconst CustomIcon055Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon055Base = createLucideIcon('CustomIcon055', CustomIcon055Node)\nexport const CustomIcon055: CustomIconComponent = CustomIcon055Base\n\nconst CustomIcon056Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M20 12H4\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon056Base = createLucideIcon('CustomIcon056', CustomIcon056Node)\nexport const CustomIcon056: CustomIconComponent = CustomIcon056Base\n\nconst CustomIcon057Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.5\", d: \"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon057Base = createLucideIcon('CustomIcon057', CustomIcon057Node)\nexport const CustomIcon057: CustomIconComponent = CustomIcon057Base\n\nconst CustomIcon058Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M3 5h18v14H3zM9 19h6\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon058Base = createLucideIcon('CustomIcon058', CustomIcon058Node)\nexport const CustomIcon058: CustomIconComponent = CustomIcon058Base\n\nconst CustomIcon059Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 3a9 9 0 100 18h1a3 3 0 000-6h-1a3 3 0 010-6h1a3 3 0 100-6h-1z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon059Base = createLucideIcon('CustomIcon059', CustomIcon059Node)\nexport const CustomIcon059: CustomIconComponent = CustomIcon059Base\n\nconst CustomIcon060Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M13 7l5 5m0 0l-5 5m5-5H6\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon060Base = createLucideIcon('CustomIcon060', CustomIcon060Node)\nexport const CustomIcon060: CustomIconComponent = CustomIcon060Base\n\nconst CustomIcon061Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.7\", d: \"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\", fill: \"none\", stroke: \"currentColor\" }],\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.7\", d: \"M15 12a3 3 0 11-6 0 3 3 0 016 0z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon061Base = createLucideIcon('CustomIcon061', CustomIcon061Node)\nexport const CustomIcon061: CustomIconComponent = CustomIcon061Base\n\nconst CustomIcon062Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 3l8 7-8 11L4 10l8-7z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon062Base = createLucideIcon('CustomIcon062', CustomIcon062Node)\nexport const CustomIcon062: CustomIconComponent = CustomIcon062Base\n\nconst CustomIcon063Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.5\", d: \"M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon063Base = createLucideIcon('CustomIcon063', CustomIcon063Node)\nexport const CustomIcon063: CustomIconComponent = CustomIcon063Base\n\nconst CustomIcon064Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M3 20h18M7 20l4-7 2 3 3-5 5 9H7z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon064Base = createLucideIcon('CustomIcon064', CustomIcon064Node)\nexport const CustomIcon064: CustomIconComponent = CustomIcon064Base\n\nconst CustomIcon065Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon065Base = createLucideIcon('CustomIcon065', CustomIcon065Node)\nexport const CustomIcon065: CustomIconComponent = CustomIcon065Base\n\nconst CustomIcon066Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon066Base = createLucideIcon('CustomIcon066', CustomIcon066Node)\nexport const CustomIcon066: CustomIconComponent = CustomIcon066Base\n\nconst CustomIcon067Node: IconNode = [\n  ['path', { fillRule: \"evenodd\", d: \"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z\", clipRule: \"evenodd\", fill: \"currentColor\" }],\n]\nconst CustomIcon067Base = createLucideIcon('CustomIcon067', CustomIcon067Node)\nexport const CustomIcon067: CustomIconComponent = forwardRef<SVGSVGElement, LucideProps>(function CustomIcon067(props, ref) {\n  return <CustomIcon067Base ref={ref} {...props} viewBox=\"0 0 20 20\" />\n})\n\nconst CustomIcon068Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.8\", d: \"M7 3h7l5 5v13a1 1 0 01-1 1H7a2 2 0 01-2-2V5a2 2 0 012-2z\", fill: \"none\", stroke: \"currentColor\" }],\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.8\", d: \"M14 3v5h5M9 12h6M9 16h6\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon068Base = createLucideIcon('CustomIcon068', CustomIcon068Node)\nexport const CustomIcon068: CustomIconComponent = CustomIcon068Base\n\nconst CustomIcon069Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.5\", d: \"M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon069Base = createLucideIcon('CustomIcon069', CustomIcon069Node)\nexport const CustomIcon069: CustomIconComponent = CustomIcon069Base\n\nconst CustomIcon070Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z\", fill: \"none\", stroke: \"currentColor\" }],\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon070Base = createLucideIcon('CustomIcon070', CustomIcon070Node)\nexport const CustomIcon070: CustomIconComponent = CustomIcon070Base\n\nconst CustomIcon071Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M14 5l7 7m0 0l-7 7m7-7H3\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon071Base = createLucideIcon('CustomIcon071', CustomIcon071Node)\nexport const CustomIcon071: CustomIconComponent = CustomIcon071Base\n\nconst CustomIcon072Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M9 17v-6m3 6V7m3 10v-3M5 20h14a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v14a1 1 0 001 1z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon072Base = createLucideIcon('CustomIcon072', CustomIcon072Node)\nexport const CustomIcon072: CustomIconComponent = CustomIcon072Base\n\nconst CustomIcon073Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.5\", d: \"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon073Base = createLucideIcon('CustomIcon073', CustomIcon073Node)\nexport const CustomIcon073: CustomIconComponent = CustomIcon073Base\n\nconst CustomIcon074Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2.4\", d: \"M6 18L18 6M6 6l12 12\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon074Base = createLucideIcon('CustomIcon074', CustomIcon074Node)\nexport const CustomIcon074: CustomIconComponent = CustomIcon074Base\n\nconst CustomIcon075Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"1.8\", d: \"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M4 18h9a2 2 0 002-2V8a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon075Base = createLucideIcon('CustomIcon075', CustomIcon075Node)\nexport const CustomIcon075: CustomIconComponent = CustomIcon075Base\n\nconst CustomIcon076Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2.2\", d: \"M9 5l7 7-7 7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon076Base = createLucideIcon('CustomIcon076', CustomIcon076Node)\nexport const CustomIcon076: CustomIconComponent = CustomIcon076Base\n\nconst CustomIcon077Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2.2\", d: \"M12 4v16m8-8H4\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon077Base = createLucideIcon('CustomIcon077', CustomIcon077Node)\nexport const CustomIcon077: CustomIconComponent = CustomIcon077Base\n\nconst CustomIcon078Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2.1\", d: \"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon078Base = createLucideIcon('CustomIcon078', CustomIcon078Node)\nexport const CustomIcon078: CustomIconComponent = CustomIcon078Base\n\nconst CustomIcon079Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M5 15l7-7 7 7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon079Base = createLucideIcon('CustomIcon079', CustomIcon079Node)\nexport const CustomIcon079: CustomIconComponent = CustomIcon079Base\n\nconst CustomIcon080Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M14.121 14.121L4.5 4.5m9.621 9.621l2.379 2.379m-2.379-2.379L21 7.242M6.75 6.75l-.75-.75M6 18l4.5-4.5m0 0L18 21\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon080Base = createLucideIcon('CustomIcon080', CustomIcon080Node)\nexport const CustomIcon080: CustomIconComponent = CustomIcon080Base\n\nconst CustomIcon081Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z\", fill: \"none\", stroke: \"currentColor\" }],\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M21 12a9 9 0 11-18 0 9 9 0 0118 0z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon081Base = createLucideIcon('CustomIcon081', CustomIcon081Node)\nexport const CustomIcon081: CustomIconComponent = CustomIcon081Base\n\nconst CustomIcon082Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon082Base = createLucideIcon('CustomIcon082', CustomIcon082Node)\nexport const CustomIcon082: CustomIconComponent = CustomIcon082Base\n\nconst CustomIcon083Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon083Base = createLucideIcon('CustomIcon083', CustomIcon083Node)\nexport const CustomIcon083: CustomIconComponent = CustomIcon083Base\n\nconst CustomIcon084Node: IconNode = [\n  ['path', { d: \"M8 6h3v12H8zM13 6h3v12h-3z\", fill: \"currentColor\" }],\n]\nconst CustomIcon084Base = createLucideIcon('CustomIcon084', CustomIcon084Node)\nexport const CustomIcon084: CustomIconComponent = CustomIcon084Base\n\nconst CustomIcon085Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon085Base = createLucideIcon('CustomIcon085', CustomIcon085Node)\nexport const CustomIcon085: CustomIconComponent = CustomIcon085Base\n\nconst CustomIcon086Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon086Base = createLucideIcon('CustomIcon086', CustomIcon086Node)\nexport const CustomIcon086: CustomIconComponent = CustomIcon086Base\n\nconst CustomIcon087Node: IconNode = [\n  ['path', { stroke: \"url(#icon-gradient)\", strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\", fill: \"none\" }],\n]\nconst CustomIcon087Base = createLucideIcon('CustomIcon087', CustomIcon087Node)\nexport const CustomIcon087: CustomIconComponent = CustomIcon087Base\n\nconst CustomIcon088Node: IconNode = [\n  ['path', { stroke: \"url(#icon-gradient)\", strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10\", fill: \"none\" }],\n]\nconst CustomIcon088Base = createLucideIcon('CustomIcon088', CustomIcon088Node)\nexport const CustomIcon088: CustomIconComponent = CustomIcon088Base\n\nconst CustomIcon089Node: IconNode = [\n  ['path', { stroke: \"url(#icon-gradient)\", strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\", fill: \"none\" }],\n]\nconst CustomIcon089Base = createLucideIcon('CustomIcon089', CustomIcon089Node)\nexport const CustomIcon089: CustomIconComponent = CustomIcon089Base\n\nconst CustomIcon090Node: IconNode = [\n  ['path', { stroke: \"url(#icon-gradient)\", strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\", fill: \"none\" }],\n]\nconst CustomIcon090Base = createLucideIcon('CustomIcon090', CustomIcon090Node)\nexport const CustomIcon090: CustomIconComponent = CustomIcon090Base\n\nconst CustomIcon091Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon091Base = createLucideIcon('CustomIcon091', CustomIcon091Node)\nexport const CustomIcon091: CustomIconComponent = CustomIcon091Base\n\nconst CustomIcon092Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon092Base = createLucideIcon('CustomIcon092', CustomIcon092Node)\nexport const CustomIcon092: CustomIconComponent = CustomIcon092Base\n\nconst CustomIcon093Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon093Base = createLucideIcon('CustomIcon093', CustomIcon093Node)\nexport const CustomIcon093: CustomIconComponent = CustomIcon093Base\n\nconst CustomIcon094Node: IconNode = [\n  ['path', { fillRule: \"evenodd\", d: \"M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z\", clipRule: \"evenodd\", fill: \"currentColor\" }],\n]\nconst CustomIcon094Base = createLucideIcon('CustomIcon094', CustomIcon094Node)\nexport const CustomIcon094: CustomIconComponent = CustomIcon094Base\n\nconst CustomIcon095Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon095Base = createLucideIcon('CustomIcon095', CustomIcon095Node)\nexport const CustomIcon095: CustomIconComponent = CustomIcon095Base\n\nconst CustomIcon096Node: IconNode = [\n  ['path', { stroke: \"currentColor\", strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 9v4m0 4h.01M5.5 19h13a1 1 0 00.87-1.5l-6.5-11.5a1 1 0 00-1.74 0L4.63 17.5A1 1 0 005.5 19z\", fill: \"none\" }],\n]\nconst CustomIcon096Base = createLucideIcon('CustomIcon096', CustomIcon096Node)\nexport const CustomIcon096: CustomIconComponent = CustomIcon096Base\n\nconst CustomIcon097Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M12 9v4m0 4h.01M10.29 3.86l-8.18 14.4A1 1 0 003 20h18a1 1 0 00.89-1.74l-8.18-14.4a1 1 0 00-1.74 0z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon097Base = createLucideIcon('CustomIcon097', CustomIcon097Node)\nexport const CustomIcon097: CustomIconComponent = CustomIcon097Base\n\nconst CustomIcon098Node: IconNode = [\n  ['path', { strokeLinecap: \"round\", strokeLinejoin: \"round\", strokeWidth: \"2\", d: \"M13 16h-1v-4h-1m1-4h.01M12 3a9 9 0 100 18 9 9 0 000-18z\", fill: \"none\", stroke: \"currentColor\" }],\n]\nconst CustomIcon098Base = createLucideIcon('CustomIcon098', CustomIcon098Node)\nexport const CustomIcon098: CustomIconComponent = CustomIcon098Base\n\nexport const IconGradientDefs = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(function IconGradientDefs(props, ref) {\n  return (\n    <svg ref={ref} {...props}>\n      <defs>\n                                <linearGradient id=\"icon-gradient\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">\n                                  <stop offset=\"0%\" stopColor=\"#3b82f6\" />\n                                  <stop offset=\"100%\" stopColor=\"#06b6d4\" />\n                                </linearGradient>\n                              </defs>\n    </svg>\n  )\n})\n\nexport const customIcons = {\n  close: CustomIcon001,\n  edit: CustomIcon002,\n  trash: CustomIcon003,\n  check: CustomIcon004,\n  refresh: CustomIcon005,\n  plus: CustomIcon006,\n  chevronDown: CustomIcon007,\n  mic: CustomIcon008,\n  bolt: CustomIcon009,\n  image: CustomIcon010,\n  sparkles: CustomIcon011,\n  closeSm: CustomIcon012,\n  play: CustomIcon013,\n  sparklesAlt: CustomIcon014,\n  chevronRight: CustomIcon015,\n  alert: CustomIcon016,\n  pause: CustomIcon017,\n  folderCards: CustomIcon018,\n  upload: CustomIcon019,\n  imageAlt: CustomIcon020,\n  user: CustomIcon021,\n  copy: CustomIcon022,\n  checkSolid: CustomIcon023,\n  video: CustomIcon024,\n  checkSm: CustomIcon025,\n  plusAlt: CustomIcon026,\n  editSquare: CustomIcon027,\n  checkTiny: CustomIcon028,\n  info: CustomIcon029,\n  searchPlus: CustomIcon030,\n  checkXs: CustomIcon031,\n  menu: CustomIcon032,\n  eye: CustomIcon033,\n  eyeOff: CustomIcon034,\n  checkDot: CustomIcon035,\n  bookOpen: CustomIcon036,\n  chevronLeft: CustomIcon037,\n  package: CustomIcon038,\n  idea: CustomIcon039,\n  userAlt: CustomIcon040,\n  globe2: CustomIcon041,\n  checkMicro: CustomIcon042,\n  imagePreview: CustomIcon043,\n  fileText: CustomIcon044,\n  micOutline: CustomIcon045,\n  loader: CustomIcon046,\n  cube: CustomIcon047,\n  bookmark: CustomIcon048,\n  settingsHex: CustomIcon049,\n  audioWave: CustomIcon050,\n  externalLink: CustomIcon051,\n  settingsHexAlt: CustomIcon052,\n  receipt: CustomIcon053,\n  logout: CustomIcon054,\n  filter: CustomIcon055,\n  minus: CustomIcon056,\n  file: CustomIcon057,\n  monitor: CustomIcon058,\n  coins: CustomIcon059,\n  arrowRight: CustomIcon060,\n  settingsHexMinor: CustomIcon061,\n  diamond: CustomIcon062,\n  ideaAlt: CustomIcon063,\n  imageLandscape: CustomIcon064,\n  lock: CustomIcon065,\n  imageEdit: CustomIcon066,\n  closeSolid: CustomIcon067,\n  fileFold: CustomIcon068,\n  userCircle: CustomIcon069,\n  volumeOff: CustomIcon070,\n  arrowRightWide: CustomIcon071,\n  chart: CustomIcon072,\n  videoAlt: CustomIcon073,\n  closeMd: CustomIcon074,\n  videoWide: CustomIcon075,\n  chevronRightMd: CustomIcon076,\n  plusMd: CustomIcon077,\n  trashAlt: CustomIcon078,\n  chevronUp: CustomIcon079,\n  wandOff: CustomIcon080,\n  playCircle: CustomIcon081,\n  clipboardCheck: CustomIcon082,\n  cloudUpload: CustomIcon083,\n  pauseSolid: CustomIcon084,\n  download: CustomIcon085,\n  folder: CustomIcon086,\n  statsBarGradient: CustomIcon087,\n  statsEpisodeGradient: CustomIcon088,\n  statsImageGradient: CustomIcon089,\n  statsVideoGradient: CustomIcon090,\n  statsBar: CustomIcon091,\n  clock: CustomIcon092,\n  search: CustomIcon093,\n  badgeCheck: CustomIcon094,\n  searchAdd: CustomIcon095,\n  alertSolid: CustomIcon096,\n  alertOutline: CustomIcon097,\n  infoCircle: CustomIcon098,\n} as const\n"
  },
  {
    "path": "src/components/ui/icons/index.ts",
    "content": "export { AppIcon, type AppIconProps } from './AppIcon'\nexport { iconRegistry, type AppIconName } from './registry'\nexport { IconGradientDefs } from './custom'\nexport { RatioPreviewIcon, type RatioPreviewIconProps } from './RatioPreviewIcon'\nexport type { LucideIcon } from 'lucide-react'\n"
  },
  {
    "path": "src/components/ui/icons/registry.ts",
    "content": "import type { LucideIcon } from 'lucide-react'\nimport {\n  ArrowDownCircle,\n  ArrowRight,\n  AudioLines,\n  BadgeCheck,\n  BarChart3,\n  BookOpen,\n  Bookmark,\n  Box,\n  Brain,\n  ChartColumn,\n  Check,\n  ChevronDown,\n  ChevronLeft,\n  ChevronRight,\n  ChevronUp,\n  CircleUser,\n  Clapperboard,\n  ClipboardCheck,\n  Clock3,\n  CloudUpload,\n  Coins,\n  Copy,\n  Cpu,\n  Diamond,\n  Download,\n  ExternalLink,\n  Eye,\n  EyeOff,\n  FileText,\n  Film,\n  Filter,\n  Folder,\n  FolderHeart,\n  FolderOpen,\n  Globe,\n  GripVertical,\n  Image,\n  ImagePlus,\n  Info,\n  Lightbulb,\n  Link2,\n  Loader2,\n  Lock,\n  LogOut,\n  Menu,\n  Mic,\n  Minus,\n  Monitor,\n  Pause,\n  Pencil,\n  Play,\n  PlayCircle,\n  Plus,\n  Receipt,\n  RefreshCw,\n  Search,\n  Settings,\n  Sparkles,\n  SquarePen,\n  Trash2,\n  TriangleAlert,\n  Unplug,\n  Undo2,\n  Upload,\n  UserRound,\n  UserRoundCog,\n  UsersRound,\n  Video,\n  VolumeX,\n  WandSparkles,\n  X,\n  Zap,\n} from 'lucide-react'\nimport { customIcons } from './custom'\n\nexport const iconRegistry = {\n  ...customIcons,\n  globe: Globe,\n  folderHeart: FolderHeart,\n  unplug: Unplug,\n  userRoundCog: UserRoundCog,\n  close: X,\n  closeSm: X,\n  closeMd: X,\n  closeSolid: X,\n  edit: Pencil,\n  editSquare: SquarePen,\n  trash: Trash2,\n  trashAlt: Trash2,\n  check: Check,\n  checkSolid: Check,\n  checkSm: Check,\n  checkTiny: Check,\n  checkXs: Check,\n  checkDot: Check,\n  checkMicro: Check,\n  refresh: RefreshCw,\n  plus: Plus,\n  plusAlt: Plus,\n  plusMd: Plus,\n  chevronDown: ChevronDown,\n  chevronRight: ChevronRight,\n  chevronRightMd: ChevronRight,\n  chevronLeft: ChevronLeft,\n  chevronUp: ChevronUp,\n  mic: Mic,\n  micOutline: Mic,\n  bolt: Zap,\n  image: Image,\n  imageAlt: Image,\n  imagePreview: Image,\n  imageEdit: ImagePlus,\n  imageLandscape: Image,\n  video: Video,\n  videoAlt: Video,\n  videoWide: Video,\n  sparkles: Sparkles,\n  sparklesAlt: WandSparkles,\n  alert: TriangleAlert,\n  alertSolid: TriangleAlert,\n  alertOutline: TriangleAlert,\n  pause: Pause,\n  pauseSolid: Pause,\n  play: Play,\n  playCircle: PlayCircle,\n  search: Search,\n  searchAdd: Search,\n  searchPlus: Search,\n  info: Info,\n  infoCircle: Info,\n  folder: Folder,\n  folderCards: Folder,\n  upload: Upload,\n  undo: Undo2,\n  copy: Copy,\n  user: UserRound,\n  userAlt: UserRound,\n  usersRound: UsersRound,\n  userCircle: CircleUser,\n  package: Box,\n  cube: Box,\n  idea: Lightbulb,\n  ideaAlt: Lightbulb,\n  globe2: Globe,\n  file: FileText,\n  fileText: FileText,\n  fileFold: FileText,\n  eye: Eye,\n  eyeOff: EyeOff,\n  bookOpen: BookOpen,\n  menu: Menu,\n  loader: Loader2,\n  settingsHex: Settings,\n  settingsHexAlt: Settings,\n  settingsHexMinor: Settings,\n  audioWave: AudioLines,\n  externalLink: ExternalLink,\n  receipt: Receipt,\n  download: Download,\n  logout: LogOut,\n  filter: Filter,\n  minus: Minus,\n  monitor: Monitor,\n  coins: Coins,\n  arrowRight: ArrowRight,\n  arrowRightWide: ArrowRight,\n  diamond: Diamond,\n  lock: Lock,\n  link: Link2,\n  badgeCheck: BadgeCheck,\n  cloudUpload: CloudUpload,\n  clipboardCheck: ClipboardCheck,\n  clock: Clock3,\n  chart: ChartColumn,\n  statsBar: ChartColumn,\n  volumeOff: VolumeX,\n  wandOff: WandSparkles,\n  bookmark: Bookmark,\n  arrowDownCircle: ArrowDownCircle,\n  barChart: BarChart3,\n  brain: Brain,\n  clapperboard: Clapperboard,\n  cpu: Cpu,\n  film: Film,\n  folderOpen: FolderOpen,\n  gripVertical: GripVertical,\n} as const satisfies Record<string, LucideIcon>\n\nexport type AppIconName = keyof typeof iconRegistry\n"
  },
  {
    "path": "src/components/ui/model-dropdown-innovative.tsx",
    "content": "'use client'\n\nimport React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport { AppIcon } from '@/components/ui/icons'\nimport type { ModelCapabilityOption, CapabilityFieldDefinition } from './config-modals/ModelCapabilityDropdown'\nimport type { CapabilityValue } from '@/lib/model-config-contract'\nexport interface ModelDropdownTestProps {\n    models: ModelCapabilityOption[]\n    value: string | undefined\n    onModelChange: (modelKey: string) => void\n    capabilityFields: CapabilityFieldDefinition[]\n    capabilityOverrides: Record<string, CapabilityValue>\n    onCapabilityChange: (field: string, rawValue: string, sample: CapabilityValue) => void\n    placeholder?: string\n}\n\nconst VIEWPORT_EDGE_GAP = 8\nconst DEFAULT_MAX_HEIGHT = 400\n\nfunction useDropdown(isOpen: boolean, setIsOpen: (val: boolean) => void, alignRight: boolean = false) {\n    const triggerRef = useRef<HTMLButtonElement>(null)\n    const panelRef = useRef<HTMLDivElement>(null)\n    const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})\n\n    const updatePosition = useCallback(() => {\n        if (!triggerRef.current) return\n        const rect = triggerRef.current.getBoundingClientRect()\n        const viewportHeight = window.innerHeight || document.documentElement.clientHeight\n        const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP\n        const spaceAbove = rect.top - VIEWPORT_EDGE_GAP\n\n        let openUpward = false\n        let currentMaxHeight = DEFAULT_MAX_HEIGHT\n\n        if (spaceBelow < 250 && spaceAbove > spaceBelow) {\n            openUpward = true\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)\n        } else {\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)\n        }\n\n        const width = Math.max(rect.width, 240)\n        let left = rect.left\n        if (alignRight) {\n            left = rect.right - width\n        }\n\n        setPanelStyle({\n            position: 'fixed',\n            left,\n            width,\n            maxHeight: currentMaxHeight,\n            ...(openUpward\n                ? { bottom: viewportHeight - rect.top + 6 }\n                : { top: rect.bottom + 6 }),\n            zIndex: 9999\n        })\n    }, [alignRight])\n\n    useEffect(() => {\n        function handleClickOutside(e: MouseEvent) {\n            const target = e.target as Node\n            if (triggerRef.current?.contains(target)) return\n            if (panelRef.current?.contains(target)) return\n            setIsOpen(false)\n        }\n        document.addEventListener('mousedown', handleClickOutside)\n        return () => document.removeEventListener('mousedown', handleClickOutside)\n    }, [setIsOpen])\n\n    useLayoutEffect(() => {\n        if (!isOpen) return\n        updatePosition()\n        window.addEventListener('resize', updatePosition)\n        window.addEventListener('scroll', updatePosition, true)\n        return () => {\n            window.removeEventListener('resize', updatePosition)\n            window.removeEventListener('scroll', updatePosition, true)\n        }\n    }, [isOpen, updatePosition])\n\n    return { triggerRef, panelRef, panelStyle }\n}\n\nfunction resolveParamSummary(fields: CapabilityFieldDefinition[], overrides: Record<string, CapabilityValue>) {\n    return fields.map(def => {\n        const val = overrides[def.field] !== undefined ? String(overrides[def.field]) : String(def.options[0] || '')\n        if (def.field === 'duration') return `${val}s`\n        return val\n    }).filter(Boolean).join(' · ')\n}\n\n\n// ============================================================================\n// V6: The Split Toolbar (Deconstructed Controls)\n// Breaks the monolithic dropdown into two separate context actions. No massive popover.\n// ============================================================================\nexport function ModelInnovativeV6(props: ModelDropdownTestProps) {\n    const [modelOpen, setModelOpen] = useState(false)\n    const [paramOpen, setParamOpen] = useState(false)\n\n    const { triggerRef: modelTrigger, panelRef: modelPanel, panelStyle: modelStyle } = useDropdown(modelOpen, setModelOpen)\n    const { triggerRef: paramTrigger, panelRef: paramPanel, panelStyle: paramStyle } = useDropdown(paramOpen, setParamOpen, true)\n\n    const activeModel = props.models.find(m => m.value === props.value)\n    const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)\n\n    return (\n        <div className=\"flex items-center bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-xl shadow-sm backdrop-blur-md\">\n            {/* Left Button: Model Selection */}\n            <button\n                ref={modelTrigger}\n                onClick={() => { setModelOpen(!modelOpen); setParamOpen(false) }}\n                className={`flex-1 flex items-center justify-between px-4 py-3 transition-colors rounded-l-xl hover:bg-black/5 dark:hover:bg-white/5 ${modelOpen ? 'bg-black/5 dark:bg-white/5' : ''}`}\n            >\n                <div className=\"flex flex-col items-start min-w-0 pr-2\">\n                    <span className=\"text-[11px] font-bold text-[var(--glass-text-tertiary)] uppercase tracking-wider mb-0.5\">模型 Model</span>\n                    <span className=\"text-[14px] font-semibold text-[var(--glass-text-primary)] truncate\">\n                        {activeModel ? activeModel.label : props.placeholder}\n                    </span>\n                </div>\n                <AppIcon name=\"chevronDown\" className=\"w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0\" />\n            </button>\n\n            {/* Divider */}\n            <div className=\"w-[1px] h-10 bg-[var(--glass-stroke-base)]\" />\n\n            {/* Right Button: Param Configuration */}\n            <button\n                ref={paramTrigger}\n                onClick={() => { setParamOpen(!paramOpen); setModelOpen(false) }}\n                className={`flex-1 flex items-center justify-between px-4 py-3 transition-colors rounded-r-xl hover:bg-black/5 dark:hover:bg-white/5 ${paramOpen ? 'bg-black/5 dark:bg-white/5' : ''}`}\n                disabled={props.capabilityFields.length === 0}\n            >\n                <div className=\"flex flex-col items-start min-w-0 pr-2\">\n                    <span className=\"text-[11px] font-bold text-[var(--glass-text-tertiary)] uppercase tracking-wider mb-0.5\">参数 Params</span>\n                    <span className={`text-[14px] font-semibold truncate ${props.capabilityFields.length === 0 ? 'text-[var(--glass-text-tertiary)]' : 'text-blue-500'}`}>\n                        {props.capabilityFields.length === 0 ? '不可配置' : (summary || '配置')}\n                    </span>\n                </div>\n                <AppIcon name=\"chevronDown\" className=\"w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0\" />\n            </button>\n\n            {/* Portals */}\n            {modelOpen && createPortal(\n                <div ref={modelPanel} style={modelStyle} className=\"glass-surface-modal rounded-xl shadow-lg border border-[var(--glass-stroke-base)] p-2\">\n                    {props.models.map(m => (\n                        <button\n                            key={m.value}\n                            onClick={() => { props.onModelChange(m.value); setModelOpen(false) }}\n                            className=\"w-full text-left px-3 py-2.5 rounded-lg hover:bg-[var(--glass-bg-hover)] flex items-center justify-between transition-colors\"\n                        >\n                            <span className=\"text-[14px] font-medium text-[var(--glass-text-primary)]\">{m.label}</span>\n                            {m.value === props.value && <AppIcon name=\"check\" className=\"w-4 h-4 text-blue-500\" />}\n                        </button>\n                    ))}\n                </div>, document.body\n            )}\n            {paramOpen && props.capabilityFields.length > 0 && createPortal(\n                <div ref={paramPanel} style={paramStyle} className=\"glass-surface-modal rounded-xl shadow-lg border border-[var(--glass-stroke-base)] p-4 space-y-4\">\n                    {props.capabilityFields.map(field => {\n                        const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n                        return (\n                            <div key={field.field}>\n                                <div className=\"text-[12px] font-medium text-[var(--glass-text-secondary)] mb-2\">{field.label || field.field}</div>\n                                <div className=\"flex gap-2\">\n                                    {field.options.map(opt => {\n                                        const s = String(opt)\n                                        const active = s === val\n                                        return (\n                                            <button\n                                                key={s}\n                                                onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                className={`flex-1 px-2 py-1.5 text-[13px] rounded-lg transition-colors border ${active ? 'bg-blue-50/50 dark:bg-blue-900/30 border-blue-500 text-blue-600 dark:text-blue-400 font-semibold' : 'bg-transparent border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] hover:hover:bg-[var(--glass-bg-hover)]'}`}\n                                            >\n                                                {s}\n                                            </button>\n                                        )\n                                    })}\n                                </div>\n                            </div>\n                        )\n                    })}\n                </div>, document.body\n            )}\n        </div>\n    )\n}\n\n// ============================================================================\n// V7: The Inline Canvas Expandable (No Overlays, Document Flow)\n// Pushes content down naturally. Perfect for form wizards.\n// ============================================================================\nexport function ModelInnovativeV7(props: ModelDropdownTestProps) {\n    const [isExpanded, setIsExpanded] = useState(false)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    return (\n        <div className=\"bg-[var(--glass-bg-surface-strong)] rounded-2xl border border-[var(--glass-stroke-subtle)] overflow-hidden transition-all duration-300 shadow-sm\">\n            <button\n                onClick={() => setIsExpanded(!isExpanded)}\n                className=\"w-full flex items-center justify-between p-4 bg-transparent outline-none focus:outline-none\"\n            >\n                <div className=\"flex items-center gap-4\">\n                    <div className=\"w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center text-blue-600 dark:text-blue-400\">\n                        <AppIcon name=\"cpu\" className=\"w-5 h-5\" />\n                    </div>\n                    <div className=\"text-left flex flex-col\">\n                        <span className=\"text-[15px] font-bold text-[var(--glass-text-primary)]\">\n                            {activeModel ? activeModel.label : '未选择模型'}\n                        </span>\n                        <span className=\"text-[12px] text-[var(--glass-text-tertiary)] mt-0.5\">\n                            展开以修改模型或参数设置\n                        </span>\n                    </div>\n                </div>\n                <div className={`w-8 h-8 rounded-full flex items-center justify-center bg-[var(--glass-bg-muted)] transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''}`}>\n                    <AppIcon name=\"chevronDown\" className=\"w-4 h-4 text-[var(--glass-text-secondary)]\" />\n                </div>\n            </button>\n\n            <div className={`transition-all duration-300 ease-in-out ${isExpanded ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0'} overflow-hidden`}>\n                <div className=\"p-4 pt-0 border-t border-[var(--glass-stroke-base)] mt-2 mx-4\">\n                    <div className=\"mt-4 mb-2 text-[12px] font-bold uppercase tracking-wider text-[var(--glass-text-secondary)]\">1. 选择模型</div>\n                    <div className=\"grid grid-cols-2 gap-2 mb-6\">\n                        {props.models.map(m => {\n                            const active = m.value === props.value\n                            return (\n                                <button\n                                    key={m.value}\n                                    onClick={() => props.onModelChange(m.value)}\n                                    className={`p-3 text-left rounded-xl transition-colors border ${active ? 'bg-blue-50/80 dark:bg-blue-900/40 border-blue-400 shadow-[0_0_12px_rgba(59,130,246,0.15)]' : 'bg-[var(--glass-bg-base)] border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-active)]'}`}\n                                >\n                                    <div className={`text-[13px] font-semibold mb-1 ${active ? 'text-blue-600 dark:text-blue-400' : 'text-[var(--glass-text-primary)]'}`}>{m.label}</div>\n                                    {m.providerName && <div className=\"text-[10px] text-[var(--glass-text-tertiary)]\">{m.providerName}</div>}\n                                </button>\n                            )\n                        })}\n                    </div>\n\n                    {props.capabilityFields.length > 0 && (\n                        <>\n                            <div className=\"mb-2 text-[12px] font-bold uppercase tracking-wider text-[var(--glass-text-secondary)]\">2. 参数微调</div>\n                            <div className=\"space-y-4 bg-[var(--glass-bg-base)] p-4 rounded-xl border border-[var(--glass-stroke-subtle)]\">\n                                {props.capabilityFields.map(field => {\n                                    const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n                                    return (\n                                        <div key={field.field} className=\"flex items-center justify-between gap-4\">\n                                            <span className=\"text-[13px] font-medium text-[var(--glass-text-primary)] shrink-0\">{field.label || field.field}</span>\n                                            <div className=\"flex flex-wrap gap-2 justify-end\">\n                                                {field.options.map(opt => {\n                                                    const s = String(opt)\n                                                    const active = s === val\n                                                    return (\n                                                        <button\n                                                            key={s}\n                                                            onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                            className={`px-3 py-1 text-[12px] transition-all rounded-md ${active ? 'bg-[var(--glass-text-primary)] text-[var(--glass-bg-base)] shadow-md font-bold' : 'bg-[var(--glass-bg-surface-strong)] text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)]'}`}\n                                                        >\n                                                            {s}\n                                                        </button>\n                                                    )\n                                                })}\n                                            </div>\n                                        </div>\n                                    )\n                                })}\n                            </div>\n                        </>\n                    )}\n                </div>\n            </div>\n        </div>\n    )\n}\n\n// ============================================================================\n// V8: The Pro Centered Modal (Context Shift)\n// Clicking opens a spacious, distraction-free modal dialog. Left-right layout.\n// ============================================================================\nexport function ModelInnovativeV8(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    return (\n        <>\n            <button\n                onClick={() => setIsOpen(true)}\n                className=\"w-full flex items-center justify-between px-5 py-3 rounded-xl bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] hover:shadow-md transition-shadow\"\n            >\n                <div className=\"flex items-center gap-3\">\n                    <AppIcon name=\"settingsHex\" className=\"w-5 h-5 text-[var(--glass-text-secondary)]\" />\n                    <span className=\"text-[15px] font-medium text-[var(--glass-text-primary)]\">\n                        {activeModel ? activeModel.label : '配置模型...'}\n                    </span>\n                </div>\n                <div className=\"text-[12px] font-bold text-blue-500 bg-blue-500/10 px-3 py-1 rounded-full uppercase tracking-widest\">\n                    编辑\n                </div>\n            </button>\n\n            {isOpen && createPortal(\n                <div className=\"fixed inset-0 z-[99999] flex items-center justify-center p-4\">\n                    <div className=\"absolute inset-0 bg-black/40 backdrop-blur-sm\" onClick={() => setIsOpen(false)} />\n                    <div className=\"relative w-full max-w-3xl h-[500px] flex rounded-2xl bg-[var(--glass-bg-base)] border border-[var(--glass-stroke-subtle)] shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200\">\n                        {/* Left: Models List */}\n                        <div className=\"w-1/2 flex flex-col border-r border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-surface)]\">\n                            <div className=\"p-5 border-b border-[var(--glass-stroke-subtle)] flex items-center justify-between\">\n                                <h2 className=\"text-[18px] font-bold text-[var(--glass-text-primary)]\">模型库</h2>\n                                <span className=\"text-[12px] text-[var(--glass-text-tertiary)]\">包含 {props.models.length} 项</span>\n                            </div>\n                            <div className=\"flex-1 overflow-y-auto p-3 space-y-2\">\n                                {props.models.map(m => {\n                                    const active = m.value === props.value\n                                    return (\n                                        <button\n                                            key={m.value}\n                                            onClick={() => props.onModelChange(m.value)}\n                                            className={`w-full text-left p-4 rounded-xl transition-colors border ${active ? 'bg-blue-500 shadow-[0_4px_12px_rgba(59,130,246,0.3)] border-transparent' : 'bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5'}`}\n                                        >\n                                            <div className={`text-[15px] font-bold ${active ? 'text-white' : 'text-[var(--glass-text-primary)]'}`}>{m.label}</div>\n                                            {m.providerName && <div className={`text-[12px] mt-1 ${active ? 'text-blue-100' : 'text-[var(--glass-text-tertiary)]'}`}>{m.providerName}</div>}\n                                        </button>\n                                    )\n                                })}\n                            </div>\n                        </div>\n                        {/* Right: Params Configuration */}\n                        <div className=\"w-1/2 flex flex-col bg-[var(--glass-bg-base)]\">\n                            <div className=\"p-5 border-b border-[var(--glass-stroke-subtle)] flex items-center justify-between\">\n                                <h2 className=\"text-[18px] font-bold text-[var(--glass-text-primary)]\">参数设置</h2>\n                                <button onClick={() => setIsOpen(false)} className=\"p-1 rounded-full hover:bg-[var(--glass-bg-hover)]\">\n                                    <AppIcon name=\"close\" className=\"w-5 h-5 text-[var(--glass-text-secondary)]\" />\n                                </button>\n                            </div>\n                            <div className=\"flex-1 overflow-y-auto p-6 space-y-8\">\n                                {props.capabilityFields.length === 0 ? (\n                                    <div className=\"flex flex-col items-center justify-center h-full text-center text-[var(--glass-text-tertiary)] gap-4 opacity-70\">\n                                        <AppIcon name=\"info\" className=\"w-10 h-10\" />\n                                        <p>当前模型无可用参数</p>\n                                    </div>\n                                ) : (\n                                    props.capabilityFields.map(field => {\n                                        const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n                                        return (\n                                            <div key={field.field} className=\"space-y-4\">\n                                                <div className=\"text-[14px] font-bold text-[var(--glass-text-secondary)] uppercase tracking-widest\">{field.label || field.field}</div>\n                                                <div className=\"grid grid-cols-2 gap-3\">\n                                                    {field.options.map(opt => {\n                                                        const s = String(opt)\n                                                        const active = s === val\n                                                        return (\n                                                            <button\n                                                                key={s}\n                                                                onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                                className={`p-3 text-[14px] text-center rounded-xl transition-all border ${active ? 'bg-[var(--glass-text-primary)] text-[var(--glass-bg-base)] border-[var(--glass-text-primary)]' : 'bg-transparent text-[var(--glass-text-primary)] border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-active)]'}`}\n                                                            >\n                                                                {s}\n                                                            </button>\n                                                        )\n                                                    })}\n                                                </div>\n                                            </div>\n                                        )\n                                    })\n                                )}\n                            </div>\n                            <div className=\"p-4 border-t border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-surface-strong)] flex justify-end\">\n                                <button onClick={() => setIsOpen(false)} className=\"px-6 py-2.5 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-500 shadow-md\">\n                                    确认应用\n                                </button>\n                            </div>\n                        </div>\n                    </div>\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// V9: The Drill-Down Popover (Nested Navigation)\n// Click Model -> Popover shows Model List -> Click \"Params\" -> View shifts sideways inside popover.\n// ============================================================================\nexport function ModelInnovativeV9(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const [view, setView] = useState<'models' | 'params'>('models')\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    // When re-opening, reset view\n    useEffect(() => {\n        if (isOpen) setView('models')\n    }, [isOpen])\n\n    return (\n        <>\n            <button\n                ref={triggerRef}\n                onClick={() => setIsOpen(!isOpen)}\n                className={`flex items-center justify-between w-full p-2 rounded-lg bg-[var(--glass-bg-surface)] border ${isOpen ? 'border-[#ff6b6b] ring-1 ring-[#ff6b6b]/20 shadow-[0_4px_16px_rgba(255,107,107,0.1)]' : 'border-[var(--glass-stroke-base)] group hover:border-[var(--glass-stroke-active)]'}`}\n            >\n                <div className=\"flex items-center gap-3\">\n                    <div className=\"w-8 h-8 rounded shrink-0 bg-[#ff6b6b]/10 flex items-center justify-center\">\n                        <AppIcon name=\"sparkles\" className=\"w-4 h-4 text-[#ff6b6b]\" />\n                    </div>\n                    <div className=\"flex flex-col text-left\">\n                        <span className=\"text-[13px] font-semibold text-[var(--glass-text-primary)]\">{activeModel ? activeModel.label : '未选择'}</span>\n                        <span className=\"text-[11px] text-[var(--glass-text-tertiary)]\">{props.capabilityFields.length} 项参数配置可设</span>\n                    </div>\n                </div>\n                <AppIcon name=\"chevronDown\" className=\"w-4 h-4 mr-1 text-[var(--glass-text-tertiary)] transition-transform group-hover:text-[var(--glass-text-primary)]\" />\n            </button>\n\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-xl shadow-xl border border-[var(--glass-stroke-subtle)] overflow-hidden bg-[var(--glass-bg-base)]\">\n                    <div className={`flex w-[200%] transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] ${view === 'params' ? '-translate-x-1/2' : 'translate-x-0'}`}>\n                        {/* Page 1: Models */}\n                        <div className=\"w-1/2 flex flex-col max-h-[300px]\">\n                            <div className=\"p-3 border-b border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-surface)] font-bold text-[13px] text-center\">选择主要模型</div>\n                            <div className=\"overflow-y-auto flex-1 p-2 space-y-1\">\n                                {props.models.map(m => {\n                                    const active = m.value === props.value\n                                    return (\n                                        <div key={m.value} className=\"flex gap-1 group\">\n                                            <button\n                                                onClick={() => props.onModelChange(m.value)}\n                                                className={`flex-1 flex items-center px-3 py-2 rounded-lg text-left transition-colors ${active ? 'bg-[#ff6b6b]/10 text-[#ff6b6b] font-bold' : 'hover:bg-[var(--glass-bg-hover)] text-[var(--glass-text-primary)]'}`}\n                                            >\n                                                <span className=\"text-[13px]\">{m.label}</span>\n                                                {active && <AppIcon name=\"check\" className=\"w-3.5 h-3.5 ml-auto\" />}\n                                            </button>\n                                            {active && props.capabilityFields.length > 0 && (\n                                                <button\n                                                    onClick={() => setView('params')}\n                                                    className=\"px-2 py-2 w-[40px] flex items-center justify-center rounded-lg bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-bg-hover)] border border-[var(--glass-stroke-subtle)] text-[var(--glass-text-secondary)] shadow-sm\"\n                                                    title=\"配置参数\"\n                                                >\n                                                    <AppIcon name=\"settingsHex\" className=\"w-4 h-4\" />\n                                                </button>\n                                            )}\n                                        </div>\n                                    )\n                                })}\n                            </div>\n                        </div>\n                        {/* Page 2: Params */}\n                        <div className=\"w-1/2 flex flex-col max-h-[300px]\">\n                            <div className=\"p-2 border-b border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-surface)] flex items-center\">\n                                <button onClick={() => setView('models')} className=\"p-1 px-2 shrink-0 flex items-center gap-1 hover:bg-[var(--glass-bg-hover)] rounded font-medium text-[12px] text-[var(--glass-text-secondary)]\">\n                                    <AppIcon name=\"chevronDown\" className=\"w-4 h-4 rotate-90\" />\n                                    返回\n                                </button>\n                                <div className=\"font-bold text-[13px] text-center flex-1 mr-8\">参数配置</div>\n                            </div>\n                            <div className=\"overflow-y-auto flex-1 p-4 space-y-5\">\n                                {props.capabilityFields.map(field => {\n                                    const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n                                    return (\n                                        <div key={field.field} className=\"space-y-2\">\n                                            <div className=\"text-[12px] font-semibold text-[var(--glass-text-secondary)]\">{field.label || field.field}</div>\n                                            <div className=\"grid grid-cols-1 gap-1.5\">\n                                                {field.options.map(opt => {\n                                                    const s = String(opt)\n                                                    const active = s === val\n                                                    return (\n                                                        <button\n                                                            key={s}\n                                                            onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                            className={`w-full p-2 text-[12px] text-center rounded-md border transition-all ${active ? 'bg-[#ff6b6b] text-white border-[#ff6b6b]' : 'bg-[var(--glass-bg-surface-strong)] border-[var(--glass-stroke-base)] text-[var(--glass-text-primary)] hover:border-[var(--glass-stroke-active)]'}`}\n                                                        >\n                                                            {s}\n                                                        </button>\n                                                    )\n                                                })}\n                                            </div>\n                                        </div>\n                                    )\n                                })}\n                            </div>\n                        </div>\n                    </div>\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// V10: Bottom Sheet Drawer (Mobile-inspired / Context Menu Bottom)\n// Triggers a drawer anchored to the bottom of the screen. Very tactile.\n// ============================================================================\nexport function ModelInnovativeV10(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    return (\n        <>\n            <button\n                onClick={() => setIsOpen(true)}\n                className=\"w-full flex items-center justify-center gap-3 py-3 px-4 rounded-full bg-[var(--glass-bg-base)] border border-[var(--glass-text-primary)] hover:bg-[var(--glass-text-primary)] hover:text-[var(--glass-bg-base)] group transition-all text-[var(--glass-text-primary)] font-bold shadow-[0_4px_14px_rgba(0,0,0,0.1)]\"\n            >\n                <AppIcon name=\"cpu\" className=\"w-5 h-5 group-hover:animate-pulse\" />\n                <span>生成偏好: {activeModel ? activeModel.label : '点击选择'}</span>\n            </button>\n\n            {isOpen && createPortal(\n                <div className=\"fixed inset-0 z-[99999] flex flex-col justify-end\">\n                    <div\n                        className=\"absolute inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in duration-300\"\n                        onClick={() => setIsOpen(false)}\n                    />\n                    <div className=\"relative w-full max-w-4xl mx-auto bg-[var(--glass-bg-base)] border-t border-[var(--glass-stroke-active)] rounded-t-[32px] p-6 pb-12 shadow-[0_-10px_40px_rgba(0,0,0,0.2)] animate-in slide-in-from-bottom duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]\">\n                        <div className=\"w-12 h-1.5 bg-[var(--glass-stroke-active)] rounded-full mx-auto mb-6\" />\n\n                        <div className=\"flex justify-between items-center mb-6 px-2\">\n                            <h2 className=\"text-[24px] font-black text-[var(--glass-text-primary)] tracking-tight\">配置生成偏好</h2>\n                            <button onClick={() => setIsOpen(false)} className=\"w-10 h-10 rounded-full bg-[var(--glass-bg-surface-strong)] flex items-center justify-center hover:bg-[var(--glass-bg-hover)]\">\n                                <AppIcon name=\"close\" className=\"w-5 h-5\" />\n                            </button>\n                        </div>\n\n                        <div className=\"flex flex-col md:flex-row gap-8 px-2\">\n                            {/* Left: Models horizontally scrollable block */}\n                            <div className=\"w-full md:w-2/3\">\n                                <h3 className=\"text-[14px] font-bold text-[var(--glass-text-secondary)] uppercase tracking-wider mb-4\">核心模型选择</h3>\n                                <div className=\"grid grid-cols-2 lg:grid-cols-3 gap-3\">\n                                    {props.models.map(m => {\n                                        const active = m.value === props.value\n                                        return (\n                                            <button\n                                                key={m.value}\n                                                onClick={() => props.onModelChange(m.value)}\n                                                className={`flex flex-col items-start p-4 rounded-[20px] transition-all border-2 text-left ${active ? 'border-[#3B82F6] bg-[#3B82F6]/5 shadow-[0_8px_20px_rgba(59,130,246,0.1)]' : 'border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] hover:border-[var(--glass-stroke-active)]'}`}\n                                            >\n                                                <div className={`w-10 h-10 rounded-xl flex items-center justify-center mb-3 ${active ? 'bg-[#3B82F6] shadow-[0_4px_10px_rgba(59,130,246,0.3)]' : 'bg-[var(--glass-bg-base)] border border-[var(--glass-stroke-subtle)]'}`}>\n                                                    <AppIcon name=\"sparkles\" className={`w-5 h-5 ${active ? 'text-white' : 'text-[var(--glass-text-tertiary)]'}`} />\n                                                </div>\n                                                <span className={`text-[15px] font-bold leading-tight ${active ? 'text-[#3B82F6]' : 'text-[var(--glass-text-primary)]'}`}>{m.label}</span>\n                                                {m.providerName && <span className=\"text-[11px] font-medium text-[var(--glass-text-tertiary)] mt-1\">{m.providerName}</span>}\n                                            </button>\n                                        )\n                                    })}\n                                </div>\n                            </div>\n\n                            {/* Right: Vertical params list */}\n                            <div className=\"w-full md:w-1/3 flex flex-col pt-2 md:pt-0 border-t md:border-t-0 md:border-l border-[var(--glass-stroke-subtle)] md:pl-8\">\n                                <h3 className=\"text-[14px] font-bold text-[var(--glass-text-secondary)] uppercase tracking-wider mb-4\">参数微调</h3>\n                                {props.capabilityFields.length === 0 ? (\n                                    <div className=\"text-[var(--glass-text-tertiary)] text-[14px]\">自动最佳配置应用中</div>\n                                ) : (\n                                    <div className=\"space-y-6\">\n                                        {props.capabilityFields.map(field => {\n                                            const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n                                            return (\n                                                <div key={field.field}>\n                                                    <div className=\"text-[15px] font-bold text-[var(--glass-text-primary)] mb-3\">{field.label || field.field}</div>\n                                                    <div className=\"flex bg-[var(--glass-bg-surface-strong)] p-1.5 rounded-[16px]\">\n                                                        {field.options.map(opt => {\n                                                            const s = String(opt)\n                                                            const active = s === val\n                                                            return (\n                                                                <button\n                                                                    key={s}\n                                                                    onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                                    className={`flex-1 py-2 text-[14px] font-bold rounded-[12px] transition-all ${active ? 'bg-white dark:bg-black text-[var(--glass-text-primary)] shadow-md' : 'text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]'}`}\n                                                                >\n                                                                    {s}\n                                                                </button>\n                                                            )\n                                                        })}\n                                                    </div>\n                                                </div>\n                                            )\n                                        })}\n                                    </div>\n                                )}\n                            </div>\n                        </div>\n                    </div>\n                </div>, document.body\n            )}\n        </>\n    )\n}\n"
  },
  {
    "path": "src/components/ui/model-dropdown-ios.tsx",
    "content": "'use client'\n\nimport React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport { AppIcon } from '@/components/ui/icons'\nimport type { ModelCapabilityOption, CapabilityFieldDefinition } from './config-modals/ModelCapabilityDropdown'\nimport type { CapabilityValue } from '@/lib/model-config-contract'\n\nexport interface ModelDropdownTestProps {\n    models: ModelCapabilityOption[]\n    value: string | undefined\n    onModelChange: (modelKey: string) => void\n    capabilityFields: CapabilityFieldDefinition[]\n    capabilityOverrides: Record<string, CapabilityValue>\n    onCapabilityChange: (field: string, rawValue: string, sample: CapabilityValue) => void\n    placeholder?: string\n}\n\nconst VIEWPORT_EDGE_GAP = 8\nconst DEFAULT_MAX_HEIGHT = 450\n\nfunction useDropdown(isOpen: boolean, setIsOpen: (val: boolean) => void) {\n    const triggerRef = useRef<HTMLButtonElement>(null)\n    const panelRef = useRef<HTMLDivElement>(null)\n    const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})\n\n    const updatePosition = useCallback(() => {\n        if (!triggerRef.current) return\n        const rect = triggerRef.current.getBoundingClientRect()\n        const viewportHeight = window.innerHeight || document.documentElement.clientHeight\n        const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP\n        const spaceAbove = rect.top - VIEWPORT_EDGE_GAP\n\n        let openUpward = false\n        let currentMaxHeight = DEFAULT_MAX_HEIGHT\n\n        if (spaceBelow < 250 && spaceAbove > spaceBelow) {\n            openUpward = true\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)\n        } else {\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)\n        }\n\n        setPanelStyle({\n            position: 'fixed',\n            left: rect.left,\n            width: Math.max(rect.width, 320),\n            maxHeight: currentMaxHeight,\n            ...(openUpward\n                ? { bottom: viewportHeight - rect.top + 6 }\n                : { top: rect.bottom + 6 }),\n            zIndex: 9999\n        })\n    }, [])\n\n    useEffect(() => {\n        function handleClickOutside(e: MouseEvent) {\n            const target = e.target as Node\n            if (triggerRef.current?.contains(target)) return\n            if (panelRef.current?.contains(target)) return\n            setIsOpen(false)\n        }\n        document.addEventListener('mousedown', handleClickOutside)\n        return () => document.removeEventListener('mousedown', handleClickOutside)\n    }, [setIsOpen])\n\n    useLayoutEffect(() => {\n        if (!isOpen) return\n        updatePosition()\n        window.addEventListener('resize', updatePosition)\n        window.addEventListener('scroll', updatePosition, true)\n        return () => {\n            window.removeEventListener('resize', updatePosition)\n            window.removeEventListener('scroll', updatePosition, true)\n        }\n    }, [isOpen, updatePosition])\n\n    return { triggerRef, panelRef, panelStyle }\n}\n\nfunction resolveParamSummary(fields: CapabilityFieldDefinition[], overrides: Record<string, CapabilityValue>) {\n    return fields.map(def => {\n        const val = overrides[def.field] !== undefined ? String(overrides[def.field]) : String(def.options[0] || '')\n        return val\n    }).filter(Boolean).join(' · ')\n}\n\nfunction DefaultParamsRenderer({ fields, overrides, onChange, className }: { fields: CapabilityFieldDefinition[], overrides: Record<string, CapabilityValue>, onChange: (field: string, rawValue: string, sample: CapabilityValue) => void, className?: string }) {\n    if (fields.length === 0) return null;\n    return (\n        <div className={className}>\n            <div className=\"text-[11px] font-bold text-[#8e8e93] px-1 pt-0.5 mb-2\">参数配置</div>\n            {fields.map(field => {\n                const val = overrides[field.field] !== undefined ? String(overrides[field.field]) : String(field.options[0] || '')\n                if (field.field === 'duration' || field.options.length >= 4) {\n                    return (\n                        <div key={field.field} className=\"flex items-center justify-between gap-4 px-1 py-1 relative group\">\n                            <span className=\"text-[13px] font-semibold text-[var(--glass-text-secondary)] shrink-0\">{field.label || field.field}</span>\n                            <div className=\"relative\">\n                                <select\n                                    value={val}\n                                    onChange={(e) => onChange(field.field, e.target.value, field.options[0])}\n                                    className=\"appearance-none bg-transparent hover:bg-[#f2f2f7] dark:hover:bg-[#1c1c1e] text-[13px] font-bold text-[var(--glass-text-primary)] pl-3 pr-7 py-1 rounded-md transition-colors outline-none cursor-pointer border border-transparent\"\n                                >\n                                    {field.options.map(opt => <option key={String(opt)} value={String(opt)}>{String(opt)}</option>)}\n                                </select>\n                                <AppIcon name=\"chevronDown\" className=\"w-3.5 h-3.5 text-[var(--glass-text-tertiary)] absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none group-hover:text-[var(--glass-text-primary)] transition-colors\" />\n                            </div>\n                        </div>\n                    )\n                }\n                return (\n                    <div key={field.field} className=\"flex items-center justify-between gap-4 px-1 py-1\">\n                        <span className=\"text-[13px] font-semibold text-[var(--glass-text-secondary)] shrink-0\">{field.label || field.field}</span>\n                        <div className=\"flex bg-[#f2f2f7] dark:bg-[#1c1c1e] p-[3px] rounded-lg shadow-inner\">\n                            {field.options.map(opt => {\n                                const s = String(opt)\n                                const active = s === val\n                                return (\n                                    <button key={s} onClick={() => onChange(field.field, s, field.options[0])} className={`px-4 py-1.5 text-[12px] font-medium rounded-md transition-all ${active ? 'bg-white text-black dark:bg-[#2c2c2e] dark:text-white shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] font-bold' : 'text-[#8e8e93] hover:text-[#3a3a3c] dark:hover:text-[#ebebf5]'}`}>\n                                        {s}\n                                    </button>\n                                )\n                            })}\n                        </div>\n                    </div>\n                )\n            })}\n        </div>\n    )\n}\n\n// ============================================================================\n// V1: Deep Glass Glow (绝对的文字发光与透明度) \n// 完美继承原 V6 的文字投影流派。去背景化。\n// ============================================================================\nexport function IOSVariant1(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    return (\n        <>\n            <button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className={`w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border transition-colors ${isOpen ? 'border-[var(--glass-tone-info-fg)]' : 'border-[var(--glass-stroke-subtle)] hover:border-[var(--glass-stroke-active)]'}`}>\n                <div className=\"flex items-center justify-between\">\n                    <div className=\"flex items-center gap-2\">\n                        <span className=\"font-semibold text-[14px] text-[var(--glass-text-primary)]\">{activeModel?.label || props.placeholder}</span>\n                        {activeModel?.providerName && (\n                            <span className=\"text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap\">\n                                {activeModel.providerName}\n                            </span>\n                        )}\n                    </div>\n                    <div className=\"flex items-center gap-2\">\n                        <span className=\"text-[13px] text-[var(--glass-text-secondary)]\">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>\n                        <AppIcon name=\"chevronDown\" className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180 text-[var(--glass-tone-info-fg)]' : 'text-[var(--glass-text-tertiary)]'} drop-shadow-[0_1px_3px_var(--glass-tone-info-bg)]`} />\n                    </div>\n                </div>\n            </button>\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-[20px] shadow-[0_8px_32px_rgba(0,0,0,0.1)] border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col p-2\">\n                    <div className=\"overflow-y-auto max-h-[220px]\">\n                        {props.models.map(m => (\n                            <button key={m.value} onClick={() => props.onModelChange(m.value)} className={`w-full text-left px-3 py-2.5 rounded-[12px] font-medium transition-colors hover:bg-[var(--glass-bg-hover)] ${m.value === props.value ? 'bg-[var(--glass-bg-surface-strong)]' : ''}`}>\n                                <span className={m.value === props.value ? 'text-[var(--glass-tone-info-fg)] font-bold drop-shadow-[0_1px_4px_var(--glass-tone-info-bg)]' : 'text-[var(--glass-text-primary)]'}>{m.label}</span>\n                            </button>\n                        ))}\n                    </div>\n                    {props.capabilityFields.length > 0 && <div className=\"h-[1px] bg-[var(--glass-stroke-subtle)] mx-2 my-2\" />}\n                    <DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className=\"space-y-3 p-2\" />\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// V2: Left Indicator Line (硬朗工业线段侧边提示) \n// 完美继承原 V7 的左侧工业级边框设计。\n// ============================================================================\nexport function IOSVariant2(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    return (\n        <>\n            <button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className=\"w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-subtle)] flex items-center justify-between hover:border-[var(--glass-stroke-active)] transition-colors\">\n                <div className=\"flex items-center gap-2\">\n                    <span className=\"font-semibold text-[14px] text-[var(--glass-text-primary)]\">{activeModel?.label || props.placeholder}</span>\n                    {activeModel?.providerName && (\n                        <span className=\"text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap\">\n                            {activeModel.providerName}\n                        </span>\n                    )}\n                </div>\n                <div className=\"flex items-center gap-2\">\n                    <span className=\"text-[13px] text-[var(--glass-text-secondary)]\">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>\n                    <AppIcon name=\"chevronDown\" className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180 text-[var(--glass-text-primary)]' : 'text-[var(--glass-text-tertiary)]'}`} />\n                </div>\n            </button>\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-[20px] shadow-lg border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col pt-3 pb-2 overflow-hidden\">\n                    <div className=\"overflow-y-auto max-h-[220px]\">\n                        {props.models.map(m => {\n                            const active = m.value === props.value\n                            return (\n                                <button key={m.value} onClick={() => props.onModelChange(m.value)} className={`w-full text-left px-5 py-2.5 transition-colors border-l-[3px] ${active ? 'border-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface-strong)] text-[var(--glass-text-primary)] font-bold' : 'border-transparent text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)]'}`}>\n                                    {m.label}\n                                </button>\n                            )\n                        })}\n                    </div>\n                    {props.capabilityFields.length > 0 && <div className=\"h-[1px] bg-[var(--glass-stroke-subtle)] mx-4 my-3\" />}\n                    <DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className=\"space-y-4 px-5 pb-3\" />\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// V3: Fusion (Indicator + Glow) 融合变体\n// 吸收了左边条指示 + 文字自身发光的完美合体。\n// ============================================================================\nexport function IOSVariant3(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    return (\n        <>\n            <button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className={`w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border transition-all duration-300 ${isOpen ? 'border-[var(--glass-tone-info-fg)] shadow-[0_0_8px_var(--glass-tone-info-bg)]' : 'border-[var(--glass-stroke-subtle)] hover:border-[var(--glass-stroke-active)]'}`}>\n                <div className=\"flex items-center justify-between\">\n                    <div className=\"flex items-center gap-2\">\n                        <span className=\"font-semibold text-[14px] text-[var(--glass-text-primary)]\">{activeModel?.label || props.placeholder}</span>\n                        {activeModel?.providerName && (\n                            <span className=\"text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap\">\n                                {activeModel.providerName}\n                            </span>\n                        )}\n                    </div>\n                    <div className=\"flex items-center gap-2\">\n                        <span className=\"text-[13px] text-[var(--glass-text-secondary)]\">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>\n                        <AppIcon name=\"chevronDown\" className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180 text-[var(--glass-tone-info-fg)]' : 'text-[var(--glass-text-tertiary)]'}`} />\n                    </div>\n                </div>\n            </button>\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-[20px] shadow-[0_8px_32px_rgba(0,0,0,0.12)] border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col p-2 overflow-hidden\">\n                    <div className=\"overflow-y-auto max-h-[220px]\">\n                        {props.models.map(m => {\n                            const active = m.value === props.value\n                            return (\n                                <button key={m.value} onClick={() => props.onModelChange(m.value)} className={`w-full text-left px-3 py-2.5 rounded-[8px] transition-all border-l-[3px] hover:bg-[var(--glass-bg-hover)] ${active ? 'bg-[var(--glass-tone-info-bg)]/10 border-[var(--glass-tone-info-fg)] shadow-[-4px_0_12px_var(--glass-tone-info-bg)]' : 'border-transparent text-[var(--glass-text-secondary)]'}`}>\n                                    <span className={active ? 'text-[var(--glass-tone-info-fg)] font-bold drop-shadow-[0_1px_4px_var(--glass-tone-info-bg)] pl-1' : 'pl-1'}>\n                                        {m.label}\n                                    </span>\n                                </button>\n                            )\n                        })}\n                    </div>\n                    {props.capabilityFields.length > 0 && <div className=\"h-[1px] bg-[var(--glass-stroke-subtle)] mx-2 my-3\" />}\n                    <DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className=\"space-y-4 px-2 pb-2\" />\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// V4: Pill Active Marker + Glow (极简悬浮胶囊标示 + 文本色晕)\n// 原左侧边条缩编为一个极其悬浮的前置小药丸胶囊，十分精致高级。\n// ============================================================================\nexport function IOSVariant4(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    return (\n        <>\n            <button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className=\"w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-subtle)] flex items-center justify-between hover:border-[var(--glass-stroke-active)] transition-colors\">\n                <div className=\"flex items-center gap-2\">\n                    <span className=\"font-semibold text-[14px] text-[var(--glass-text-primary)]\">{activeModel?.label || props.placeholder}</span>\n                    {activeModel?.providerName && (\n                        <span className=\"text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap\">\n                            {activeModel.providerName}\n                        </span>\n                    )}\n                </div>\n                <div className=\"flex items-center gap-2\">\n                    <span className=\"text-[13px] text-[var(--glass-text-secondary)]\">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>\n                    <AppIcon name=\"chevronDown\" className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''} text-[var(--glass-text-tertiary)]`} />\n                </div>\n            </button>\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-[20px] shadow-xl border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col p-2 overflow-hidden\">\n                    <div className=\"overflow-y-auto max-h-[220px]\">\n                        {props.models.map(m => {\n                            const active = m.value === props.value\n                            return (\n                                <button key={m.value} onClick={() => props.onModelChange(m.value)} className=\"w-full relative text-left px-5 py-2.5 rounded-[10px] transition-colors hover:bg-[var(--glass-bg-hover)]\">\n                                    {active && (\n                                        <div className=\"absolute left-1.5 top-1/2 -translate-y-1/2 w-1 h-3/5 rounded-full bg-[var(--glass-tone-info-fg)] shadow-[0_0_8px_var(--glass-tone-info-bg)]\" />\n                                    )}\n                                    <span className={active ? 'text-[var(--glass-tone-info-fg)] font-bold drop-shadow-[0_1px_4px_var(--glass-tone-info-bg)]' : 'text-[var(--glass-text-secondary)] font-medium'}>\n                                        {m.label}\n                                    </span>\n                                </button>\n                            )\n                        })}\n                    </div>\n                    {props.capabilityFields.length > 0 && <div className=\"h-[1px] bg-[var(--glass-stroke-subtle)] mx-3 my-3\" />}\n                    <DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className=\"space-y-4 px-3 pb-3\" />\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// V5: Underline Glow (下划弧度标示 + 全文字发光)\n// 在保留 Glow 特性的前提下，将纯粹的左侧指示器转移到了内嵌胶囊底部极具设计感的下划线。\n// ============================================================================\nexport function IOSVariant5(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    return (\n        <>\n            <button ref={triggerRef} onClick={() => setIsOpen(!isOpen)} className=\"w-full h-[46px] px-4 rounded-[14px] bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-subtle)] flex items-center justify-between hover:bg-[var(--glass-bg-surface-strong)] transition-colors\">\n                <div className=\"flex items-center gap-2\">\n                    <span className=\"font-semibold text-[14px] text-[var(--glass-text-primary)]\">{activeModel?.label || props.placeholder}</span>\n                    {activeModel?.providerName && (\n                        <span className=\"text-[10px] font-medium px-2 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] whitespace-nowrap\">\n                            {activeModel.providerName}\n                        </span>\n                    )}\n                </div>\n                <div className=\"flex items-center gap-2\">\n                    <span className=\"text-[13px] text-[var(--glass-text-secondary)]\">{resolveParamSummary(props.capabilityFields, props.capabilityOverrides)}</span>\n                    <AppIcon name=\"chevronDown\" className=\"w-4 h-4 text-[var(--glass-text-tertiary)]\" />\n                </div>\n            </button>\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-[20px] shadow-lg border border-[var(--glass-stroke-subtle)] bg-[var(--glass-bg-base)] flex flex-col p-2 overflow-hidden\">\n                    <div className=\"overflow-y-auto max-h-[220px]\">\n                        {props.models.map(m => {\n                            const active = m.value === props.value\n                            return (\n                                <button key={m.value} onClick={() => props.onModelChange(m.value)} className={`w-full text-left px-4 py-3 rounded-[12px] transition-all border-b-[2px] ${active ? 'border-[var(--glass-tone-info-fg)] bg-[var(--glass-bg-surface-strong)] shadow-[0_4px_16px_var(--glass-tone-info-bg)]' : 'border-transparent text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)]'}`}>\n                                    <span className={active ? 'text-[var(--glass-tone-info-fg)] font-bold drop-shadow-[0_1px_4px_var(--glass-tone-info-bg)]' : 'font-medium'}>\n                                        {m.label}\n                                    </span>\n                                </button>\n                            )\n                        })}\n                    </div>\n                    {props.capabilityFields.length > 0 && <div className=\"h-[1px] bg-[var(--glass-stroke-subtle)] mx-3 my-3\" />}\n                    <DefaultParamsRenderer fields={props.capabilityFields} overrides={props.capabilityOverrides} onChange={props.onCapabilityChange} className=\"space-y-4 px-3 pb-2\" />\n                </div>, document.body\n            )}\n        </>\n    )\n}\n"
  },
  {
    "path": "src/components/ui/model-dropdown-variants.tsx",
    "content": "'use client'\n\nimport React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport { AppIcon } from '@/components/ui/icons'\nimport type { ModelCapabilityOption, CapabilityFieldDefinition } from './config-modals/ModelCapabilityDropdown'\nimport type { CapabilityValue } from '@/lib/model-config-contract'\nexport interface ModelDropdownTestProps {\n    models: ModelCapabilityOption[]\n    value: string | undefined\n    onModelChange: (modelKey: string) => void\n    capabilityFields: CapabilityFieldDefinition[]\n    capabilityOverrides: Record<string, CapabilityValue>\n    onCapabilityChange: (field: string, rawValue: string, sample: CapabilityValue) => void\n    placeholder?: string\n}\n\nconst VIEWPORT_EDGE_GAP = 8\nconst DEFAULT_MAX_HEIGHT = 400\n\nfunction useDropdown(isOpen: boolean, setIsOpen: (val: boolean) => void) {\n    const triggerRef = useRef<HTMLButtonElement>(null)\n    const panelRef = useRef<HTMLDivElement>(null)\n    const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})\n\n    const updatePosition = useCallback(() => {\n        if (!triggerRef.current) return\n        const rect = triggerRef.current.getBoundingClientRect()\n        const viewportHeight = window.innerHeight || document.documentElement.clientHeight\n        const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP\n        const spaceAbove = rect.top - VIEWPORT_EDGE_GAP\n\n        let openUpward = false\n        let currentMaxHeight = DEFAULT_MAX_HEIGHT\n\n        if (spaceBelow < 250 && spaceAbove > spaceBelow) {\n            openUpward = true\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)\n        } else {\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)\n        }\n\n        setPanelStyle({\n            position: 'fixed',\n            left: rect.left,\n            width: Math.max(rect.width, 320),\n            maxHeight: currentMaxHeight,\n            ...(openUpward\n                ? { bottom: viewportHeight - rect.top + 6 }\n                : { top: rect.bottom + 6 }),\n            zIndex: 9999\n        })\n    }, [])\n\n    useEffect(() => {\n        function handleClickOutside(e: MouseEvent) {\n            const target = e.target as Node\n            if (triggerRef.current?.contains(target)) return\n            if (panelRef.current?.contains(target)) return\n            setIsOpen(false)\n        }\n        document.addEventListener('mousedown', handleClickOutside)\n        return () => document.removeEventListener('mousedown', handleClickOutside)\n    }, [setIsOpen])\n\n    useLayoutEffect(() => {\n        if (!isOpen) return\n        updatePosition()\n        window.addEventListener('resize', updatePosition)\n        window.addEventListener('scroll', updatePosition, true)\n        return () => {\n            window.removeEventListener('resize', updatePosition)\n            window.removeEventListener('scroll', updatePosition, true)\n        }\n    }, [isOpen, updatePosition])\n\n    return { triggerRef, panelRef, panelStyle }\n}\n\nfunction resolveParamSummary(fields: CapabilityFieldDefinition[], overrides: Record<string, CapabilityValue>) {\n    return fields.map(def => {\n        const val = overrides[def.field] !== undefined ? String(overrides[def.field]) : String(def.options[0] || '')\n        if (def.field === 'duration') return `${val}s`\n        return val\n    }).filter(Boolean).join(' · ')\n}\n\n// ============================================================================\n// Variant 1: Apple iOS Segmented Control Style\n// Clean white/glass, segmented parameters, extremely rounded corners.\n// ============================================================================\nexport function ModelDropdownV1(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n    const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)\n\n    return (\n        <>\n            <button\n                ref={triggerRef}\n                onClick={() => setIsOpen(!isOpen)}\n                className={`flex items-center justify-between w-full h-[46px] px-4 rounded-[14px] transition-all duration-300 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-subtle)] hover:bg-[var(--glass-bg-surface-strong)] ${isOpen ? 'ring-2 ring-black/10 dark:ring-white/20' : ''}`}\n            >\n                <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                    <span className=\"font-semibold text-[14px] text-[var(--glass-text-primary)]\">\n                        {activeModel ? activeModel.label : props.placeholder}\n                    </span>\n                    {activeModel?.providerName && (\n                        <span className=\"text-[10px] font-medium px-2 py-0.5 rounded-full bg-black/5 dark:bg-white/10 text-[var(--glass-text-secondary)]\">\n                            {activeModel.providerName}\n                        </span>\n                    )}\n                    {summary && <span className=\"text-[12px] text-[var(--glass-text-tertiary)] ml-auto pr-2\">{summary}</span>}\n                </div>\n                <AppIcon name=\"chevronDown\" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n            </button>\n\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-[20px] shadow-[0_12px_40px_-10px_rgba(0,0,0,0.15)] border border-[var(--glass-stroke-subtle)] overflow-hidden flex flex-col backdrop-blur-2xl bg-white/70 dark:bg-black/70\">\n                    <div className=\"overflow-y-auto px-2 py-2 max-h-[220px]\">\n                        {props.models.map(m => {\n                            const active = m.value === props.value\n                            return (\n                                <button\n                                    key={m.value}\n                                    onClick={() => props.onModelChange(m.value)}\n                                    className={`w-full flex items-center justify-between px-3 py-2.5 rounded-[12px] mb-0.5 transition-all ${active ? 'bg-black text-white dark:bg-white dark:text-black shadow-md' : 'hover:bg-black/5 dark:hover:bg-white/10 text-[var(--glass-text-secondary)]'}`}\n                                >\n                                    <div className=\"flex items-center gap-2\">\n                                        <span className={`text-[14px] ${active ? 'font-bold' : 'font-medium'}`}>{m.label}</span>\n                                        <span className={`text-[10px] px-1.5 py-0.5 rounded-md ${active ? 'bg-white/20 text-white dark:bg-black/10 dark:text-black' : 'border border-[var(--glass-stroke-base)] text-[var(--glass-text-tertiary)]'}`}>\n                                            {m.providerName}\n                                        </span>\n                                    </div>\n                                    {active && <AppIcon name=\"check\" className=\"w-4 h-4 ml-2\" />}\n                                </button>\n                            )\n                        })}\n                    </div>\n                    {props.capabilityFields.length > 0 && (\n                        <div className=\"bg-black/5 dark:bg-white/5 border-t border-[var(--glass-stroke-subtle)] p-3 space-y-3\">\n                            {props.capabilityFields.map(field => {\n                                const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n                                return (\n                                    <div key={field.field} className=\"flex items-center justify-between\">\n                                        <span className=\"text-[12px] font-semibold text-[var(--glass-text-secondary)]\">{field.label || field.field}</span>\n                                        <div className=\"flex bg-black/5 dark:bg-white/10 p-0.5 rounded-[10px]\">\n                                            {field.options.map((opt) => {\n                                                const s = String(opt)\n                                                const active = s === val\n                                                return (\n                                                    <button\n                                                        key={s}\n                                                        onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                        className={`px-3 py-1 text-[12px] font-medium rounded-[8px] transition-all ${active ? 'bg-white dark:bg-[#333] shadow-sm text-[var(--glass-text-primary)]' : 'text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]'}`}\n                                                    >\n                                                        {s}\n                                                    </button>\n                                                )\n                                            })}\n                                        </div>\n                                    </div>\n                                )\n                            })}\n                        </div>\n                    )}\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// Variant 2: Minimalist Tech Borderless (Vercel Style)\n// Sharp, thin borders, hover states very subtle, focus on typography\n// ============================================================================\nexport function ModelDropdownV2(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n    const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)\n\n    return (\n        <>\n            <button\n                ref={triggerRef}\n                onClick={() => setIsOpen(!isOpen)}\n                className={`flex items-center justify-between w-full h-[40px] px-3 rounded-md transition-colors bg-[var(--glass-bg-surface)] border ${isOpen ? 'border-[var(--glass-text-primary)]' : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-text-secondary)]'}`}\n            >\n                <div className=\"flex items-baseline gap-2 truncate\">\n                    <span className=\"font-medium text-[13px] text-[var(--glass-text-primary)]\">\n                        {activeModel ? activeModel.label : props.placeholder}\n                    </span>\n                    <span className=\"text-[11px] text-[var(--glass-text-tertiary)]\">\n                        {summary ? `— ${summary}` : ''}\n                    </span>\n                </div>\n                <AppIcon name=\"chevronDown\" className={`w-3.5 h-3.5 text-[var(--glass-text-secondary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n            </button>\n\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-md shadow-[0_4px_16px_rgba(0,0,0,0.1)] border border-[var(--glass-stroke-base)] overflow-hidden flex flex-col bg-[var(--glass-bg-surface-strong)]\">\n                    <div className=\"overflow-y-auto max-h-[200px]\">\n                        {props.models.map(m => {\n                            const active = m.value === props.value\n                            return (\n                                <button\n                                    key={m.value}\n                                    onClick={() => props.onModelChange(m.value)}\n                                    className={`w-full flex items-center justify-between px-3 py-2 text-left group ${active ? 'bg-[var(--glass-bg-active)]' : 'hover:bg-[var(--glass-bg-hover)]'}`}\n                                >\n                                    <div className=\"flex items-center gap-2\">\n                                        <span className={`text-[13px] ${active ? 'text-[var(--glass-text-primary)] font-medium' : 'text-[var(--glass-text-secondary)]'}`}>{m.label}</span>\n                                        {m.providerName && (\n                                            <span className=\"text-[10px] text-[var(--glass-text-tertiary)]\">{m.providerName}</span>\n                                        )}\n                                    </div>\n                                    {active && <AppIcon name=\"check\" className=\"w-3.5 h-3.5 text-[var(--glass-text-primary)]\" />}\n                                </button>\n                            )\n                        })}\n                    </div>\n                    {props.capabilityFields.length > 0 && (\n                        <div className=\"border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-base)]\">\n                            {props.capabilityFields.map(field => {\n                                const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n                                return (\n                                    <div key={field.field} className=\"flex flex-col border-b last:border-0 border-[var(--glass-stroke-subtle)]\">\n                                        <div className=\"px-3 pt-2 text-[10px] tracking-wider uppercase text-[var(--glass-text-tertiary)] font-semibold\">{field.label || field.field}</div>\n                                        <div className=\"flex px-2 pb-2 mt-1 flex-wrap gap-1\">\n                                            {field.options.map((opt) => {\n                                                const s = String(opt)\n                                                const active = s === val\n                                                return (\n                                                    <button\n                                                        key={s}\n                                                        onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                        className={`min-w-[40px] px-2 py-1 text-[11px] font-mono rounded transition-colors ${active ? 'bg-[var(--glass-text-primary)] text-[var(--glass-bg-base)]' : 'bg-transparent text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)] box-border border border-transparent'}`}\n                                                    >\n                                                        {s}\n                                                    </button>\n                                                )\n                                            })}\n                                        </div>\n                                    </div>\n                                )\n                            })}\n                        </div>\n                    )}\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// Variant 3: Neon / Playful Flow (Gradient accents, expressive)\n// Premium AI feeling with slight accent colors and generous padding\n// ============================================================================\nexport function ModelDropdownV3(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n    const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)\n\n    return (\n        <>\n            <button\n                ref={triggerRef}\n                onClick={() => setIsOpen(!isOpen)}\n                className={`relative flex items-center justify-between w-full h-[54px] px-4 rounded-[16px] transition-all duration-300 overflow-hidden group bg-[var(--glass-bg-surface)] backdrop-blur-xl ${isOpen ? 'shadow-[0_0_0_2px_#3B82F6]' : 'hover:shadow-[0_4px_24px_rgba(0,0,0,0.05)] border border-[var(--glass-stroke-base)]'}`}\n            >\n                {isOpen && <div className=\"absolute inset-0 bg-blue-500/5 transition-opacity duration-300\" />}\n                <div className=\"relative flex flex-col items-start min-w-0 pr-4 z-10\">\n                    <div className=\"flex items-center gap-2 w-full\">\n                        <span className=\"font-bold text-[15px] truncate text-[var(--glass-text-primary)]\" style={{ fontFamily: 'Inter, sans-serif' }}>\n                            {activeModel ? activeModel.label : props.placeholder}\n                        </span>\n                        {activeModel?.providerName && (\n                            <span className=\"px-1.5 py-0.5 rounded border border-[var(--glass-stroke-base)] text-[10px] text-[var(--glass-text-tertiary)] uppercase tracking-wider\">\n                                {activeModel.providerName}\n                            </span>\n                        )}\n                    </div>\n                    {summary && <span className=\"text-[12px] mt-0.5 font-medium text-blue-500 dark:text-blue-400 opacity-80\">{summary}</span>}\n                </div>\n                <div className=\"relative w-8 h-8 rounded-full bg-[var(--glass-bg-muted)] flex items-center justify-center shrink-0 group-hover:bg-[var(--glass-bg-surface-strong)] transition-colors z-10\">\n                    <AppIcon name=\"chevronDown\" className={`w-4 h-4 text-[var(--glass-text-secondary)] transition-transform duration-300 ${isOpen ? 'rotate-180 text-blue-500' : ''}`} />\n                </div>\n            </button>\n\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-[20px] shadow-[0_24px_48px_rgba(0,0,0,0.2)] border border-[var(--glass-stroke-base)] flex flex-col bg-gradient-to-br from-[var(--glass-bg-surface-strong)] to-[var(--glass-bg-surface)] backdrop-blur-3xl overflow-hidden\">\n                    <div className=\"overflow-y-auto max-h-[220px] p-2 space-y-1\">\n                        {props.models.map(m => {\n                            const active = m.value === props.value\n                            return (\n                                <button\n                                    key={m.value}\n                                    onClick={() => props.onModelChange(m.value)}\n                                    className={`w-full flex items-center px-4 py-3 rounded-xl transition-all ${active ? 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white shadow-lg' : 'hover:bg-[var(--glass-bg-hover)] text-[var(--glass-text-secondary)]'}`}\n                                >\n                                    <div className=\"w-5 h-5 mr-3 shrink-0 flex items-center justify-center\">\n                                        {active ? <AppIcon name=\"check\" className=\"w-4 h-4 text-white\" /> : <div className=\"w-1.5 h-1.5 rounded-full bg-[var(--glass-text-tertiary)] opacity-30\" />}\n                                    </div>\n                                    <span className={`text-[14px] flex-1 text-left ${active ? 'font-bold' : 'font-medium'}`}>{m.label}</span>\n                                </button>\n                            )\n                        })}\n                    </div>\n                    {props.capabilityFields.length > 0 && (\n                        <div className=\"p-4 border-t border-[var(--glass-stroke-subtle)] bg-black/5 dark:bg-white/5 space-y-4\">\n                            {props.capabilityFields.map(field => {\n                                const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n                                return (\n                                    <div key={field.field} className=\"flex flex-col gap-2\">\n                                        <div className=\"text-[13px] font-semibold text-[var(--glass-text-primary)]\">{field.label || field.field}</div>\n                                        <div className=\"flex flex-wrap gap-2\">\n                                            {field.options.map((opt) => {\n                                                const s = String(opt)\n                                                const active = s === val\n                                                return (\n                                                    <button\n                                                        key={s}\n                                                        onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                        className={`px-4 py-1.5 rounded-full text-[12px] font-bold transition-all border ${active ? 'bg-blue-500/10 border-blue-500 text-blue-600 dark:text-blue-400' : 'bg-transparent border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] hover:border-[var(--glass-text-tertiary)]'}`}\n                                                    >\n                                                        {s}\n                                                    </button>\n                                                )\n                                            })}\n                                        </div>\n                                    </div>\n                                )\n                            })}\n                        </div>\n                    )}\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// Variant 4: Card Overlay (Like the original photo, but beautifully refined)\n// Dual-tone top and bottom, very clear visual hierarchy.\n// ============================================================================\nexport function ModelDropdownV4(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n\n    // Convert to what original had: e.g. \"2 · 720p\"\n    const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)\n\n    return (\n        <>\n            <button\n                ref={triggerRef}\n                onClick={() => setIsOpen(!isOpen)}\n                className={`flex items-center justify-between w-full h-[50px] px-4 rounded-[12px] bg-[var(--glass-bg-surface)] border transition-shadow duration-200 ${isOpen ? 'border-[#8B5CF6] shadow-[0_0_0_4px_rgba(139,92,246,0.15)] ring-0' : 'border-[var(--glass-stroke-base)] hover:border-gray-400 dark:hover:border-gray-500'}`}\n            >\n                <div className=\"flex items-center gap-3\">\n                    <span className=\"font-semibold text-[15px] text-[var(--glass-text-primary)]\">{activeModel ? activeModel.label : props.placeholder}</span>\n                    {activeModel?.providerName && (\n                        <span className=\"px-2 py-0.5 rounded-[6px] border border-[var(--glass-stroke-subtle)] text-[11px] text-[var(--glass-text-secondary)] bg-[var(--glass-bg-base)] shadow-sm\">\n                            {activeModel.providerName}\n                        </span>\n                    )}\n                </div>\n                <div className=\"flex items-center gap-3\">\n                    {summary && <span className=\"font-medium text-[13px] text-[var(--glass-text-secondary)]\">{summary}</span>}\n                    <AppIcon name=\"chevronDown\" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n                </div>\n            </button>\n\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-[16px] shadow-[0_16px_50px_rgba(0,0,0,0.12)] border border-[var(--glass-stroke-base)] overflow-hidden flex flex-col bg-white dark:bg-[#1C1C1E]\">\n                    {/* Top: Models */}\n                    <div className=\"px-3 pt-3 pb-2 bg-[var(--glass-bg-base)]\">\n                        <div className=\"text-[12px] font-bold text-[var(--glass-text-secondary)] mb-2 px-1\">选择模型</div>\n                        <div className=\"overflow-y-auto max-h-[160px] custom-scrollbar space-y-1 pr-1\">\n                            {props.models.map(m => {\n                                const active = m.value === props.value\n                                return (\n                                    <button\n                                        key={m.value}\n                                        onClick={() => props.onModelChange(m.value)}\n                                        className={`w-full flex items-center px-3 py-2.5 rounded-[10px] transition-all border ${active ? 'bg-blue-50/50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800' : 'bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5'}`}\n                                    >\n                                        <span className={`text-[14px] flex-1 text-left ${active ? 'font-semibold text-blue-600 dark:text-blue-400' : 'text-[var(--glass-text-primary)] font-medium'}`}>{m.label}</span>\n                                        {m.providerName && (\n                                            <span className=\"px-1.5 py-0.5 rounded text-[10px] bg-black/5 dark:bg-white/10 text-[var(--glass-text-secondary)] ml-2\">{m.providerName}</span>\n                                        )}\n                                        {active && <div className=\"w-1.5 h-6 rounded-full bg-blue-500 ml-3\" />}\n                                    </button>\n                                )\n                            })}\n                        </div>\n                    </div>\n                    {/* Bottom: Settings */}\n                    {props.capabilityFields.length > 0 && (\n                        <div className=\"p-4 bg-[var(--glass-bg-surface)] border-t border-[var(--glass-stroke-subtle)] space-y-4\">\n                            <div className=\"text-[12px] font-bold text-[var(--glass-text-secondary)]\">参数配置</div>\n                            {props.capabilityFields.map(field => {\n                                const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n\n                                // To mimic the \"duration using select, ratio using pill\" behaviour from the original\n                                const useSelectBox = field.options.every(o => typeof o === 'number') || field.field.toLowerCase().includes('duration')\n\n                                return (\n                                    <div key={field.field} className=\"flex items-center justify-between gap-4\">\n                                        <span className=\"text-[14px] text-[var(--glass-text-primary)] font-medium shrink-0\">{field.label || field.field}</span>\n\n                                        {useSelectBox ? (\n                                            <div className=\"relative w-[120px]\">\n                                                <select\n                                                    value={val}\n                                                    onChange={e => props.onCapabilityChange(field.field, e.target.value, field.options[0])}\n                                                    className=\"w-full h-[36px] appearance-none bg-[var(--glass-bg-base)] border border-[var(--glass-stroke-base)] rounded-[8px] px-3 font-medium text-[13px] text-[var(--glass-text-primary)] focus:outline-none focus:border-blue-500\"\n                                                >\n                                                    {field.options.map(opt => <option key={String(opt)} value={String(opt)}>{opt}</option>)}\n                                                </select>\n                                                <AppIcon name=\"chevronDown\" className=\"w-4 h-4 text-[var(--glass-text-tertiary)] absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none\" />\n                                            </div>\n                                        ) : (\n                                            <div className=\"flex bg-[var(--glass-bg-base)] p-[3px] rounded-[10px] border border-[var(--glass-stroke-subtle)]\">\n                                                {field.options.map((opt) => {\n                                                    const s = String(opt)\n                                                    const active = s === val\n                                                    return (\n                                                        <button\n                                                            key={s}\n                                                            onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                            className={`px-3 py-1.5 text-[13px] font-medium rounded-[7px] transition-all min-w-[50px] text-center ${active ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 shadow-sm' : 'text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] hover:bg-black/5 dark:hover:bg-white/5'}`}\n                                                        >\n                                                            {s}\n                                                        </button>\n                                                    )\n                                                })}\n                                            </div>\n                                        )}\n                                    </div>\n                                )\n                            })}\n                        </div>\n                    )}\n                </div>, document.body\n            )}\n        </>\n    )\n}\n\n// ============================================================================\n// Variant 5: Ultra-Minimal Inline Flat\n// No explicit boxes for the dropdown fields. A seamless, document-like feel.\n// ============================================================================\nexport function ModelDropdownV5(props: ModelDropdownTestProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { triggerRef, panelRef, panelStyle } = useDropdown(isOpen, setIsOpen)\n    const activeModel = props.models.find(m => m.value === props.value)\n    const summary = resolveParamSummary(props.capabilityFields, props.capabilityOverrides)\n\n    return (\n        <>\n            <button\n                ref={triggerRef}\n                onClick={() => setIsOpen(!isOpen)}\n                className={`group flex flex-col justify-center w-full px-2 py-2 rounded-lg transition-all border-b-2 ${isOpen ? 'border-[#FCA5A5] bg-[var(--glass-bg-hover)]' : 'border-transparent hover:border-[var(--glass-stroke-base)] hover:bg-[var(--glass-bg-surface)]'}`}\n            >\n                <div className=\"flex items-center gap-2 w-full\">\n                    <span className=\"font-semibold text-[16px] text-[var(--glass-text-primary)]\">\n                        {activeModel ? activeModel.label : props.placeholder}\n                    </span>\n                    <AppIcon name=\"chevronDown\" className={`w-4 h-4 text-[var(--glass-text-tertiary)] ml-auto transition-transform ${isOpen ? 'rotate-180 text-[#FCA5A5]' : 'group-hover:text-[var(--glass-text-primary)]'}`} />\n                </div>\n                <div className=\"flex items-center gap-2 mt-1 opacity-70\">\n                    <span className=\"text-[12px] text-[var(--glass-text-tertiary)] font-mono uppercase\">\n                        {activeModel?.providerName || 'MODEL'}\n                    </span>\n                    {summary && (\n                        <>\n                            <span className=\"w-1 h-1 rounded-full bg-gray-400\" />\n                            <span className=\"text-[12px] text-[var(--glass-text-secondary)]\">{summary}</span>\n                        </>\n                    )}\n                </div>\n            </button>\n\n            {isOpen && createPortal(\n                <div ref={panelRef} style={panelStyle} className=\"glass-surface-modal rounded-xl shadow-[0_20px_60px_-15px_rgba(0,0,0,0.3)] border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-base)] overflow-hidden flex flex-col\">\n                    <div className=\"flex-1 overflow-y-auto max-h-[200px] p-2\">\n                        {props.models.map(m => {\n                            const active = m.value === props.value\n                            return (\n                                <button\n                                    key={m.value}\n                                    onClick={() => props.onModelChange(m.value)}\n                                    className={`relative flex items-center w-full px-4 py-3 rounded-lg text-left transition-colors mb-1 overflow-hidden ${active ? 'bg-[#FCA5A5]/10' : 'hover:bg-[var(--glass-bg-surface-strong)]'}`}\n                                >\n                                    {active && <div className=\"absolute left-0 top-1/2 -translate-y-1/2 w-1 h-1/2 bg-[#FCA5A5] rounded-r-md\" />}\n                                    <span className={`text-[15px] flex-1 ${active ? 'text-[#FCA5A5] font-bold' : 'text-[var(--glass-text-primary)] font-medium'}`}>{m.label}</span>\n                                    {m.providerName && (\n                                        <span className=\"text-[11px] font-mono text-[var(--glass-text-tertiary)] bg-[var(--glass-bg-surface)] px-2 py-0.5 rounded\">{m.providerName}</span>\n                                    )}\n                                </button>\n                            )\n                        })}\n                    </div>\n                    {props.capabilityFields.length > 0 && (\n                        <div className=\"p-4 bg-[var(--glass-bg-surface-strong)] border-t border-[var(--glass-stroke-base)] space-y-4\">\n                            {props.capabilityFields.map(field => {\n                                const val = props.capabilityOverrides[field.field] !== undefined ? String(props.capabilityOverrides[field.field]) : String(field.options[0] || '')\n                                return (\n                                    <div key={field.field} className=\"flex flex-col gap-2\">\n                                        <span className=\"text-[11px] uppercase tracking-widest font-bold text-[var(--glass-text-tertiary)]\">{field.label || field.field}</span>\n                                        <div className=\"flex gap-2\">\n                                            {field.options.map((opt) => {\n                                                const s = String(opt)\n                                                const active = s === val\n                                                return (\n                                                    <button\n                                                        key={s}\n                                                        onClick={() => props.onCapabilityChange(field.field, s, field.options[0])}\n                                                        className={`flex-1 py-1.5 text-[13px] font-semibold rounded-md border-b-2 transition-all ${active ? 'border-[#FCA5A5] text-[#FCA5A5] bg-[#FCA5A5]/5' : 'border-transparent text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-hover)]'}`}\n                                                    >\n                                                        {s}\n                                                    </button>\n                                                )\n                                            })}\n                                        </div>\n                                    </div>\n                                )\n                            })}\n                        </div>\n                    )}\n                </div>, document.body\n            )}\n        </>\n    )\n}\n"
  },
  {
    "path": "src/components/ui/patterns/PanelCardV2.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport type { PanelEditData } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PanelEditForm'\nimport type { StoryboardPanel } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useStoryboardState'\nimport { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'\nimport { GlassButton, GlassChip, GlassSurface } from '@/components/ui/primitives'\nimport PanelEditFormV2 from './PanelEditFormV2'\nimport type { UiPatternMode } from './types'\n\ninterface PanelCandidateData {\n  candidates: string[]\n  selectedIndex: number\n}\n\nexport interface PanelCardV2Props {\n  panel: StoryboardPanel\n  panelData: PanelEditData\n  imageUrl: string | null\n  globalPanelNumber: number\n  isSaving: boolean\n  isDeleting: boolean\n  isModifying: boolean\n  isTaskRunning: boolean\n  failedError: string | null\n  candidateData: PanelCandidateData | null\n  onUpdate: (updates: Partial<PanelEditData>) => void\n  onDelete: () => void\n  onOpenCharacterPicker: () => void\n  onOpenLocationPicker: () => void\n  onRemoveCharacter: (index: number) => void\n  onRemoveLocation: () => void\n  onRegeneratePanelImage: (panelId: string, count?: number, force?: boolean) => void\n  onOpenEditModal: () => void\n  onOpenAIDataModal: () => void\n  onSelectCandidateIndex: (panelId: string, index: number) => void\n  onConfirmCandidate: (panelId: string, imageUrl: string) => Promise<void>\n  onCancelCandidate: (panelId: string) => void\n  onClearError: () => void\n  uiMode?: UiPatternMode\n}\n\nexport default function PanelCardV2({\n  panel,\n  panelData,\n  imageUrl,\n  globalPanelNumber,\n  isSaving,\n  isDeleting,\n  isModifying,\n  isTaskRunning,\n  failedError,\n  candidateData,\n  onUpdate,\n  onDelete,\n  onOpenCharacterPicker,\n  onOpenLocationPicker,\n  onRemoveCharacter,\n  onRemoveLocation,\n  onRegeneratePanelImage,\n  onOpenEditModal,\n  onOpenAIDataModal,\n  onSelectCandidateIndex,\n  onConfirmCandidate,\n  onCancelCandidate,\n  onClearError,\n  uiMode = 'flow'\n}: PanelCardV2Props) {\n  const t = useTranslations('storyboard')\n  const selectedCandidate =\n    candidateData && candidateData.candidates[candidateData.selectedIndex]\n      ? candidateData.candidates[candidateData.selectedIndex]\n      : null\n\n  return (\n    <GlassSurface\n      variant=\"elevated\"\n      padded={false}\n      className={`ui-pattern-panel-card ui-pattern-panel-card-${uiMode} relative overflow-hidden`}\n    >\n      <div className=\"relative\">\n        <div className=\"aspect-[9/16] w-full overflow-hidden bg-[rgba(255,255,255,0.35)]\">\n          {isDeleting || isModifying || isTaskRunning ? (\n            <div className=\"flex h-full items-center justify-center\">\n              <GlassChip tone={isDeleting ? 'danger' : 'info'}>\n                {isDeleting\n                  ? t('common.deleting')\n                  : isModifying\n                    ? t('common.editing')\n                    : t('image.generating')}\n              </GlassChip>\n            </div>\n          ) : failedError ? (\n            <div className=\"flex h-full flex-col items-center justify-center gap-2 p-4 text-center\">\n              <GlassChip tone=\"danger\">{t('image.failed')}</GlassChip>\n              <p className=\"text-xs text-[var(--glass-text-secondary)]\">{failedError}</p>\n              <GlassButton size=\"sm\" variant=\"ghost\" onClick={onClearError}>{t('common.cancel')}</GlassButton>\n            </div>\n          ) : selectedCandidate ? (\n            <MediaImageWithLoading\n              src={selectedCandidate}\n              alt=\"candidate\"\n              containerClassName=\"h-full w-full\"\n              className=\"h-full w-full object-cover\"\n            />\n          ) : imageUrl ? (\n            <MediaImageWithLoading\n              src={imageUrl}\n              alt=\"panel\"\n              containerClassName=\"h-full w-full\"\n              className=\"h-full w-full object-cover\"\n            />\n          ) : (\n            <div className=\"flex h-full items-center justify-center\">\n              <GlassButton size=\"sm\" variant=\"secondary\" onClick={() => onRegeneratePanelImage(panel.id, 1)}>\n                {t('panel.generateImage')}\n              </GlassButton>\n            </div>\n          )}\n        </div>\n\n        <div className=\"absolute left-2 top-2 flex items-center gap-2\">\n          <GlassChip tone=\"neutral\">#{globalPanelNumber}</GlassChip>\n          <GlassChip tone=\"info\">{panel.shot_type || t('panel.noShotType')}</GlassChip>\n        </div>\n\n        <div className=\"absolute right-2 top-2\">\n          <GlassButton size=\"sm\" variant=\"danger\" onClick={onDelete}>{t('common.delete')}</GlassButton>\n        </div>\n\n        <div className=\"absolute bottom-2 left-2 right-2 flex flex-wrap items-center gap-2\">\n          <GlassButton size=\"sm\" variant=\"secondary\" onClick={() => onRegeneratePanelImage(panel.id, 1, isTaskRunning)}>\n            {t('image.regenerate')}\n          </GlassButton>\n          <GlassButton size=\"sm\" variant=\"secondary\" onClick={onOpenEditModal}>{t('image.editImage')}</GlassButton>\n          <GlassButton size=\"sm\" variant=\"secondary\" onClick={onOpenAIDataModal}>{t('aiData.title')}</GlassButton>\n\n          {candidateData ? (\n            <>\n              <GlassButton size=\"sm\" variant=\"ghost\" onClick={() => onCancelCandidate(panel.id)}>{t('image.cancelSelection')}</GlassButton>\n              <GlassButton\n                size=\"sm\"\n                variant=\"primary\"\n                onClick={() => {\n                  const candidate = candidateData.candidates[candidateData.selectedIndex]\n                  if (candidate) {\n                    void onConfirmCandidate(panel.id, candidate)\n                  }\n                }}\n              >\n                {t('image.confirmCandidate')}\n              </GlassButton>\n              <div className=\"ml-auto flex gap-1\">\n                {candidateData.candidates.slice(0, 4).map((_, index) => (\n                  <button\n                    key={index}\n                    type=\"button\"\n                    onClick={() => onSelectCandidateIndex(panel.id, index)}\n                    className={`h-2.5 w-2.5 rounded-full ${index === candidateData.selectedIndex ? 'bg-[var(--glass-accent-from)]' : 'bg-[var(--glass-bg-surface)]/80 border border-[var(--glass-stroke-base)]'}`}\n                    aria-label={`candidate-${index + 1}`}\n                  />\n                ))}\n              </div>\n            </>\n          ) : null}\n        </div>\n      </div>\n\n      <div className=\"p-3\">\n        <PanelEditFormV2\n          panelData={panelData}\n          isSaving={isSaving}\n          onUpdate={onUpdate}\n          onOpenCharacterPicker={onOpenCharacterPicker}\n          onOpenLocationPicker={onOpenLocationPicker}\n          onRemoveCharacter={onRemoveCharacter}\n          onRemoveLocation={onRemoveLocation}\n          uiMode={uiMode}\n        />\n      </div>\n    </GlassSurface>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/patterns/PanelEditFormV2.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport type { PanelEditData } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PanelEditForm'\nimport {\n  GlassChip,\n  GlassField,\n  GlassInput,\n  GlassTextarea\n} from '@/components/ui/primitives'\nimport type { UiPatternMode } from './types'\nimport { AppIcon } from '@/components/ui/icons'\n\nexport interface PanelEditFormV2Props {\n  panelData: PanelEditData\n  isSaving?: boolean\n  saveStatus?: 'idle' | 'saving' | 'error'\n  saveErrorMessage?: string | null\n  onRetrySave?: () => void\n  onUpdate: (updates: Partial<PanelEditData>) => void\n  onOpenCharacterPicker: () => void\n  onOpenLocationPicker: () => void\n  onRemoveCharacter: (index: number) => void\n  onRemoveLocation: () => void\n  uiMode?: UiPatternMode\n}\n\nexport default function PanelEditFormV2({\n  panelData,\n  isSaving = false,\n  saveStatus = 'idle',\n  saveErrorMessage = null,\n  onRetrySave,\n  onUpdate,\n  onOpenCharacterPicker,\n  onOpenLocationPicker,\n  onRemoveCharacter,\n  onRemoveLocation,\n  uiMode = 'flow'\n}: PanelEditFormV2Props) {\n  const t = useTranslations('storyboard')\n\n  return (\n    <div className={`ui-pattern-form ui-pattern-form-${uiMode} space-y-2`}>\n      {saveStatus === 'saving' || isSaving ? (\n        <GlassChip tone=\"info\" icon={<span className=\"h-2 w-2 animate-pulse rounded-full bg-current\" />}>\n          {t('common.saving')}\n        </GlassChip>\n      ) : null}\n      {saveStatus === 'error' ? (\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <GlassChip tone=\"danger\">\n            {saveErrorMessage || t('common.saveFailed')}\n          </GlassChip>\n          {onRetrySave ? (\n            <button\n              type=\"button\"\n              onClick={onRetrySave}\n              className=\"glass-btn-base glass-btn-soft px-2 py-1 text-xs\"\n            >\n              {t('common.retrySave')}\n            </button>\n          ) : null}\n        </div>\n      ) : null}\n\n      <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-2\">\n        <GlassField label={t('panel.shotTypeLabel')}>\n          <GlassInput\n            density=\"compact\"\n            value={panelData.shotType || ''}\n            onChange={(event) => onUpdate({ shotType: event.target.value || null })}\n            placeholder={t('panel.shotTypePlaceholder')}\n          />\n        </GlassField>\n\n        <GlassField label={t('panel.cameraMove')}>\n          <GlassInput\n            density=\"compact\"\n            value={panelData.cameraMove || ''}\n            onChange={(event) => onUpdate({ cameraMove: event.target.value || null })}\n            placeholder={t('panel.cameraMovePlaceholder')}\n          />\n        </GlassField>\n      </div>\n\n      {panelData.sourceText ? (\n        <GlassField label={t('panel.sourceText')}>\n          <div className=\"rounded-[var(--glass-radius-md)] bg-[var(--glass-bg-surface-strong)] px-3 py-2.5\">\n            <p className=\"text-sm leading-6 text-[var(--glass-text-secondary)]\">&ldquo;{panelData.sourceText}&rdquo;</p>\n          </div>\n        </GlassField>\n      ) : null}\n\n      <GlassField label={t('panel.sceneDescription')}>\n        <GlassTextarea\n          density=\"compact\"\n          rows={2}\n          value={panelData.description || ''}\n          onChange={(event) => onUpdate({ description: event.target.value })}\n          placeholder={t('panel.sceneDescriptionPlaceholder')}\n        />\n      </GlassField>\n\n      <GlassField label={t('panel.videoPrompt')} hint={t('panel.videoPromptHint')}>\n        <GlassTextarea\n          density=\"compact\"\n          rows={2}\n          value={panelData.videoPrompt || ''}\n          onChange={(event) => onUpdate({ videoPrompt: event.target.value })}\n          placeholder={t('panel.videoPromptPlaceholder')}\n        />\n      </GlassField>\n\n      <div className=\"grid grid-cols-1 gap-2 xl:grid-cols-2\">\n        <GlassField\n          label={t('panel.locationLabel')}\n          actions={\n            <button\n              type=\"button\"\n              onClick={onOpenLocationPicker}\n              className=\"inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors\"\n              aria-label={t('panel.editLocation')}\n              title={t('panel.editLocation')}\n            >\n              <AppIcon name=\"edit\" className=\"h-4 w-4\" />\n            </button>\n          }\n        >\n          {panelData.location ? (\n            <div className=\"flex flex-wrap gap-1.5\">\n              <GlassChip tone=\"success\" onRemove={onRemoveLocation}>{panelData.location}</GlassChip>\n            </div>\n          ) : (\n            <p className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('panel.locationNotEdited')}</p>\n          )}\n        </GlassField>\n\n        <GlassField\n          label={t('panel.characterLabelWithCount', { count: panelData.characters.length })}\n          actions={\n            <button\n              type=\"button\"\n              onClick={onOpenCharacterPicker}\n              className=\"inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors\"\n              aria-label={t('panel.editCharacter')}\n              title={t('panel.editCharacter')}\n            >\n              <AppIcon name=\"edit\" className=\"h-4 w-4\" />\n            </button>\n          }\n        >\n          {panelData.characters.length > 0 ? (\n            <div className=\"flex flex-wrap gap-1.5\">\n              {panelData.characters.map((character, index) => (\n                <GlassChip key={`${character.name}-${index}`} tone=\"info\" onRemove={() => onRemoveCharacter(index)}>\n                  {character.name}({character.appearance})\n                </GlassChip>\n              ))}\n            </div>\n          ) : (\n            <p className=\"text-xs text-[var(--glass-text-tertiary)]\">{t('panel.charactersNotEdited')}</p>\n          )}\n        </GlassField>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/patterns/StoryboardHeaderV2.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { GlassButton, GlassChip, GlassSurface } from '@/components/ui/primitives'\nimport type { UiPatternMode } from './types'\n\nexport interface StoryboardHeaderV2Props {\n  totalSegments: number\n  totalPanels: number\n  isDownloadingImages: boolean\n  runningCount: number\n  pendingPanelCount: number\n  isBatchSubmitting: boolean\n  onDownloadAllImages: () => void\n  onGenerateAllPanels: () => void\n  onBack: () => void\n  uiMode?: UiPatternMode\n}\n\nexport default function StoryboardHeaderV2({\n  totalSegments,\n  totalPanels,\n  isDownloadingImages,\n  runningCount,\n  pendingPanelCount,\n  isBatchSubmitting,\n  onDownloadAllImages,\n  onGenerateAllPanels,\n  onBack,\n  uiMode = 'flow'\n}: StoryboardHeaderV2Props) {\n  const t = useTranslations('storyboard')\n\n  return (\n    <GlassSurface variant=\"elevated\" className={`ui-pattern-header ui-pattern-header-${uiMode} space-y-4`}>\n      <div className=\"flex flex-col gap-4 md:flex-row md:items-center md:justify-between\">\n        <div className=\"space-y-1\">\n          <h3 className=\"text-sm font-semibold text-[var(--glass-text-primary)]\">{t('header.storyboardPanel')} (V2)</h3>\n          <p className=\"text-sm text-[var(--glass-text-secondary)]\">\n            {t('header.segmentsCount', { count: totalSegments })} {t('header.panelsCount', { count: totalPanels })}\n          </p>\n        </div>\n\n        <div className=\"flex flex-wrap items-center gap-2\">\n          {runningCount > 0 ? (\n            <GlassChip tone=\"info\" icon={<span className=\"h-2 w-2 animate-pulse rounded-full bg-current\" />}>\n              {t('header.generatingStatus', { count: runningCount })}\n            </GlassChip>\n          ) : null}\n          <GlassChip tone=\"neutral\">{t('header.concurrencyLimit', { count: 10 })}</GlassChip>\n        </div>\n      </div>\n\n      <div className=\"flex flex-wrap items-center gap-2\">\n        {pendingPanelCount > 0 ? (\n          <GlassButton\n            variant=\"primary\"\n            loading={isBatchSubmitting}\n            onClick={onGenerateAllPanels}\n            disabled={runningCount > 0}\n          >\n            {t('header.generatePendingPanels', { count: pendingPanelCount })}\n          </GlassButton>\n        ) : null}\n\n        <GlassButton\n          variant=\"secondary\"\n          loading={isDownloadingImages}\n          onClick={onDownloadAllImages}\n          disabled={totalPanels === 0}\n        >\n          {t('header.downloadAll')}\n        </GlassButton>\n\n        <GlassButton variant=\"ghost\" onClick={onBack}>{t('header.back')}</GlassButton>\n      </div>\n    </GlassSurface>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/patterns/index.ts",
    "content": "export { default as StoryboardHeaderV2 } from './StoryboardHeaderV2'\nexport type { StoryboardHeaderV2Props } from './StoryboardHeaderV2'\n\nexport { default as PanelEditFormV2 } from './PanelEditFormV2'\nexport type { PanelEditFormV2Props } from './PanelEditFormV2'\n\nexport { default as PanelCardV2 } from './PanelCardV2'\nexport type { PanelCardV2Props } from './PanelCardV2'\n\nexport type { UiPatternMode } from './types'\n"
  },
  {
    "path": "src/components/ui/patterns/types.ts",
    "content": "export type UiPatternMode = 'flow'\n"
  },
  {
    "path": "src/components/ui/primitives/GlassButton.tsx",
    "content": "import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\n\nexport interface GlassButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n  variant?: 'primary' | 'secondary' | 'ghost' | 'danger'\n  size?: 'sm' | 'md' | 'lg'\n  loading?: boolean\n  iconLeft?: ReactNode\n  iconRight?: ReactNode\n}\n\nfunction cx(...names: Array<string | false | null | undefined>) {\n  return names.filter(Boolean).join(' ')\n}\n\nconst GlassButton = forwardRef<HTMLButtonElement, GlassButtonProps>(function GlassButton(\n  {\n    variant = 'secondary',\n    size = 'md',\n    loading = false,\n    iconLeft,\n    iconRight,\n    className,\n    children,\n    disabled,\n    ...props\n  },\n  ref\n) {\n  const variantClass =\n    variant === 'primary' ? 'glass-btn-primary' :\n      variant === 'ghost' ? 'glass-btn-ghost' :\n        variant === 'danger' ? 'glass-btn-danger' :\n          'glass-btn-secondary'\n\n  const sizeClass =\n    size === 'sm' ? 'h-8 px-3 text-xs' :\n      size === 'lg' ? 'h-11 px-5 text-base' :\n        'h-9 px-4 text-sm'\n  const loadingState = loading\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'generate',\n      resource: 'text',\n      hasOutput: true,\n    })\n    : null\n\n  return (\n    <button\n      ref={ref}\n      className={cx('glass-btn-base', variantClass, sizeClass, className)}\n      disabled={disabled || loading}\n      {...props}\n    >\n      {loading ? (\n        <TaskStatusInline state={loadingState} className=\"[&>span]:sr-only\" />\n      ) : iconLeft}\n      {children}\n      {!loading && iconRight}\n    </button>\n  )\n})\n\nexport default GlassButton\n"
  },
  {
    "path": "src/components/ui/primitives/GlassChip.tsx",
    "content": "import type { ReactNode } from 'react'\nimport { AppIcon } from '@/components/ui/icons'\n\nexport type UiTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'\n\nexport interface GlassChipProps {\n  tone?: UiTone\n  icon?: ReactNode\n  onRemove?: () => void\n  children: ReactNode\n  className?: string\n}\n\nfunction cx(...names: Array<string | false | null | undefined>) {\n  return names.filter(Boolean).join(' ')\n}\n\nexport default function GlassChip({ tone = 'neutral', icon, onRemove, children, className }: GlassChipProps) {\n  const toneClass =\n    tone === 'info' ? 'glass-chip-info' :\n      tone === 'success' ? 'glass-chip-success' :\n        tone === 'warning' ? 'glass-chip-warning' :\n          tone === 'danger' ? 'glass-chip-danger' :\n            'glass-chip-neutral'\n\n  return (\n    <span className={cx('glass-chip', toneClass, className)}>\n      {icon}\n      <span>{children}</span>\n      {onRemove ? (\n        <button\n          type=\"button\"\n          onClick={onRemove}\n          className=\"rounded-full p-0.5 transition-colors hover:bg-black/10\"\n          aria-label=\"remove\"\n        >\n          <AppIcon name=\"close\" className=\"h-3 w-3\" />\n        </button>\n      ) : null}\n    </span>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/primitives/GlassField.tsx",
    "content": "import type { ReactNode } from 'react'\n\nexport interface GlassFieldProps {\n  id?: string\n  label?: ReactNode\n  hint?: ReactNode\n  error?: ReactNode\n  required?: boolean\n  actions?: ReactNode\n  className?: string\n  children: ReactNode\n}\n\nfunction cx(...names: Array<string | false | null | undefined>) {\n  return names.filter(Boolean).join(' ')\n}\n\nexport default function GlassField({\n  id,\n  label,\n  hint,\n  error,\n  required = false,\n  actions,\n  className,\n  children\n}: GlassFieldProps) {\n  return (\n    <div className={cx('space-y-1.5', className)}>\n      {(label || actions) && (\n        <div className=\"flex items-center justify-between gap-2\">\n          {label ? (\n            <label htmlFor={id} className=\"glass-field-label\">\n              {label}\n              {required ? <span className=\"ml-1 text-[var(--glass-tone-danger-fg)]\">*</span> : null}\n            </label>\n          ) : <span />}\n          {actions}\n        </div>\n      )}\n      {children}\n      {error ? (\n        <p className=\"text-xs text-[var(--glass-tone-danger-fg)]\">{error}</p>\n      ) : hint ? (\n        <p className=\"glass-field-hint\">{hint}</p>\n      ) : null}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/primitives/GlassInput.tsx",
    "content": "import { forwardRef, type InputHTMLAttributes } from 'react'\n\nexport interface GlassInputProps extends InputHTMLAttributes<HTMLInputElement> {\n  density?: 'compact' | 'default'\n}\n\nfunction cx(...names: Array<string | false | null | undefined>) {\n  return names.filter(Boolean).join(' ')\n}\n\nconst GlassInput = forwardRef<HTMLInputElement, GlassInputProps>(function GlassInput(\n  { density = 'default', className, ...props },\n  ref\n) {\n  return (\n    <input\n      ref={ref}\n      className={cx(\n        'glass-input-base',\n        density === 'compact' ? 'h-9 px-3 text-sm leading-5' : 'h-10 px-3 text-sm leading-5',\n        className\n      )}\n      {...props}\n    />\n  )\n})\n\nexport default GlassInput\n"
  },
  {
    "path": "src/components/ui/primitives/GlassModalShell.tsx",
    "content": "'use client'\n\nimport { useEffect, type ReactNode } from 'react'\nimport { createPortal } from 'react-dom'\nimport { AppIcon } from '@/components/ui/icons'\n\nexport interface GlassModalShellProps {\n  open: boolean\n  onClose: () => void\n  title?: ReactNode\n  description?: ReactNode\n  footer?: ReactNode\n  children: ReactNode\n  size?: 'sm' | 'md' | 'lg' | 'xl'\n  closeOnBackdrop?: boolean\n  closeOnEsc?: boolean\n  showCloseButton?: boolean\n}\n\nfunction cx(...names: Array<string | false | null | undefined>) {\n  return names.filter(Boolean).join(' ')\n}\n\nexport default function GlassModalShell({\n  open,\n  onClose,\n  title,\n  description,\n  footer,\n  children,\n  size = 'md',\n  closeOnBackdrop = true,\n  closeOnEsc = true,\n  showCloseButton = true\n}: GlassModalShellProps) {\n  useEffect(() => {\n    if (!open || !closeOnEsc) return\n    const onKeydown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') onClose()\n    }\n    window.addEventListener('keydown', onKeydown)\n    return () => window.removeEventListener('keydown', onKeydown)\n  }, [open, closeOnEsc, onClose])\n\n  if (!open || typeof document === 'undefined') return null\n\n  const maxWidthClass =\n    size === 'sm' ? 'max-w-md' :\n      size === 'lg' ? 'max-w-4xl' :\n        size === 'xl' ? 'max-w-6xl' :\n          'max-w-2xl'\n\n  return createPortal(\n    <div\n      className=\"fixed inset-0 z-[120] flex items-center justify-center p-4 sm:p-6\"\n      role=\"dialog\"\n      aria-modal=\"true\"\n      onMouseDown={(event) => {\n        if (closeOnBackdrop && event.target === event.currentTarget) onClose()\n      }}\n    >\n      <div\n        className=\"glass-overlay absolute inset-0\"\n        onMouseDown={() => {\n          if (closeOnBackdrop) onClose()\n        }}\n      />\n      <div className={cx('glass-surface-modal relative z-10 w-full overflow-hidden', maxWidthClass)}>\n        {(title || description || showCloseButton) && (\n          <div className=\"flex items-start justify-between gap-4 px-5 py-4 sm:px-6\">\n            <div>\n              {title ? <h2 className=\"text-lg font-semibold text-[var(--glass-text-primary)] sm:text-xl\">{title}</h2> : null}\n              {description ? <p className=\"mt-1 text-sm text-[var(--glass-text-secondary)]\">{description}</p> : null}\n            </div>\n            {showCloseButton ? (\n              <button\n                type=\"button\"\n                onClick={onClose}\n                className=\"glass-btn-base glass-btn-ghost h-9 w-9\"\n                aria-label=\"close\"\n              >\n                <AppIcon name=\"close\" className=\"h-5 w-5\" />\n              </button>\n            ) : null}\n          </div>\n        )}\n\n        <div className=\"glass-divider\" />\n        <div className=\"px-5 py-4 sm:px-6 sm:py-5\">{children}</div>\n\n        {footer ? (\n          <>\n            <div className=\"glass-divider\" />\n            <div className=\"px-5 py-4 sm:px-6\">{footer}</div>\n          </>\n        ) : null}\n      </div>\n    </div>,\n    document.body\n  )\n}\n"
  },
  {
    "path": "src/components/ui/primitives/GlassSurface.tsx",
    "content": "import type { ReactNode } from 'react'\n\nexport type UiDensity = 'compact' | 'default'\n\nexport interface GlassSurfaceProps {\n  children: ReactNode\n  className?: string\n  variant?: 'panel' | 'card' | 'elevated' | 'modal'\n  density?: UiDensity\n  interactive?: boolean\n  padded?: boolean\n}\n\nfunction cx(...names: Array<string | false | null | undefined>) {\n  return names.filter(Boolean).join(' ')\n}\n\nexport default function GlassSurface({\n  children,\n  className,\n  variant = 'panel',\n  density = 'default',\n  interactive = false,\n  padded = true\n}: GlassSurfaceProps) {\n  const variantClass =\n    variant === 'elevated' ? 'glass-surface-elevated' :\n      variant === 'modal' ? 'glass-surface-modal' :\n        'glass-surface'\n\n  const densityClass = density === 'compact' ? 'glass-density-compact' : 'glass-density-default'\n\n  return (\n    <div\n      className={cx(\n        variantClass,\n        densityClass,\n        padded ? 'p-4 md:p-6' : '',\n        interactive ? 'transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[var(--glass-shadow-md)]' : '',\n        className\n      )}\n    >\n      {children}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/primitives/GlassTextarea.tsx",
    "content": "import { forwardRef, type TextareaHTMLAttributes } from 'react'\n\nexport interface GlassTextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n  density?: 'compact' | 'default'\n}\n\nfunction cx(...names: Array<string | false | null | undefined>) {\n  return names.filter(Boolean).join(' ')\n}\n\nconst GlassTextarea = forwardRef<HTMLTextAreaElement, GlassTextareaProps>(function GlassTextarea(\n  { density = 'default', className, ...props },\n  ref\n) {\n  return (\n    <textarea\n      ref={ref}\n      className={cx(\n        'glass-textarea-base resize-none',\n        density === 'compact' ? 'px-3 py-2 text-sm leading-6' : 'px-3 py-2.5 text-sm leading-6',\n        className\n      )}\n      {...props}\n    />\n  )\n})\n\nexport default GlassTextarea\n"
  },
  {
    "path": "src/components/ui/primitives/index.ts",
    "content": "export { default as GlassSurface } from './GlassSurface'\nexport type { GlassSurfaceProps, UiDensity } from './GlassSurface'\n\nexport { default as GlassButton } from './GlassButton'\nexport type { GlassButtonProps } from './GlassButton'\n\nexport { default as GlassField } from './GlassField'\nexport type { GlassFieldProps } from './GlassField'\n\nexport { default as GlassInput } from './GlassInput'\nexport type { GlassInputProps } from './GlassInput'\n\nexport { default as GlassTextarea } from './GlassTextarea'\nexport type { GlassTextareaProps } from './GlassTextarea'\n\nexport { default as GlassChip } from './GlassChip'\nexport type { GlassChipProps, UiTone } from './GlassChip'\n\nexport { default as GlassModalShell } from './GlassModalShell'\nexport type { GlassModalShellProps } from './GlassModalShell'\n"
  },
  {
    "path": "src/components/ui/select-variants.tsx",
    "content": "'use client'\n\nimport React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport { AppIcon } from '@/components/ui/icons'\n\n// ─── Constants & Types ─────────────────────────────────────────\n\nconst VIEWPORT_EDGE_GAP = 8\nconst DEFAULT_MAX_HEIGHT = 280\n\nexport interface SelectOption {\n    value: string\n    label: string\n    description?: string\n    icon?: string\n    disabled?: boolean\n}\n\nexport interface CustomSelectProps {\n    options: SelectOption[]\n    value?: string\n    onChange: (value: string) => void\n    placeholder?: string\n    disabled?: boolean\n    className?: string\n}\n\n// ─── Variant 1: Pill / Solid Card Style ─────────────────────────\n// (最贴近”默认模型配置“卡片的经典风格，四周有饱满的边框与微弱底色)\n\nexport function SelectVariantCard({\n    options,\n    value,\n    onChange,\n    placeholder = '请选择...',\n    disabled = false,\n    className = '',\n}: CustomSelectProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})\n    const triggerRef = useRef<HTMLButtonElement>(null)\n    const panelRef = useRef<HTMLDivElement>(null)\n\n    const selectedOption = options.find((opt) => opt.value === value)\n\n    const updatePosition = useCallback(() => {\n        if (!triggerRef.current) return\n        const rect = triggerRef.current.getBoundingClientRect()\n        const viewportHeight = window.innerHeight || document.documentElement.clientHeight\n        const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP\n        const spaceAbove = rect.top - VIEWPORT_EDGE_GAP\n\n        let openUpward = false\n        let currentMaxHeight = DEFAULT_MAX_HEIGHT\n\n        if (spaceBelow < 200 && spaceAbove > spaceBelow) {\n            openUpward = true\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)\n        } else {\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)\n        }\n\n        setPanelStyle({\n            position: 'fixed',\n            left: rect.left,\n            width: rect.width,\n            maxHeight: currentMaxHeight,\n            ...(openUpward\n                ? { bottom: viewportHeight - rect.top + 4 }\n                : { top: rect.bottom + 4 }),\n        })\n    }, [])\n\n    useEffect(() => {\n        function handleClickOutside(e: MouseEvent) {\n            const target = e.target as Node\n            if (triggerRef.current?.contains(target)) return\n            if (panelRef.current?.contains(target)) return\n            setIsOpen(false)\n        }\n        document.addEventListener('mousedown', handleClickOutside)\n        return () => document.removeEventListener('mousedown', handleClickOutside)\n    }, [])\n\n    useLayoutEffect(() => {\n        if (!isOpen) return\n        updatePosition()\n        window.addEventListener('resize', updatePosition)\n        window.addEventListener('scroll', updatePosition, true)\n        return () => {\n            window.removeEventListener('resize', updatePosition)\n            window.removeEventListener('scroll', updatePosition, true)\n        }\n    }, [isOpen, updatePosition])\n\n    return (\n        <>\n            <button\n                ref={triggerRef}\n                type=\"button\"\n                disabled={disabled}\n                onClick={() => setIsOpen(!isOpen)}\n                className={`glass-input-base w-full flex items-center justify-between px-3 py-2.5 transition-all text-left ${isOpen ? 'ring-1 ring-[var(--glass-stroke-active)]' : ''\n                    } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-[var(--glass-bg-hover)]'} ${className}`}\n            >\n                <div className=\"flex-1 min-w-0 pr-2\">\n                    {selectedOption ? (\n                        <div className=\"flex flex-col\">\n                            <span className=\"text-sm font-medium text-[var(--glass-text-primary)] truncate\">\n                                {selectedOption.label}\n                            </span>\n                            {selectedOption.description && (\n                                <span className=\"text-[11px] text-[var(--glass-text-tertiary)] truncate mt-0.5\">\n                                    {selectedOption.description}\n                                </span>\n                            )}\n                        </div>\n                    ) : (\n                        <span className=\"text-sm text-[var(--glass-text-tertiary)]\">{placeholder}</span>\n                    )}\n                </div>\n                <AppIcon\n                    name=\"chevronDown\"\n                    className={`w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''\n                        }`}\n                />\n            </button>\n\n            {isOpen &&\n                createPortal(\n                    <div\n                        ref={panelRef}\n                        className=\"glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-xl border border-[var(--glass-stroke-base)] py-1\"\n                        style={panelStyle}\n                    >\n                        <div className=\"overflow-y-auto custom-scrollbar px-1 py-1 max-h-full\">\n                            {options.map((opt) => {\n                                const isSelected = value === opt.value\n                                return (\n                                    <button\n                                        key={opt.value}\n                                        type=\"button\"\n                                        disabled={opt.disabled}\n                                        onClick={() => {\n                                            if (opt.disabled) return\n                                            onChange(opt.value)\n                                            setIsOpen(false)\n                                        }}\n                                        className={`flex items-center w-full px-3 py-2 my-0.5 rounded-lg text-left transition-all ${isSelected\n                                                ? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'\n                                                : opt.disabled\n                                                    ? 'text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'\n                                                    : 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'\n                                            }`}\n                                    >\n                                        <div className=\"flex-1 min-w-0\">\n                                            <div className={`text-sm ${isSelected ? 'font-semibold' : 'font-medium'}`}>\n                                                {opt.label}\n                                            </div>\n                                            {opt.description && (\n                                                <div className={`text-[11px] mt-0.5 ${isSelected ? 'text-[var(--glass-tone-info-fg)] opacity-80' : 'text-[var(--glass-text-tertiary)]'}`}>\n                                                    {opt.description}\n                                                </div>\n                                            )}\n                                        </div>\n                                        {isSelected && (\n                                            <AppIcon name=\"check\" className=\"w-4 h-4 shrink-0 overflow-visible ml-2\" />\n                                        )}\n                                    </button>\n                                )\n                            })}\n                        </div>\n                    </div>,\n                    document.body\n                )}\n        </>\n    )\n}\n\n// ─── Variant 2: Minimalist Line / Base Style ────────────────────\n// (底部细线风格，适用于表单密集的区域，突出内容而非边框)\n\nexport function SelectVariantMinimal({\n    options,\n    value,\n    onChange,\n    placeholder = '请选择...',\n    disabled = false,\n    className = '',\n}: CustomSelectProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})\n    const triggerRef = useRef<HTMLButtonElement>(null)\n    const panelRef = useRef<HTMLDivElement>(null)\n\n    const selectedOption = options.find((opt) => opt.value === value)\n\n    const updatePosition = useCallback(() => {\n        if (!triggerRef.current) return\n        const rect = triggerRef.current.getBoundingClientRect()\n        const viewportHeight = window.innerHeight || document.documentElement.clientHeight\n        const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP\n        const spaceAbove = rect.top - VIEWPORT_EDGE_GAP\n\n        let openUpward = false\n        let currentMaxHeight = DEFAULT_MAX_HEIGHT\n\n        if (spaceBelow < 200 && spaceAbove > spaceBelow) {\n            openUpward = true\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)\n        } else {\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)\n        }\n\n        setPanelStyle({\n            position: 'fixed',\n            left: rect.left,\n            width: rect.width,\n            maxHeight: currentMaxHeight,\n            ...(openUpward\n                ? { bottom: viewportHeight - rect.top + 4 }\n                : { top: rect.bottom + 4 }),\n        })\n    }, [])\n\n    useEffect(() => {\n        function handleClickOutside(e: MouseEvent) {\n            const target = e.target as Node\n            if (triggerRef.current?.contains(target)) return\n            if (panelRef.current?.contains(target)) return\n            setIsOpen(false)\n        }\n        document.addEventListener('mousedown', handleClickOutside)\n        return () => document.removeEventListener('mousedown', handleClickOutside)\n    }, [])\n\n    useLayoutEffect(() => {\n        if (!isOpen) return\n        updatePosition()\n        window.addEventListener('resize', updatePosition)\n        window.addEventListener('scroll', updatePosition, true)\n        return () => {\n            window.removeEventListener('resize', updatePosition)\n            window.removeEventListener('scroll', updatePosition, true)\n        }\n    }, [isOpen, updatePosition])\n\n    return (\n        <>\n            <button\n                ref={triggerRef}\n                type=\"button\"\n                disabled={disabled}\n                onClick={() => setIsOpen(!isOpen)}\n                className={`group flex items-center justify-between w-full py-2 px-1 text-left transition-all border-b border-[var(--glass-stroke-base)] ${isOpen ? 'border-[var(--glass-text-primary)]' : 'hover:border-[var(--glass-text-secondary)]'\n                    } ${disabled ? 'opacity-50 cursor-not-allowed border-[var(--glass-stroke-subtle)]' : 'cursor-pointer'} ${className}`}\n            >\n                <div className=\"flex-1 min-w-0\">\n                    {selectedOption ? (\n                        <span className=\"text-sm font-medium text-[var(--glass-text-primary)] truncate\">\n                            {selectedOption.label}\n                        </span>\n                    ) : (\n                        <span className=\"text-sm text-[var(--glass-text-tertiary)]\">{placeholder}</span>\n                    )}\n                </div>\n                <AppIcon\n                    name=\"chevronDown\"\n                    className={`w-4 h-4 text-[var(--glass-text-tertiary)] shrink-0 transition-all ${isOpen ? 'rotate-180 text-[var(--glass-text-primary)]' : 'group-hover:text-[var(--glass-text-secondary)]'\n                        }`}\n                />\n            </button>\n\n            {isOpen &&\n                createPortal(\n                    <div\n                        ref={panelRef}\n                        className=\"glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] border border-[var(--glass-stroke-subtle)] py-1 bg-gradient-to-b from-[var(--glass-bg-surface-strong)] to-[var(--glass-bg-surface)] backdrop-blur-md\"\n                        style={panelStyle}\n                    >\n                        <div className=\"overflow-y-auto custom-scrollbar px-1 py-1 max-h-full\">\n                            {options.map((opt) => {\n                                const isSelected = value === opt.value\n                                return (\n                                    <button\n                                        key={opt.value}\n                                        type=\"button\"\n                                        disabled={opt.disabled}\n                                        onClick={() => {\n                                            if (opt.disabled) return\n                                            onChange(opt.value)\n                                            setIsOpen(false)\n                                        }}\n                                        className={`flex items-center w-full px-4 py-2.5 my-0.5 rounded-md text-left transition-all ${isSelected\n                                                ? 'bg-[var(--glass-text-primary)] text-white dark:text-black dark:bg-[var(--glass-text-primary)] shadow-sm'\n                                                : opt.disabled\n                                                    ? 'text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'\n                                                    : 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)]'\n                                            }`}\n                                    >\n                                        <span className={`flex-1 min-w-0 text-sm ${isSelected ? 'font-semibold' : 'font-medium'}`}>\n                                            {opt.label}\n                                        </span>\n                                        {isSelected && (\n                                            <AppIcon name=\"check\" className=\"w-4 h-4 shrink-0 overflow-visible ml-2\" />\n                                        )}\n                                    </button>\n                                )\n                            })}\n                        </div>\n                    </div>,\n                    document.body\n                )}\n        </>\n    )\n}\n\n// ─── Variant 3: Ghost / Lightweight ─────────────────────────────\n// (背景透明，只有hover态有色块，适合用于工具栏、筛选器等紧凑小巧的场景)\n\nexport function SelectVariantGhost({\n    options,\n    value,\n    onChange,\n    placeholder = '请选择...',\n    disabled = false,\n    className = '',\n}: CustomSelectProps) {\n    const [isOpen, setIsOpen] = useState(false)\n    const [panelStyle, setPanelStyle] = useState<React.CSSProperties>({})\n    const triggerRef = useRef<HTMLButtonElement>(null)\n    const panelRef = useRef<HTMLDivElement>(null)\n\n    const selectedOption = options.find((opt) => opt.value === value)\n\n    const updatePosition = useCallback(() => {\n        if (!triggerRef.current) return\n        const rect = triggerRef.current.getBoundingClientRect()\n        const viewportHeight = window.innerHeight || document.documentElement.clientHeight\n        const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP\n        const spaceAbove = rect.top - VIEWPORT_EDGE_GAP\n\n        let openUpward = false\n        let currentMaxHeight = DEFAULT_MAX_HEIGHT\n\n        if (spaceBelow < 200 && spaceAbove > spaceBelow) {\n            openUpward = true\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceAbove)\n        } else {\n            currentMaxHeight = Math.min(DEFAULT_MAX_HEIGHT, spaceBelow)\n        }\n\n        setPanelStyle({\n            position: 'fixed',\n            left: rect.left,\n            width: Math.max(rect.width, 180), // Ghost往往自身较小，给下拉留点宽度\n            maxHeight: currentMaxHeight,\n            ...(openUpward\n                ? { bottom: viewportHeight - rect.top + 4 }\n                : { top: rect.bottom + 4 }),\n        })\n    }, [])\n\n    useEffect(() => {\n        function handleClickOutside(e: MouseEvent) {\n            const target = e.target as Node\n            if (triggerRef.current?.contains(target)) return\n            if (panelRef.current?.contains(target)) return\n            setIsOpen(false)\n        }\n        document.addEventListener('mousedown', handleClickOutside)\n        return () => document.removeEventListener('mousedown', handleClickOutside)\n    }, [])\n\n    useLayoutEffect(() => {\n        if (!isOpen) return\n        updatePosition()\n        window.addEventListener('resize', updatePosition)\n        window.addEventListener('scroll', updatePosition, true)\n        return () => {\n            window.removeEventListener('resize', updatePosition)\n            window.removeEventListener('scroll', updatePosition, true)\n        }\n    }, [isOpen, updatePosition])\n\n    return (\n        <>\n            <button\n                ref={triggerRef}\n                type=\"button\"\n                disabled={disabled}\n                onClick={() => setIsOpen(!isOpen)}\n                className={`inline-flex items-center justify-between gap-2 px-2.5 py-1.5 rounded-lg transition-colors text-left ${isOpen ? 'bg-[var(--glass-bg-hover)]' : 'hover:bg-[var(--glass-bg-surface-strong)]'\n                    } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${className}`}\n            >\n                <span className={`text-[13px] whitespace-nowrap overflow-hidden text-ellipsis ${selectedOption ? 'font-medium text-[var(--glass-text-secondary)]' : 'text-[var(--glass-text-tertiary)]'}`}>\n                    {selectedOption ? selectedOption.label : placeholder}\n                </span>\n                <AppIcon\n                    name=\"chevronDown\"\n                    className={`w-3.5 h-3.5 mt-0.5 text-[var(--glass-text-tertiary)] shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''\n                        }`}\n                />\n            </button>\n\n            {isOpen &&\n                createPortal(\n                    <div\n                        ref={panelRef}\n                        className=\"glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-lg border border-[var(--glass-stroke-subtle)] py-1\"\n                        style={panelStyle}\n                    >\n                        <div className=\"overflow-y-auto custom-scrollbar p-1 max-h-full space-y-0.5\">\n                            {options.map((opt) => {\n                                const isSelected = value === opt.value\n                                return (\n                                    <button\n                                        key={opt.value}\n                                        type=\"button\"\n                                        disabled={opt.disabled}\n                                        onClick={() => {\n                                            if (opt.disabled) return\n                                            onChange(opt.value)\n                                            setIsOpen(false)\n                                        }}\n                                        className={`flex items-center w-full px-2.5 py-1.5 rounded-md text-left transition-colors ${isSelected\n                                                ? 'bg-[var(--glass-bg-active)] text-[var(--glass-text-primary)]'\n                                                : opt.disabled\n                                                    ? 'text-[var(--glass-text-tertiary)] opacity-60 cursor-not-allowed'\n                                                    : 'hover:bg-[var(--glass-bg-hover)] text-[var(--glass-text-secondary)]'\n                                            }`}\n                                    >\n                                        <span className={`flex-1 min-w-0 text-[13px] ${isSelected ? 'font-medium' : ''}`}>\n                                            {opt.label}\n                                        </span>\n                                        {isSelected && (\n                                            <AppIcon name=\"check\" className=\"w-3.5 h-3.5 shrink-0 overflow-visible ml-2 text-[var(--glass-text-primary)]\" />\n                                        )}\n                                    </button>\n                                )\n                            })}\n                        </div>\n                    </div>,\n                    document.body\n                )}\n        </>\n    )\n}\n"
  },
  {
    "path": "src/components/voice/VoiceDesignDialogBase.tsx",
    "content": "'use client'\n\nimport { useRef, useState } from 'react'\nimport { createPortal } from 'react-dom'\nimport { useTranslations } from 'next-intl'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport { AppIcon } from '@/components/ui/icons'\nimport VoiceDesignGeneratorSection from './VoiceDesignGeneratorSection'\nimport {\n  DEFAULT_VOICE_SCHEME_COUNT,\n  generateVoiceDesignOptions,\n  type GeneratedVoice,\n  type VoiceDesignMutationPayload,\n  type VoiceDesignMutationResult,\n} from './voice-design-shared'\n\nexport type { VoiceDesignMutationPayload, VoiceDesignMutationResult } from './voice-design-shared'\n\ninterface VoiceDesignDialogBaseProps {\n  isOpen: boolean\n  speaker: string\n  hasExistingVoice?: boolean\n  onClose: () => void\n  onSave: (voiceId: string, audioBase64: string) => void\n  onDesignVoice: (payload: VoiceDesignMutationPayload) => Promise<VoiceDesignMutationResult>\n}\n\nexport default function VoiceDesignDialogBase({\n  isOpen,\n  speaker,\n  hasExistingVoice = false,\n  onClose,\n  onSave,\n  onDesignVoice,\n}: VoiceDesignDialogBaseProps) {\n  const t = useTranslations('common')\n  const tv = useTranslations('voice.voiceDesign')\n\n  const [voicePrompt, setVoicePrompt] = useState('')\n  const [previewText, setPreviewText] = useState(tv('defaultPreviewText'))\n  const [schemeCount, setSchemeCount] = useState(String(DEFAULT_VOICE_SCHEME_COUNT))\n  const [isDesignSubmitting, setIsDesignSubmitting] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [generatedVoices, setGeneratedVoices] = useState<GeneratedVoice[]>([])\n  const [selectedIndex, setSelectedIndex] = useState<number | null>(null)\n  const [showConfirmDialog, setShowConfirmDialog] = useState(false)\n  const [playingIndex, setPlayingIndex] = useState<number | null>(null)\n  const audioRef = useRef<HTMLAudioElement | null>(null)\n  const designSubmittingState = isDesignSubmitting\n    ? resolveTaskPresentationState({\n        phase: 'processing',\n        intent: 'generate',\n        resource: 'audio',\n        hasOutput: false,\n      })\n    : null\n\n  const handleGenerate = async () => {\n    if (!voicePrompt.trim()) {\n      setError(tv('pleaseSelectStyle'))\n      return\n    }\n\n    setIsDesignSubmitting(true)\n    setError(null)\n    setGeneratedVoices([])\n    setSelectedIndex(null)\n\n    try {\n      const voices = await generateVoiceDesignOptions({\n        count: schemeCount,\n        voicePrompt,\n        previewText,\n        defaultPreviewText: tv('defaultPreviewText'),\n        onDesignVoice,\n      })\n      setGeneratedVoices(voices)\n    } catch (err: unknown) {\n      const status = err instanceof Error ? (err as Error & { status?: number }).status : undefined\n      if (status === 402) {\n        const detail = err instanceof Error ? (err as Error & { detail?: string }).detail : undefined\n        alert(t('insufficientBalance') + '\\n\\n' + (detail || t('insufficientBalanceDetail')))\n        setError('INSUFFICIENT_BALANCE')\n        return\n      }\n\n      const message = err instanceof Error ? err.message : tv('generationError')\n      setError(message === 'VOICE_DESIGN_EMPTY_RESULT' ? tv('noVoiceGenerated') : (message || tv('generationError')))\n    } finally {\n      setIsDesignSubmitting(false)\n    }\n  }\n\n  const handlePlayVoice = (index: number) => {\n    if (playingIndex === index && audioRef.current) {\n      audioRef.current.pause()\n      setPlayingIndex(null)\n      return\n    }\n\n    if (audioRef.current) {\n      audioRef.current.pause()\n    }\n\n    setPlayingIndex(index)\n    const audio = new Audio(generatedVoices[index].audioUrl)\n    audioRef.current = audio\n    audio.onended = () => setPlayingIndex(null)\n    audio.onerror = () => setPlayingIndex(null)\n    void audio.play()\n  }\n\n  const handleConfirmSelection = () => {\n    if (selectedIndex !== null && generatedVoices[selectedIndex]) {\n      if (hasExistingVoice) {\n        setShowConfirmDialog(true)\n      } else {\n        doSave()\n      }\n    }\n  }\n\n  const doSave = () => {\n    if (selectedIndex !== null && generatedVoices[selectedIndex]) {\n      const voice = generatedVoices[selectedIndex]\n      onSave(voice.voiceId, voice.audioBase64)\n      handleClose()\n    }\n  }\n\n  const handleClose = () => {\n    setVoicePrompt('')\n    setPreviewText(tv('defaultPreviewText'))\n    setSchemeCount(String(DEFAULT_VOICE_SCHEME_COUNT))\n    setError(null)\n    setGeneratedVoices([])\n    setSelectedIndex(null)\n    setShowConfirmDialog(false)\n    setPlayingIndex(null)\n    if (audioRef.current) {\n      audioRef.current.pause()\n    }\n    onClose()\n  }\n\n  if (!isOpen) return null\n  if (typeof document === 'undefined') return null\n\n  const dialogContent = (\n    <>\n      <div className=\"fixed inset-0 z-[9999] glass-overlay\" onClick={handleClose} />\n      <div\n        className=\"fixed z-[10000] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 glass-surface-modal w-full max-w-xl overflow-hidden\"\n        onClick={(event) => event.stopPropagation()}\n      >\n        <div className=\"flex items-center justify-between px-5 py-3 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]\">\n          <div className=\"flex items-center gap-2\">\n            <AppIcon name=\"mic\" className=\"w-5 h-5 text-[var(--glass-tone-info-fg)]\" />\n            <h2 className=\"font-semibold text-[var(--glass-text-primary)]\">{tv('designVoiceFor', { speaker })}</h2>\n            {hasExistingVoice && (\n              <span className=\"glass-chip glass-chip-warning text-xs px-1.5 py-0.5\">{tv('hasExistingVoice')}</span>\n            )}\n          </div>\n          <button onClick={handleClose} className=\"glass-btn-base glass-btn-soft p-1 text-[var(--glass-text-tertiary)]\">\n            <AppIcon name=\"close\" className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        <div className=\"p-5 space-y-4\">\n          <VoiceDesignGeneratorSection\n            voicePrompt={voicePrompt}\n            onVoicePromptChange={setVoicePrompt}\n            previewText={previewText}\n            onPreviewTextChange={setPreviewText}\n            schemeCount={schemeCount}\n            onSchemeCountChange={setSchemeCount}\n            isSubmitting={isDesignSubmitting}\n            submittingState={designSubmittingState}\n            error={error}\n            generatedVoices={generatedVoices}\n            selectedIndex={selectedIndex}\n            onSelectIndex={setSelectedIndex}\n            playingIndex={playingIndex}\n            onPlayVoice={handlePlayVoice}\n            onGenerate={() => {\n              void handleGenerate()\n            }}\n            footer={(\n              <div className=\"flex gap-2 pt-2\">\n                <button\n                  onClick={() => {\n                    void handleGenerate()\n                  }}\n                  disabled={isDesignSubmitting}\n                  className=\"glass-btn-base glass-btn-secondary flex-1 py-2 rounded-lg text-sm\"\n                >\n                  {tv('regenerate')}\n                </button>\n                <button\n                  onClick={handleConfirmSelection}\n                  disabled={selectedIndex === null}\n                  className=\"glass-btn-base glass-btn-tone-success flex-1 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium\"\n                >\n                  {tv('confirmUse')}\n                </button>\n              </div>\n            )}\n          />\n        </div>\n      </div>\n\n      {showConfirmDialog && (\n        <div className=\"fixed inset-0 z-[10001] flex items-center justify-center p-4 glass-overlay\">\n          <div className=\"glass-surface-modal w-full max-w-sm p-5 text-center\">\n            <div className=\"w-12 h-12 mx-auto glass-chip glass-chip-warning rounded-full flex items-center justify-center mb-3 p-0\">\n              <AppIcon name=\"alert\" className=\"w-6 h-6 text-[var(--glass-tone-warning-fg)]\" />\n            </div>\n            <h3 className=\"font-semibold text-[var(--glass-text-primary)] mb-1\">{tv('confirmReplace')}</h3>\n            <p className=\"text-sm text-[var(--glass-text-secondary)] mb-4\">\n              {tv('replaceWarning')}\n              <span className=\"font-medium text-[var(--glass-text-primary)]\">「{speaker}」</span>\n            </p>\n            <div className=\"flex gap-2\">\n              <button\n                onClick={() => setShowConfirmDialog(false)}\n                className=\"glass-btn-base glass-btn-secondary flex-1 py-2 rounded-lg text-sm\"\n              >\n                {t('cancel')}\n              </button>\n              <button\n                onClick={doSave}\n                className=\"glass-btn-base glass-btn-danger flex-1 py-2 rounded-lg text-sm\"\n              >\n                {tv('confirmReplaceBtn')}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n    </>\n  )\n\n  return createPortal(dialogContent, document.body)\n}\n"
  },
  {
    "path": "src/components/voice/VoiceDesignGeneratorSection.tsx",
    "content": "'use client'\n\nimport type { ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\nimport TaskStatusInline from '@/components/task/TaskStatusInline'\nimport { AppIcon } from '@/components/ui/icons'\nimport type { TaskPresentationState } from '@/lib/task/presentation'\nimport {\n  MAX_VOICE_SCHEME_COUNT,\n  MIN_VOICE_SCHEME_COUNT,\n  normalizeVoiceSchemeCount,\n  type GeneratedVoice,\n} from './voice-design-shared'\n\nconst VOICE_PRESET_KEYS = [\n  'maleBroadcaster',\n  'gentleFemale',\n  'matureMale',\n  'livelyFemale',\n  'intellectualFemale',\n  'narrator',\n] as const\n\ntype VoicePresetKey = (typeof VOICE_PRESET_KEYS)[number]\n\ninterface VoiceDesignGeneratorSectionProps {\n  voicePrompt: string\n  onVoicePromptChange: (value: string) => void\n  previewText: string\n  onPreviewTextChange: (value: string) => void\n  schemeCount: string\n  onSchemeCountChange: (value: string) => void\n  isSubmitting: boolean\n  submittingState: TaskPresentationState | null\n  error: string | null\n  generatedVoices: GeneratedVoice[]\n  selectedIndex: number | null\n  onSelectIndex: (index: number) => void\n  playingIndex: number | null\n  onPlayVoice: (index: number) => void\n  onGenerate: () => void\n  footer?: ReactNode\n}\n\nexport default function VoiceDesignGeneratorSection({\n  voicePrompt,\n  onVoicePromptChange,\n  previewText,\n  onPreviewTextChange,\n  schemeCount,\n  onSchemeCountChange,\n  isSubmitting,\n  submittingState,\n  error,\n  generatedVoices,\n  selectedIndex,\n  onSelectIndex,\n  playingIndex,\n  onPlayVoice,\n  onGenerate,\n  footer = null,\n}: VoiceDesignGeneratorSectionProps) {\n  const tv = useTranslations('voice.voiceDesign')\n  const normalizedSchemeCount = normalizeVoiceSchemeCount(schemeCount)\n\n  return (\n    <>\n      <div>\n        <div className=\"text-sm text-[var(--glass-text-secondary)] mb-2\">{tv('selectStyle')}</div>\n        <div className=\"flex flex-wrap gap-1.5\">\n          {VOICE_PRESET_KEYS.map((presetKey) => {\n            const prompt = tv(`presetsPrompts.${presetKey}` as `presetsPrompts.${VoicePresetKey}`)\n            return (\n              <button\n                key={presetKey}\n                onClick={() => onVoicePromptChange(prompt)}\n                className={`glass-btn-base px-2.5 py-1 text-xs rounded-md border transition-all ${\n                  voicePrompt === prompt\n                    ? 'glass-btn-tone-info border-[var(--glass-stroke-focus)]'\n                    : 'glass-btn-soft text-[var(--glass-text-secondary)] border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'\n                }`}\n              >\n                {tv(`presets.${presetKey}` as `presets.${VoicePresetKey}`)}\n              </button>\n            )\n          })}\n        </div>\n      </div>\n\n      <div>\n        <div className=\"text-sm text-[var(--glass-text-secondary)] mb-1\">{tv('orCustomDescription')}</div>\n        <textarea\n          value={voicePrompt}\n          onChange={(event) => onVoicePromptChange(event.target.value)}\n          placeholder={tv('describePlaceholder')}\n          className=\"glass-textarea-base w-full px-3 py-2 text-sm resize-none\"\n          rows={2}\n        />\n      </div>\n\n      <details className=\"text-sm\">\n        <summary className=\"text-[var(--glass-text-secondary)] cursor-pointer hover:text-[var(--glass-text-primary)]\">\n          {tv('editPreviewText')}\n        </summary>\n        <input\n          type=\"text\"\n          value={previewText}\n          onChange={(event) => onPreviewTextChange(event.target.value)}\n          placeholder={tv('defaultPreviewText')}\n          className=\"glass-input-base w-full mt-2 px-3 py-2 text-sm\"\n        />\n      </details>\n\n      {generatedVoices.length === 0 && !isSubmitting && (\n        <div\n          role=\"button\"\n          tabIndex={!voicePrompt.trim() ? -1 : 0}\n          aria-disabled={!voicePrompt.trim()}\n          onClick={() => {\n            if (!voicePrompt.trim()) return\n            onGenerate()\n          }}\n          onKeyDown={(event) => {\n            if (!voicePrompt.trim()) return\n            if (event.key === 'Enter' || event.key === ' ') {\n              event.preventDefault()\n              onGenerate()\n            }\n          }}\n          className={`glass-btn-base glass-btn-primary w-full py-2.5 rounded-lg text-sm font-medium transition-opacity ${\n            !voicePrompt.trim() ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'\n          }`}\n        >\n          <div className=\"flex items-center justify-center gap-2\">\n            <span>{tv('generateSchemesPrefix')}</span>\n            <div\n              className=\"group relative inline-flex items-center rounded-md px-1.5 py-0.5 transition-colors hover:bg-white/12 focus-within:bg-white/14\"\n              onClick={(event) => event.stopPropagation()}\n              onKeyDown={(event) => event.stopPropagation()}\n            >\n              <select\n                value={String(normalizedSchemeCount)}\n                onChange={(event) => onSchemeCountChange(event.target.value)}\n                aria-label={tv('schemeCountAriaLabel')}\n                className=\"appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-white/96 outline-none cursor-pointer leading-none transition-colors group-hover:text-white focus:text-white\"\n              >\n                {Array.from({ length: MAX_VOICE_SCHEME_COUNT - MIN_VOICE_SCHEME_COUNT + 1 }, (_, index) => {\n                  const value = String(index + MIN_VOICE_SCHEME_COUNT)\n                  return (\n                    <option key={value} value={value} className=\"text-black\">\n                      {value}\n                    </option>\n                  )\n                })}\n              </select>\n              <div className=\"pointer-events-none absolute inset-y-0 right-1 flex items-center text-white/82 transition-colors group-hover:text-white group-focus-within:text-white\">\n                <AppIcon name=\"chevronDown\" className=\"h-3 w-3\" />\n              </div>\n            </div>\n            <span>{tv('generateSchemesSuffix')}</span>\n          </div>\n        </div>\n      )}\n\n      {isSubmitting && submittingState && (\n        <div className=\"py-6\">\n          <TaskStatusInline\n            state={submittingState}\n            className=\"justify-center text-[var(--glass-text-secondary)] [&>span]:text-[var(--glass-text-secondary)]\"\n          />\n        </div>\n      )}\n\n      {generatedVoices.length > 0 && (\n        <div className=\"space-y-3\">\n          <div className=\"text-sm text-[var(--glass-text-secondary)]\">{tv('selectScheme')}</div>\n          <div className=\"grid grid-cols-3 gap-2\">\n            {generatedVoices.map((voice, index) => (\n              <div\n                key={voice.voiceId}\n                onClick={() => onSelectIndex(index)}\n                className={`relative p-3 rounded-lg border-2 cursor-pointer transition-all text-center ${\n                  selectedIndex === index\n                    ? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'\n                    : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'\n                }`}\n              >\n                {selectedIndex === index && (\n                  <div className=\"absolute -top-1.5 -right-1.5 w-5 h-5 glass-chip glass-chip-info rounded-full flex items-center justify-center p-0\">\n                    <AppIcon name=\"checkSolid\" className=\"w-3 h-3 text-white\" />\n                  </div>\n                )}\n                <div className=\"text-sm font-medium text-[var(--glass-text-primary)] mb-2\">{tv('schemeN', { n: index + 1 })}</div>\n                <button\n                  onClick={(event) => {\n                    event.stopPropagation()\n                    onPlayVoice(index)\n                  }}\n                  className={`w-10 h-10 mx-auto rounded-full glass-btn-base flex items-center justify-center transition-all ${\n                    playingIndex === index\n                      ? 'glass-btn-tone-info animate-pulse'\n                      : 'glass-btn-secondary text-[var(--glass-text-secondary)]'\n                  }`}\n                >\n                  {playingIndex === index ? (\n                    <AppIcon name=\"pause\" className=\"w-4 h-4\" />\n                  ) : (\n                    <AppIcon name=\"play\" className=\"w-5 h-5\" />\n                  )}\n                </button>\n              </div>\n            ))}\n          </div>\n          {footer}\n        </div>\n      )}\n\n      {error && (\n        <div className=\"text-sm text-[var(--glass-tone-danger-fg)] bg-[var(--glass-tone-danger-bg)] px-3 py-2 rounded-lg\">\n          {error}\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/components/voice/voice-design-shared.ts",
    "content": "export const DEFAULT_VOICE_SCHEME_COUNT = 3\nexport const MIN_VOICE_SCHEME_COUNT = 1\nexport const MAX_VOICE_SCHEME_COUNT = 10\n\nexport type VoiceDesignMutationPayload = {\n  voicePrompt: string\n  previewText: string\n  preferredName: string\n  language: 'zh'\n}\n\nexport type VoiceDesignMutationResult = {\n  voiceId?: string\n  audioBase64?: string\n  detail?: string\n}\n\nexport type GeneratedVoice = {\n  voiceId: string\n  audioBase64: string\n  audioUrl: string\n}\n\nexport function normalizeVoiceSchemeCount(input: string | number | undefined): number {\n  const rawValue = typeof input === 'number' ? input : Number.parseInt(input ?? '', 10)\n  if (!Number.isFinite(rawValue)) return DEFAULT_VOICE_SCHEME_COUNT\n  return Math.min(MAX_VOICE_SCHEME_COUNT, Math.max(MIN_VOICE_SCHEME_COUNT, rawValue))\n}\n\nexport function createVoiceDesignPreferredName(index: number, now: () => number = Date.now): string {\n  return `voice_${now().toString(36)}_${index + 1}`.slice(0, 16)\n}\n\ninterface GenerateVoiceDesignOptionsParams {\n  count: string | number | undefined\n  voicePrompt: string\n  previewText: string\n  defaultPreviewText: string\n  language?: 'zh'\n  onDesignVoice: (payload: VoiceDesignMutationPayload) => Promise<VoiceDesignMutationResult>\n  createPreferredName?: (index: number) => string\n}\n\nexport async function generateVoiceDesignOptions({\n  count,\n  voicePrompt,\n  previewText,\n  defaultPreviewText,\n  language = 'zh',\n  onDesignVoice,\n  createPreferredName = (index) => createVoiceDesignPreferredName(index),\n}: GenerateVoiceDesignOptionsParams): Promise<GeneratedVoice[]> {\n  const trimmedPrompt = voicePrompt.trim()\n  if (!trimmedPrompt) throw new Error('VOICE_PROMPT_REQUIRED')\n\n  const resolvedPreviewText = previewText.trim() || defaultPreviewText\n  const resolvedCount = normalizeVoiceSchemeCount(count)\n  const voices: GeneratedVoice[] = []\n\n  for (let index = 0; index < resolvedCount; index += 1) {\n    const result = await onDesignVoice({\n      voicePrompt: trimmedPrompt,\n      previewText: resolvedPreviewText,\n      preferredName: createPreferredName(index),\n      language,\n    })\n\n    if (!result.audioBase64) continue\n    if (typeof result.voiceId !== 'string' || result.voiceId.length === 0) {\n      throw new Error('VOICE_DESIGN_INVALID_RESPONSE: missing voiceId')\n    }\n\n    voices.push({\n      voiceId: result.voiceId,\n      audioBase64: result.audioBase64,\n      audioUrl: `data:audio/wav;base64,${result.audioBase64}`,\n    })\n  }\n\n  if (voices.length === 0) throw new Error('VOICE_DESIGN_EMPTY_RESULT')\n\n  return voices\n}\n"
  },
  {
    "path": "src/contexts/ToastContext.tsx",
    "content": "'use client'\n\n/**\n * 🔔 全局 Toast 通知系统\n * \n * 职责：\n * 1. 提供全局 Toast 状态管理\n * 2. 支持成功/错误/警告/信息四种类型\n * 3. 支持自动翻译错误码\n * \n * 使用示例：\n * ```typescript\n * const { showToast, showError } = useToast()\n * \n * // 显示普通消息\n * showToast('操作成功', 'success')\n * \n * // 显示错误（自动翻译错误码）\n * showError('RATE_LIMIT', { retryAfter: 55 })\n * // 显示为: \"请求过于频繁，请 55 秒后重试\"\n * ```\n */\n\nimport { createContext, useContext, useState, useCallback, ReactNode } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\n\n// ============================================================\n// 类型定义\n// ============================================================\n\nexport interface Toast {\n    id: string\n    message: string\n    type: 'success' | 'error' | 'warning' | 'info'\n    duration: number\n}\n\ninterface ToastContextValue {\n    toasts: Toast[]\n    showToast: (message: string, type?: Toast['type'], duration?: number) => void\n    showError: (code: string, details?: Record<string, unknown>) => void\n    dismissToast: (id: string) => void\n}\n\n// ============================================================\n// Context\n// ============================================================\n\nconst ToastContext = createContext<ToastContextValue | null>(null)\n\n// ============================================================\n// Provider 组件\n// ============================================================\n\nexport function ToastProvider({ children }: { children: ReactNode }) {\n    const [toasts, setToasts] = useState<Toast[]>([])\n    const t = useTranslations('errors')\n\n    /**\n     * 显示 Toast 消息\n     */\n    const showToast = useCallback((\n        message: string,\n        type: Toast['type'] = 'info',\n        duration = 5000\n    ) => {\n        const id = Math.random().toString(36).slice(2, 9)\n\n        setToasts(prev => [...prev, { id, message, type, duration }])\n\n        // 自动消失\n        if (duration > 0) {\n            setTimeout(() => {\n                setToasts(prev => prev.filter(toast => toast.id !== id))\n            }, duration)\n        }\n    }, [])\n\n    /**\n     * 显示错误消息（自动翻译错误码）\n     */\n    const showError = useCallback((code: string, details?: Record<string, unknown>) => {\n        let message: string\n\n        // 尝试翻译错误码\n        try {\n            const translationValues = Object.fromEntries(\n                Object.entries(details || {}).map(([key, value]) => {\n                    if (typeof value === 'string' || typeof value === 'number') {\n                        return [key, value]\n                    }\n                    if (value instanceof Date) {\n                        return [key, value]\n                    }\n                    return [key, String(value)]\n                })\n            )\n            message = t(code, translationValues)\n        } catch {\n            message = code\n        }\n\n        showToast(message, 'error', 8000)\n    }, [t, showToast])\n\n    /**\n     * 关闭 Toast\n     */\n    const dismissToast = useCallback((id: string) => {\n        setToasts(prev => prev.filter(toast => toast.id !== id))\n    }, [])\n\n    return (\n        <ToastContext.Provider value={{ toasts, showToast, showError, dismissToast }}>\n            {children}\n            <ToastContainer toasts={toasts} onDismiss={dismissToast} />\n        </ToastContext.Provider>\n    )\n}\n\n// ============================================================\n// Hook\n// ============================================================\n\n/**\n * 获取 Toast 上下文\n * \n * @example\n * const { showToast, showError } = useToast()\n */\nexport function useToast(): ToastContextValue {\n    const context = useContext(ToastContext)\n    if (!context) {\n        throw new Error('useToast must be used within ToastProvider')\n    }\n    return context\n}\n\n// ============================================================\n// Toast 容器组件\n// ============================================================\n\nfunction ToastContainer({\n    toasts,\n    onDismiss\n}: {\n    toasts: Toast[]\n    onDismiss: (id: string) => void\n}) {\n    if (toasts.length === 0) return null\n\n    return (\n        <div className=\"fixed bottom-4 md:bottom-6 left-4 md:left-6 z-[9999] flex flex-col gap-2 pointer-events-none\">\n            {toasts.map(toast => (\n                <div\n                    key={toast.id}\n                    className={`\n                        pointer-events-auto\n                        flex items-center gap-3 \n                        px-4 py-3 \n                        rounded-xl\n                        animate-in slide-in-from-right-full duration-300\n                        max-w-md\n                        border\n                        ${getToastStyle(toast.type)}\n                    `}\n                >\n                    {/* 图标 */}\n                    <span className=\"w-5 h-5 flex items-center justify-center\">{getToastIcon(toast.type)}</span>\n\n                    {/* 消息 */}\n                    <span className=\"text-sm font-medium flex-1\">{toast.message}</span>\n\n                    {/* 关闭按钮 */}\n                    <button\n                        onClick={() => onDismiss(toast.id)}\n                        className=\"glass-btn-base glass-btn-ghost w-6 h-6 rounded-md p-0 opacity-70 hover:opacity-100 transition-opacity\"\n                    >\n                        <AppIcon name=\"close\" className=\"w-4 h-4\" />\n                    </button>\n                </div>\n            ))}\n        </div>\n    )\n}\n\n// ============================================================\n// 工具函数\n// ============================================================\n\nfunction getToastStyle(type: Toast['type']): string {\n    switch (type) {\n        case 'success':\n            return 'bg-[var(--glass-tone-success-bg)] text-[var(--glass-tone-success-fg)] border-[color:color-mix(in_srgb,var(--glass-tone-success-fg)_22%,transparent)]'\n        case 'error':\n            return 'bg-[var(--glass-tone-danger-bg)] text-[var(--glass-tone-danger-fg)] border-[color:color-mix(in_srgb,var(--glass-tone-danger-fg)_22%,transparent)]'\n        case 'warning':\n            return 'bg-[var(--glass-tone-warning-bg)] text-[var(--glass-tone-warning-fg)] border-[color:color-mix(in_srgb,var(--glass-tone-warning-fg)_22%,transparent)]'\n        case 'info':\n        default:\n            return 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] border-[color:color-mix(in_srgb,var(--glass-tone-info-fg)_22%,transparent)]'\n    }\n}\n\nfunction getToastIcon(type: Toast['type']) {\n    switch (type) {\n        case 'success':\n            return (\n                <AppIcon name=\"check\" className=\"w-4 h-4\" />\n            )\n        case 'error':\n            return (\n                <AppIcon name=\"close\" className=\"w-4 h-4\" />\n            )\n        case 'warning':\n            return (\n                <AppIcon name=\"alertOutline\" className=\"w-4 h-4\" />\n            )\n        case 'info':\n        default:\n            return (\n                <AppIcon name=\"infoCircle\" className=\"w-4 h-4\" />\n            )\n    }\n}\n"
  },
  {
    "path": "src/features/video-editor/components/Preview/RemotionPreview.tsx",
    "content": "'use client'\n\nimport React, { useMemo, useRef, useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Player, PlayerRef } from '@remotion/player'\nimport { AppIcon } from '@/components/ui/icons'\nimport { VideoComposition } from '../../remotion/VideoComposition'\nimport { VideoEditorProject } from '../../types/editor.types'\nimport { calculateTimelineDuration } from '../../utils/time-utils'\n\ninterface RemotionPreviewProps {\n    project: VideoEditorProject\n    currentFrame: number\n    playing: boolean\n    onFrameChange?: (frame: number) => void\n    onPlayingChange?: (playing: boolean) => void\n}\n\n/**\n * Remotion Player 预览封装\n * 支持双向同步：timelineState ↔ Player\n */\nexport const RemotionPreview: React.FC<RemotionPreviewProps> = ({\n    project,\n    currentFrame,\n    playing,\n    onFrameChange,\n    onPlayingChange\n}) => {\n    const t = useTranslations('video')\n    const playerRef = useRef<PlayerRef>(null)\n    const lastSyncedFrame = useRef<number>(0)\n\n    const totalDuration = useMemo(\n        () => calculateTimelineDuration(project.timeline),\n        [project.timeline]\n    )\n\n    // 当 currentFrame 从外部改变时，同步到 Player\n    useEffect(() => {\n        const player = playerRef.current\n        if (!player) return\n\n        // 避免循环更新：只有当帧差距大于 1 时才 seek\n        if (Math.abs(currentFrame - lastSyncedFrame.current) > 1) {\n            player.seekTo(currentFrame)\n            lastSyncedFrame.current = currentFrame\n        }\n    }, [currentFrame])\n\n    // 当 playing 状态改变时，控制 Player 播放/暂停\n    useEffect(() => {\n        const player = playerRef.current\n        if (!player) return\n\n        if (playing) {\n            player.play()\n        } else {\n            player.pause()\n        }\n    }, [playing])\n\n    // 监听 Player 的帧变化，同步到 timelineState\n    useEffect(() => {\n        const player = playerRef.current\n        if (!player) return\n\n        const handleFrameUpdate = () => {\n            const frame = player.getCurrentFrame()\n            lastSyncedFrame.current = frame\n            onFrameChange?.(frame)\n        }\n\n        // Remotion Player 触发 timeupdate 事件\n        player.addEventListener('frameupdate', handleFrameUpdate)\n\n        return () => {\n            player.removeEventListener('frameupdate', handleFrameUpdate)\n        }\n    }, [onFrameChange])\n\n    // 监听 Player 播放状态变化\n    useEffect(() => {\n        const player = playerRef.current\n        if (!player) return\n\n        const handlePlay = () => onPlayingChange?.(true)\n        const handlePause = () => onPlayingChange?.(false)\n        const handleEnded = () => onPlayingChange?.(false)\n\n        player.addEventListener('play', handlePlay)\n        player.addEventListener('pause', handlePause)\n        player.addEventListener('ended', handleEnded)\n\n        return () => {\n            player.removeEventListener('play', handlePlay)\n            player.removeEventListener('pause', handlePause)\n            player.removeEventListener('ended', handleEnded)\n        }\n    }, [onPlayingChange])\n\n    // 如果没有片段，显示占位\n    if (project.timeline.length === 0) {\n        return (\n            <div style={{\n                width: '100%',\n                aspectRatio: `${project.config.width} / ${project.config.height}`,\n                maxHeight: '100%',\n                background: 'var(--glass-bg-surface)',\n                border: '1px solid var(--glass-stroke-base)',\n                display: 'flex',\n                alignItems: 'center',\n                justifyContent: 'center',\n                borderRadius: '8px',\n                color: 'var(--glass-text-tertiary)'\n            }}>\n                <div style={{ textAlign: 'center' }}>\n                    <div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'center' }}>\n                        <AppIcon name=\"image\" className=\"w-12 h-12\" />\n                    </div>\n                    <span>{t('editor.preview.emptyStartEditing')}</span>\n                </div>\n            </div>\n        )\n    }\n\n    return (\n        <div style={{\n            width: '100%',\n            aspectRatio: `${project.config.width} / ${project.config.height}`,\n            maxHeight: '100%',\n            background: 'var(--glass-overlay-strong)',\n            borderRadius: '8px',\n            overflow: 'hidden'\n        }}>\n            <Player\n                ref={playerRef}\n                component={VideoComposition}\n                inputProps={{\n                    clips: project.timeline,\n                    bgmTrack: project.bgmTrack,\n                    config: project.config\n                }}\n                durationInFrames={Math.max(1, totalDuration)}\n                fps={project.config.fps}\n                compositionWidth={project.config.width}\n                compositionHeight={project.config.height}\n                style={{\n                    width: '100%',\n                    height: '100%'\n                }}\n                controls={false}  // 使用自定义控制\n                loop={false}\n                clickToPlay={false}  // 禁用点击播放，由外部控制\n            />\n        </div>\n    )\n}\n\nexport default RemotionPreview\n"
  },
  {
    "path": "src/features/video-editor/components/Preview/index.ts",
    "content": "export { RemotionPreview } from './RemotionPreview'\n"
  },
  {
    "path": "src/features/video-editor/components/Timeline/Timeline.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport { useTranslations } from 'next-intl'\nimport {\n    DndContext,\n    closestCenter,\n    KeyboardSensor,\n    PointerSensor,\n    useSensor,\n    useSensors,\n    DragEndEvent\n} from '@dnd-kit/core'\nimport {\n    SortableContext,\n    sortableKeyboardCoordinates,\n    horizontalListSortingStrategy,\n    useSortable\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\nimport { VideoClip, TimelineState, EditorConfig } from '../../types/editor.types'\nimport { framesToTime } from '../../utils/time-utils'\n\ninterface TimelineProps {\n    clips: VideoClip[]\n    timelineState: TimelineState\n    config: EditorConfig\n    onReorder: (fromIndex: number, toIndex: number) => void\n    onSelectClip: (clipId: string | null) => void\n    onZoomChange: (zoom: number) => void\n    onSeek?: (frame: number) => void\n}\n\n/**\n * 时间轴主组件\n * 使用 dnd-kit 实现拖拽排序\n */\nexport const Timeline: React.FC<TimelineProps> = ({\n    clips,\n    timelineState,\n    config,\n    onReorder,\n    onSelectClip,\n    onZoomChange,\n    onSeek\n}) => {\n    const t = useTranslations('video')\n    // 计算总时长和播放头位置\n    const totalDuration = clips.reduce((sum, clip) => sum + clip.durationInFrames, 0)\n    const playheadPosition = totalDuration > 0 ? (timelineState.currentFrame / totalDuration) * 100 : 0\n    const sensors = useSensors(\n        useSensor(PointerSensor, {\n            activationConstraint: {\n                distance: 5 // 5px 移动才开始拖拽\n            }\n        }),\n        useSensor(KeyboardSensor, {\n            coordinateGetter: sortableKeyboardCoordinates\n        })\n    )\n\n    const handleDragEnd = (event: DragEndEvent) => {\n        const { active, over } = event\n\n        if (over && active.id !== over.id) {\n            const oldIndex = clips.findIndex(c => c.id === active.id)\n            const newIndex = clips.findIndex(c => c.id === over.id)\n            onReorder(oldIndex, newIndex)\n        }\n    }\n\n    return (\n        <div className=\"timeline\" style={{\n            display: 'flex',\n            flexDirection: 'column',\n            gap: '8px',\n            padding: '12px',\n            background: 'var(--glass-bg-surface)',\n            borderRadius: '12px',\n            border: '1px solid var(--glass-stroke-base)',\n            height: '100%'\n        }}>\n            {/* 缩放控制 */}\n            <div style={{\n                display: 'flex',\n                alignItems: 'center',\n                gap: '8px'\n            }}>\n                <span style={{ fontSize: '12px', color: 'var(--glass-text-secondary)' }}>{t('editor.timeline.zoomLabel')}</span>\n                <input\n                    type=\"range\"\n                    min=\"0.5\"\n                    max=\"3\"\n                    step=\"0.1\"\n                    value={timelineState.zoom}\n                    onChange={(e) => onZoomChange(parseFloat(e.target.value))}\n                    style={{ width: '100px' }}\n                />\n                <span style={{ fontSize: '12px', color: 'var(--glass-text-tertiary)' }}>\n                    {Math.round(timelineState.zoom * 100)}%\n                </span>\n            </div>\n\n            {/* 进度条 + 播放头 */}\n            <div\n                style={{\n                    position: 'relative',\n                    height: '24px',\n                    background: 'var(--glass-bg-muted)',\n                    border: '1px solid var(--glass-stroke-base)',\n                    borderRadius: '4px',\n                    cursor: 'pointer',\n                    marginLeft: '70px'  // 与轨道标签对齐\n                }}\n                onClick={(e) => {\n                    if (!onSeek || totalDuration === 0) return\n                    const rect = e.currentTarget.getBoundingClientRect()\n                    const x = e.clientX - rect.left\n                    const percent = x / rect.width\n                    const frame = Math.round(percent * totalDuration)\n                    onSeek(Math.max(0, Math.min(totalDuration, frame)))\n                }}\n            >\n                {/* 已播放部分 */}\n                <div style={{\n                    position: 'absolute',\n                    left: 0,\n                    top: 0,\n                    height: '100%',\n                    width: `${playheadPosition}%`,\n                    background: 'linear-gradient(90deg, var(--glass-accent-from) 0%, var(--glass-accent-to) 100%)',\n                    borderRadius: '4px 0 0 4px',\n                    transition: timelineState.playing ? 'none' : 'width 0.1s'\n                }} />\n                {/* 播放头指示器 */}\n                <div style={{\n                    position: 'absolute',\n                    left: `${playheadPosition}%`,\n                    top: '-4px',\n                    bottom: '-4px',\n                    width: '3px',\n                    background: 'var(--glass-accent-to)',\n                    borderRadius: '2px',\n                    boxShadow: '0 0 8px var(--glass-accent-shadow-strong)',\n                    transform: 'translateX(-50%)',\n                    transition: timelineState.playing ? 'none' : 'left 0.1s'\n                }} />\n                {/* 时间标记 */}\n                <div style={{\n                    position: 'absolute',\n                    right: '8px',\n                    top: '50%',\n                    transform: 'translateY(-50%)',\n                    fontSize: '10px',\n                    color: 'var(--glass-text-tertiary)'\n                }}>\n                    {framesToTime(timelineState.currentFrame, config.fps)} / {framesToTime(totalDuration, config.fps)}\n                </div>\n            </div>\n\n            {/* 视频轨道 */}\n            <div style={{\n                display: 'flex',\n                alignItems: 'center',\n                height: '56px',\n                background: 'var(--glass-bg-surface-strong)',\n                border: '1px solid var(--glass-stroke-base)',\n                borderRadius: '6px',\n                padding: '0 12px'\n            }}>\n                <span style={{\n                    fontSize: '12px',\n                    color: 'var(--glass-text-secondary)',\n                    width: '70px',\n                    flexShrink: 0\n                }}>\n                    {t('editor.timeline.videoTrack')}\n                </span>\n\n                <DndContext\n                    sensors={sensors}\n                    collisionDetection={closestCenter}\n                    onDragEnd={handleDragEnd}\n                >\n                    <SortableContext\n                        items={clips.map(c => c.id)}\n                        strategy={horizontalListSortingStrategy}\n                    >\n                        <div style={{\n                            display: 'flex',\n                            gap: '4px',\n                            flex: 1,\n                            overflowX: 'auto',\n                            paddingRight: '12px'\n                        }}>\n                            {clips.map((clip, index) => (\n                                <SortableClip\n                                    key={clip.id}\n                                    clip={clip}\n                                    index={index}\n                                    isSelected={timelineState.selectedClipId === clip.id}\n                                    zoom={timelineState.zoom}\n                                    fps={config.fps}\n                                    onClick={() => onSelectClip(clip.id)}\n                                />\n                            ))}\n                            {clips.length === 0 && (\n                                <span style={{ fontSize: '12px', color: 'var(--glass-text-tertiary)' }}>\n                                    {t('editor.timeline.emptyHint')}\n                                </span>\n                            )}\n                        </div>\n                    </SortableContext>\n                </DndContext>\n            </div>\n\n            {/* 配音轨道 (显示附属音频) */}\n            <div style={{\n                display: 'flex',\n                alignItems: 'center',\n                height: '40px',\n                background: 'var(--glass-bg-surface-strong)',\n                border: '1px solid var(--glass-stroke-base)',\n                borderRadius: '6px',\n                padding: '0 12px'\n            }}>\n                <span style={{\n                    fontSize: '12px',\n                    color: 'var(--glass-text-secondary)',\n                    width: '70px',\n                    flexShrink: 0\n                }}>\n                    {t('editor.timeline.audioTrack')}\n                </span>\n                <div style={{ display: 'flex', gap: '4px', flex: 1 }}>\n                    {clips.filter(c => c.attachment?.audio).map((clip) => (\n                        <div\n                            key={`audio-${clip.id}`}\n                            style={{\n                                width: `${clip.durationInFrames * timelineState.zoom * 2}px`,\n                                height: '28px',\n                                background: 'var(--glass-tone-success-bg)',\n                                borderRadius: '4px',\n                                fontSize: '10px',\n                                color: 'var(--glass-tone-success-fg)',\n                                display: 'flex',\n                                alignItems: 'center',\n                                justifyContent: 'center',\n                                flexShrink: 0\n                            }}\n                        >\n                            {t('editor.timeline.audioBadge')}\n                        </div>\n                    ))}\n                </div>\n            </div>\n\n            {/* BGM 轨道 */}\n            <div style={{\n                display: 'flex',\n                alignItems: 'center',\n                height: '40px',\n                background: 'var(--glass-bg-surface-strong)',\n                border: '1px solid var(--glass-stroke-base)',\n                borderRadius: '6px',\n                padding: '0 12px'\n            }}>\n                <span style={{\n                    fontSize: '12px',\n                    color: 'var(--glass-text-secondary)',\n                    width: '70px',\n                    flexShrink: 0\n                }}>\n                    BGM\n                </span>\n            </div>\n        </div>\n    )\n}\n\n/**\n * 可拖拽的片段组件\n */\ninterface SortableClipProps {\n    clip: VideoClip\n    index: number\n    isSelected: boolean\n    zoom: number\n    fps: number\n    onClick: () => void\n}\n\nconst SortableClip: React.FC<SortableClipProps> = ({\n    clip,\n    index,\n    isSelected,\n    zoom,\n    fps,\n    onClick\n}) => {\n    const {\n        attributes,\n        listeners,\n        setNodeRef,\n        transform,\n        transition,\n        isDragging\n    } = useSortable({ id: clip.id })\n\n    const style: React.CSSProperties = {\n        transform: CSS.Transform.toString(transform),\n        transition,\n        width: `${clip.durationInFrames * zoom * 2}px`,\n        minWidth: '60px',\n        height: '40px',\n        background: isSelected\n            ? 'var(--glass-accent-from)'\n            : isDragging\n                ? 'var(--glass-bg-muted)'\n                : 'var(--glass-bg-surface)',\n        borderRadius: '4px',\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n        fontSize: '11px',\n        color: isSelected ? 'var(--glass-text-on-accent)' : 'var(--glass-text-primary)',\n        cursor: isDragging ? 'grabbing' : 'grab',\n        flexShrink: 0,\n        border: isSelected ? '2px solid var(--glass-stroke-focus)' : '1px solid var(--glass-stroke-base)',\n        opacity: isDragging ? 0.8 : 1,\n        zIndex: isDragging ? 100 : 1,\n        position: 'relative'\n    }\n\n    return (\n        <div\n            ref={setNodeRef}\n            style={style}\n            onClick={onClick}\n            {...attributes}\n            {...listeners}\n        >\n            <span style={{ fontWeight: 'bold' }}>{index + 1}</span>\n            <span style={{\n                position: 'absolute',\n                bottom: '2px',\n                fontSize: '9px',\n                color: isSelected ? 'rgba(255, 255, 255, 0.8)' : 'var(--glass-text-tertiary)'\n            }}>\n                {framesToTime(clip.durationInFrames, fps)}\n            </span>\n\n            {/* 转场指示器 */}\n            {clip.transition && clip.transition.type !== 'none' && (\n                <div style={{\n                    position: 'absolute',\n                    right: '-6px',\n                    top: '50%',\n                    transform: 'translateY(-50%)',\n                    width: '12px',\n                    height: '12px',\n                    background: 'var(--glass-tone-warning-fg)',\n                    borderRadius: '50%',\n                    fontSize: '8px',\n                    color: 'var(--glass-text-on-accent)',\n                    display: 'flex',\n                    alignItems: 'center',\n                    justifyContent: 'center',\n                    zIndex: 10\n                }}>\n                    T\n                </div>\n            )}\n        </div>\n    )\n}\n\nexport default Timeline\n"
  },
  {
    "path": "src/features/video-editor/components/Timeline/index.ts",
    "content": "export { Timeline } from './Timeline'\n"
  },
  {
    "path": "src/features/video-editor/components/TransitionPicker.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AppIcon } from '@/components/ui/icons'\nimport type { AppIconName } from '@/components/ui/icons'\n\nexport type TransitionType = 'none' | 'dissolve' | 'fade' | 'slide'\n\ninterface TransitionPickerProps {\n    value: TransitionType\n    duration: number\n    onChange: (type: TransitionType, duration: number) => void\n    disabled?: boolean\n}\n\nconst TRANSITION_OPTIONS: { type: TransitionType; labelKey: string; icon: AppIconName }[] = [\n    { type: 'none', labelKey: 'none', icon: 'minus' },\n    { type: 'dissolve', labelKey: 'dissolve', icon: 'diamond' },\n    { type: 'fade', labelKey: 'fade', icon: 'clock' },\n    { type: 'slide', labelKey: 'slide', icon: 'arrowRight' }\n]\n\nconst DURATION_OPTIONS = [\n    { value: 10, label: '0.3s' },\n    { value: 15, label: '0.5s' },\n    { value: 30, label: '1s' },\n    { value: 45, label: '1.5s' }\n]\n\nexport const TransitionPicker: React.FC<TransitionPickerProps> = ({\n    value,\n    duration,\n    onChange,\n    disabled = false\n}) => {\n    const t = useTranslations('video')\n    return (\n        <div className=\"transition-picker\" style={{\n            display: 'flex',\n            flexDirection: 'column',\n            gap: '8px',\n            padding: '12px',\n            background: 'var(--glass-bg-surface)',\n            border: '1px solid var(--glass-stroke-base)',\n            borderRadius: '8px'\n        }}>\n            <div style={{ fontSize: '12px', color: 'var(--glass-text-secondary)', marginBottom: '4px' }}>\n                {t('editor.transition.title')}\n            </div>\n\n            {/* 转场类型选择 */}\n            <div style={{\n                display: 'grid',\n                gridTemplateColumns: 'repeat(4, 1fr)',\n                gap: '4px'\n            }}>\n                {TRANSITION_OPTIONS.map(option => (\n                    <button\n                        key={option.type}\n                        onClick={() => onChange(option.type, duration)}\n                        disabled={disabled}\n                        style={{\n                            display: 'flex',\n                            flexDirection: 'column',\n                            alignItems: 'center',\n                            gap: '2px',\n                            padding: '8px 4px',\n                            background: value === option.type ? 'var(--glass-accent-from)' : 'var(--glass-bg-muted)',\n                            border: value === option.type ? '1px solid var(--glass-stroke-focus)' : '1px solid var(--glass-stroke-base)',\n                            borderRadius: '6px',\n                            cursor: disabled ? 'not-allowed' : 'pointer',\n                            opacity: disabled ? 0.5 : 1,\n                            transition: 'all 0.2s'\n                        }}\n                    >\n                        <AppIcon\n                            name={option.icon}\n                            size={16}\n                            color={value === option.type ? 'var(--glass-text-on-accent)' : 'var(--glass-text-primary)'}\n                        />\n                        <span style={{ fontSize: '10px', color: value === option.type ? 'var(--glass-text-on-accent)' : 'var(--glass-text-primary)' }}>{t(`editor.transition.options.${option.labelKey}`)}</span>\n                    </button>\n                ))}\n            </div>\n\n            {/* 持续时间选择 */}\n            {value !== 'none' && (\n                <div style={{ marginTop: '8px' }}>\n                    <div style={{ fontSize: '11px', color: 'var(--glass-text-tertiary)', marginBottom: '4px' }}>\n                        {t('editor.transition.duration')}\n                    </div>\n                    <div style={{\n                        display: 'flex',\n                        gap: '4px'\n                    }}>\n                        {DURATION_OPTIONS.map(option => (\n                            <button\n                                key={option.value}\n                                onClick={() => onChange(value, option.value)}\n                                disabled={disabled}\n                                style={{\n                                    flex: 1,\n                                    padding: '6px 8px',\n                                    background: duration === option.value ? 'var(--glass-accent-from)' : 'var(--glass-bg-muted)',\n                                    border: duration === option.value ? '1px solid var(--glass-stroke-focus)' : '1px solid var(--glass-stroke-base)',\n                                    borderRadius: '4px',\n                                    fontSize: '11px',\n                                    color: duration === option.value ? 'var(--glass-text-on-accent)' : 'var(--glass-text-primary)',\n                                    cursor: disabled ? 'not-allowed' : 'pointer',\n                                    opacity: disabled ? 0.5 : 1\n                                }}\n                            >\n                                {option.label}\n                            </button>\n                        ))}\n                    </div>\n                </div>\n            )}\n        </div>\n    )\n}\n\nexport default TransitionPicker\n"
  },
  {
    "path": "src/features/video-editor/components/VideoEditorStage.tsx",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useTranslations } from 'next-intl'\n\nimport React from 'react'\nimport { AppIcon } from '@/components/ui/icons'\nimport { useEditorState } from '../hooks/useEditorState'\nimport { useEditorActions } from '../hooks/useEditorActions'\nimport { VideoEditorProject } from '../types/editor.types'\nimport { calculateTimelineDuration, framesToTime } from '../utils/time-utils'\nimport { RemotionPreview } from './Preview'\nimport { Timeline } from './Timeline'\nimport { TransitionPicker, TransitionType } from './TransitionPicker'\n\ninterface VideoEditorStageProps {\n    projectId: string\n    episodeId: string\n    initialProject?: VideoEditorProject\n    onBack?: () => void\n}\n\n/**\n * 视频编辑器主页面\n * \n * 布局:\n * ┌──────────────────────────────────────────────────────────┐\n * │ Toolbar (返回 | 保存 | 导出)                              │\n * ├──────────────┬───────────────────────────────────────────┤\n * │  素材库       │       Preview (Remotion Player)           │\n * │              │                                           │\n * │              ├───────────────────────────────────────────┤\n * │              │       Properties Panel                    │\n * ├──────────────┴───────────────────────────────────────────┤\n * │                      Timeline                            │\n * └──────────────────────────────────────────────────────────┘\n */\nexport function VideoEditorStage({\n    projectId,\n    episodeId,\n    initialProject,\n    onBack\n}: VideoEditorStageProps) {\n    const t = useTranslations('video')\n    const {\n        project,\n        timelineState,\n        isDirty,\n        removeClip,\n        updateClip,\n        reorderClips,\n        play,\n        pause,\n        seek,\n        selectClip,\n        setZoom,\n        markSaved\n    } = useEditorState({ episodeId, initialProject })\n\n    const { saveProject, startRender } = useEditorActions({ projectId, episodeId })\n\n    const totalDuration = calculateTimelineDuration(project.timeline)\n    const totalTime = framesToTime(totalDuration, project.config.fps)\n    const currentTime = framesToTime(timelineState.currentFrame, project.config.fps)\n\n    const handleSave = async () => {\n        try {\n            await saveProject(project)\n            markSaved()\n            alert(t('editor.alert.saveSuccess'))\n        } catch (error) {\n            _ulogError('Save failed:', error)\n            alert(t('editor.alert.saveFailed'))\n        }\n    }\n\n    const handleExport = async () => {\n        try {\n            await startRender(project.id)\n            alert(t('editor.alert.exportStarted'))\n        } catch (error) {\n            _ulogError('Export failed:', error)\n            alert(t('editor.alert.exportFailed'))\n        }\n    }\n\n    const selectedClip = project.timeline.find(c => c.id === timelineState.selectedClipId)\n\n    return (\n        <div className=\"video-editor-stage\" style={{\n            display: 'flex',\n            flexDirection: 'column',\n            height: '100vh',\n            background: 'var(--glass-bg-canvas)',\n            color: 'var(--glass-text-primary)'\n        }}>\n            {/* Toolbar */}\n            <div style={{\n                display: 'flex',\n                alignItems: 'center',\n                gap: '12px',\n                padding: '12px 16px',\n                borderBottom: '1px solid var(--glass-stroke-base)',\n                background: 'var(--glass-bg-surface)'\n            }}>\n                <button\n                    onClick={onBack}\n                    className=\"glass-btn-base glass-btn-secondary px-4 py-2\"\n                >\n                    {t('editor.toolbar.back')}\n                </button>\n\n                <div style={{ flex: 1 }} />\n\n                <span style={{ color: 'var(--glass-text-secondary)', fontSize: '14px' }}>\n                    {currentTime} / {totalTime}\n                </span>\n\n                <button\n                    onClick={handleSave}\n                    className={`glass-btn-base px-4 py-2 ${isDirty ? 'glass-btn-primary text-white' : 'glass-btn-secondary'}`}\n                >\n                    {isDirty ? t('editor.toolbar.saveDirty') : t('editor.toolbar.saved')}\n                </button>\n\n                <button\n                    onClick={handleExport}\n                    className=\"glass-btn-base glass-btn-tone-success px-4 py-2\"\n                >\n                    {t('editor.toolbar.export')}\n                </button>\n            </div>\n\n            {/* Main Content */}\n            <div style={{\n                display: 'flex',\n                flex: 1,\n                overflow: 'hidden'\n            }}>\n                {/* Left Panel - Media Library */}\n                <div style={{\n                    width: '200px',\n                    borderRight: '1px solid var(--glass-stroke-base)',\n                    padding: '12px',\n                    background: 'var(--glass-bg-surface-strong)'\n                }}>\n                    <h3 style={{ margin: '0 0 12px 0', fontSize: '14px', color: 'var(--glass-text-secondary)' }}>\n                        {t('editor.left.title')}\n                    </h3>\n                    <p style={{ fontSize: '12px', color: 'var(--glass-text-tertiary)' }}>\n                        {t('editor.left.description')}\n                    </p>\n                </div>\n\n                {/* Center - Preview + Properties */}\n                <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>\n                    {/* Preview */}\n                    <div style={{\n                        flex: 1,\n                        display: 'flex',\n                        alignItems: 'center',\n                        justifyContent: 'center',\n                        background: 'var(--glass-bg-muted)',\n                        padding: '20px'\n                    }}>\n                        <RemotionPreview\n                            project={project}\n                            currentFrame={timelineState.currentFrame}\n                            playing={timelineState.playing}\n                            onFrameChange={seek}\n                            onPlayingChange={(playing) => playing ? play() : pause()}\n                        />\n                    </div>\n\n                    {/* Playback Controls */}\n                    <div style={{\n                        display: 'flex',\n                        alignItems: 'center',\n                        justifyContent: 'center',\n                        gap: '16px',\n                        padding: '12px',\n                        background: 'var(--glass-bg-surface-strong)',\n                        borderTop: '1px solid var(--glass-stroke-base)'\n                    }}>\n                        <button\n                            onClick={() => seek(0)}\n                            className=\"glass-btn-base glass-btn-ghost px-3 py-1.5\"\n                        >\n                            <AppIcon name=\"chevronLeft\" className=\"w-4 h-4\" />\n                        </button>\n                        <button\n                            onClick={() => timelineState.playing ? pause() : play()}\n                            style={{\n                                background: 'var(--glass-accent-from)',\n                                border: 'none',\n                                color: 'var(--glass-text-on-accent)',\n                                cursor: 'pointer',\n                                width: '40px',\n                                height: '40px',\n                                borderRadius: '50%',\n                                fontSize: '18px'\n                            }}\n                        >\n                            {timelineState.playing\n                                ? <AppIcon name=\"pause\" className=\"w-4 h-4\" />\n                                : <AppIcon name=\"play\" className=\"w-4 h-4\" />}\n                        </button>\n                        <button\n                            onClick={() => seek(totalDuration)}\n                            className=\"glass-btn-base glass-btn-ghost px-3 py-1.5\"\n                        >\n                            <AppIcon name=\"chevronRight\" className=\"w-4 h-4\" />\n                        </button>\n                    </div>\n                </div>\n\n                {/* Right Panel - Properties */}\n                <div style={{\n                    width: '280px',\n                    borderLeft: '1px solid var(--glass-stroke-base)',\n                    padding: '12px',\n                    background: 'var(--glass-bg-surface-strong)',\n                    overflowY: 'auto'\n                }}>\n                    <h3 style={{ margin: '0 0 12px 0', fontSize: '14px', color: 'var(--glass-text-secondary)' }}>\n                        {t('editor.right.title')}\n                    </h3>\n                    {selectedClip ? (\n                        <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>\n                            {/* 基础信息 */}\n                            <div style={{ fontSize: '12px' }}>\n                                <p style={{ margin: '0 0 8px 0' }}>\n                                    <span style={{ color: 'var(--glass-text-secondary)' }}>{t('editor.right.clipLabel')}</span> {selectedClip.metadata?.description || t('editor.right.clipFallback', { index: project.timeline.findIndex(c => c.id === selectedClip.id) + 1 })}\n                                </p>\n                                <p style={{ margin: '0 0 8px 0' }}>\n                                    <span style={{ color: 'var(--glass-text-secondary)' }}>{t('editor.right.durationLabel')}</span> {framesToTime(selectedClip.durationInFrames, project.config.fps)}\n                                </p>\n                            </div>\n\n                            {/* 转场设置 */}\n                            <div>\n                                <h4 style={{ margin: '0 0 8px 0', fontSize: '13px', color: 'var(--glass-text-secondary)' }}>\n                                    {t('editor.right.transitionLabel')}\n                                </h4>\n                                <TransitionPicker\n                                    value={(selectedClip.transition?.type as TransitionType) || 'none'}\n                                    duration={selectedClip.transition?.durationInFrames || 15}\n                                    onChange={(type, duration) => {\n                                        updateClip(selectedClip.id, {\n                                            transition: type === 'none' ? undefined : { type, durationInFrames: duration }\n                                        })\n                                    }}\n                                />\n                            </div>\n\n                            {/* 删除按钮 */}\n                            <button\n                                onClick={() => {\n                                    if (confirm(t('editor.right.deleteConfirm'))) {\n                                        removeClip(selectedClip.id)\n                                        selectClip(null)\n                                    }\n                                }}\n                                className=\"glass-btn-base glass-btn-tone-danger mt-2 px-3 py-2 text-xs\"\n                            >\n                                {t('editor.right.deleteClip')}\n                            </button>\n                        </div>\n                    ) : (\n                        <p style={{ fontSize: '12px', color: 'var(--glass-text-tertiary)' }}>\n                            {t('editor.right.selectClipHint')}\n                        </p>\n                    )}\n                </div>\n            </div>\n\n            {/* Timeline */}\n            <div style={{\n                height: '220px',\n                borderTop: '1px solid var(--glass-stroke-base)'\n            }}>\n                <Timeline\n                    clips={project.timeline}\n                    timelineState={timelineState}\n                    config={project.config}\n                    onReorder={reorderClips}\n                    onSelectClip={selectClip}\n                    onZoomChange={setZoom}\n                    onSeek={seek}\n                />\n            </div>\n        </div>\n    )\n}\n\nexport default VideoEditorStage\n"
  },
  {
    "path": "src/features/video-editor/hooks/useEditorActions.ts",
    "content": "'use client'\n\nimport { useCallback } from 'react'\nimport { VideoClip, VideoEditorProject } from '../types/editor.types'\nimport { apiFetch } from '@/lib/api-fetch'\n\ninterface UseEditorActionsProps {\n    projectId: string\n    episodeId: string\n}\n\n/**\n * 面板数据类型（灵活接受各种格式）\n */\ninterface PanelData {\n    id?: string\n    panelIndex?: number\n    storyboardId: string\n    videoUrl?: string\n    description?: string\n    duration?: number\n}\n\n/**\n * 从已生成的视频面板创建编辑器项目\n */\nexport function createProjectFromPanels(\n    episodeId: string,\n    panels: PanelData[],\n    voiceLines?: Array<{ id: string; speaker: string; content: string; audioUrl?: string | null }>\n): VideoEditorProject {\n    // 过滤出有视频的面板\n    const videoPanels = panels.filter(p => p.videoUrl)\n\n    // 创建视频片段\n    const timeline: VideoClip[] = videoPanels.map((panel, index) => {\n        // 查找匹配的配音（简单匹配：按索引）\n        const matchedVoice = voiceLines?.[index]\n\n        return {\n            id: `clip_${panel.id || panel.storyboardId}_${panel.panelIndex ?? index}`,\n            src: panel.videoUrl!,\n            durationInFrames: Math.round((panel.duration || 3) * 30), // 默认 3 秒，30fps\n            attachment: {\n                audio: matchedVoice?.audioUrl ? {\n                    src: matchedVoice.audioUrl,\n                    volume: 1,\n                    voiceLineId: matchedVoice.id\n                } : undefined,\n                subtitle: matchedVoice ? {\n                    text: matchedVoice.content,\n                    style: 'default' as const\n                } : undefined\n            },\n            transition: index < videoPanels.length - 1 ? {\n                type: 'dissolve' as const,\n                durationInFrames: 15 // 0.5s @ 30fps\n            } : undefined,\n            metadata: {\n                panelId: panel.id || `${panel.storyboardId}-${panel.panelIndex ?? index}`,\n                storyboardId: panel.storyboardId,\n                description: panel.description || undefined\n            }\n        }\n    })\n\n    return {\n        id: `editor_${episodeId}_${Date.now()}`,\n        episodeId,\n        schemaVersion: '1.0',\n        config: {\n            fps: 30,\n            width: 1920,\n            height: 1080\n        },\n        timeline,\n        bgmTrack: []\n    }\n}\n\nexport function useEditorActions({ projectId, episodeId }: UseEditorActionsProps) {\n    /**\n     * 保存项目到服务器\n     */\n    const saveProject = useCallback(async (project: VideoEditorProject) => {\n        const response = await apiFetch(`/api/novel-promotion/${projectId}/editor`, {\n            method: 'PUT',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify({ projectData: project })\n        })\n\n        if (!response.ok) {\n            throw new Error('Failed to save project')\n        }\n\n        return response.json()\n    }, [projectId])\n\n    /**\n     * 加载项目\n     */\n    const loadProject = useCallback(async (): Promise<VideoEditorProject | null> => {\n        const response = await apiFetch(`/api/novel-promotion/${projectId}/editor?episodeId=${episodeId}`)\n\n        if (!response.ok) {\n            if (response.status === 404) return null\n            throw new Error('Failed to load project')\n        }\n\n        const data = await response.json()\n        return data.projectData\n    }, [projectId, episodeId])\n\n    /**\n     * 发起渲染导出\n     */\n    const startRender = useCallback(async (editorProjectId: string) => {\n        const response = await apiFetch(`/api/novel-promotion/${projectId}/editor/render`, {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify({\n                editorProjectId,\n                format: 'mp4',\n                quality: 'high'\n            })\n        })\n\n        if (!response.ok) {\n            throw new Error('Failed to start render')\n        }\n\n        return response.json()\n    }, [projectId])\n\n    /**\n     * 获取渲染状态\n     */\n    const getRenderStatus = useCallback(async (editorProjectId: string) => {\n        const response = await apiFetch(\n            `/api/novel-promotion/${projectId}/editor/render?id=${editorProjectId}`\n        )\n\n        if (!response.ok) {\n            throw new Error('Failed to get render status')\n        }\n\n        return response.json()\n    }, [projectId])\n\n    return {\n        saveProject,\n        loadProject,\n        startRender,\n        getRenderStatus\n    }\n}\n"
  },
  {
    "path": "src/features/video-editor/hooks/useEditorState.ts",
    "content": "'use client'\n\nimport { useState, useCallback } from 'react'\nimport {\n    VideoEditorProject,\n    VideoClip,\n    BgmClip,\n    TimelineState,\n    createDefaultProject,\n    generateClipId\n} from '../index'\n\ninterface UseEditorStateProps {\n    episodeId: string\n    initialProject?: VideoEditorProject\n}\n\nexport function useEditorState({ episodeId, initialProject }: UseEditorStateProps) {\n    // 项目数据\n    const [project, setProject] = useState<VideoEditorProject>(\n        initialProject || createDefaultProject(episodeId)\n    )\n\n    // 时间轴 UI 状态\n    const [timelineState, setTimelineState] = useState<TimelineState>({\n        currentFrame: 0,\n        playing: false,\n        selectedClipId: null,\n        zoom: 1\n    })\n\n    // 是否有未保存的更改\n    const [isDirty, setIsDirty] = useState(false)\n\n    // ========================================\n    // 时间轴片段操作\n    // ========================================\n\n    const addClip = useCallback((clip: Omit<VideoClip, 'id'>) => {\n        const newClip: VideoClip = {\n            ...clip,\n            id: generateClipId()\n        }\n        setProject(prev => ({\n            ...prev,\n            timeline: [...prev.timeline, newClip]\n        }))\n        setIsDirty(true)\n        return newClip.id\n    }, [])\n\n    const removeClip = useCallback((clipId: string) => {\n        setProject(prev => ({\n            ...prev,\n            timeline: prev.timeline.filter(c => c.id !== clipId)\n        }))\n        setIsDirty(true)\n    }, [])\n\n    const updateClip = useCallback((clipId: string, updates: Partial<VideoClip>) => {\n        setProject(prev => ({\n            ...prev,\n            timeline: prev.timeline.map(c =>\n                c.id === clipId ? { ...c, ...updates } : c\n            )\n        }))\n        setIsDirty(true)\n    }, [])\n\n    const reorderClips = useCallback((fromIndex: number, toIndex: number) => {\n        setProject(prev => {\n            const newTimeline = [...prev.timeline]\n            const [removed] = newTimeline.splice(fromIndex, 1)\n            newTimeline.splice(toIndex, 0, removed)\n            return { ...prev, timeline: newTimeline }\n        })\n        setIsDirty(true)\n    }, [])\n\n    // ========================================\n    // BGM 操作\n    // ========================================\n\n    const addBgm = useCallback((bgm: Omit<BgmClip, 'id'>) => {\n        const newBgm: BgmClip = {\n            ...bgm,\n            id: `bgm_${Date.now()}`\n        }\n        setProject(prev => ({\n            ...prev,\n            bgmTrack: [...prev.bgmTrack, newBgm]\n        }))\n        setIsDirty(true)\n    }, [])\n\n    const removeBgm = useCallback((bgmId: string) => {\n        setProject(prev => ({\n            ...prev,\n            bgmTrack: prev.bgmTrack.filter(b => b.id !== bgmId)\n        }))\n        setIsDirty(true)\n    }, [])\n\n    // ========================================\n    // 播放控制\n    // ========================================\n\n    const play = useCallback(() => {\n        setTimelineState(prev => ({ ...prev, playing: true }))\n    }, [])\n\n    const pause = useCallback(() => {\n        setTimelineState(prev => ({ ...prev, playing: false }))\n    }, [])\n\n    const seek = useCallback((frame: number) => {\n        setTimelineState(prev => ({ ...prev, currentFrame: frame }))\n    }, [])\n\n    const selectClip = useCallback((clipId: string | null) => {\n        setTimelineState(prev => ({ ...prev, selectedClipId: clipId }))\n    }, [])\n\n    const setZoom = useCallback((zoom: number) => {\n        setTimelineState(prev => ({ ...prev, zoom: Math.max(0.1, Math.min(5, zoom)) }))\n    }, [])\n\n    // ========================================\n    // 项目操作\n    // ========================================\n\n    const resetProject = useCallback(() => {\n        setProject(createDefaultProject(episodeId))\n        setIsDirty(false)\n    }, [episodeId])\n\n    const loadProject = useCallback((data: VideoEditorProject) => {\n        setProject(data)\n        setIsDirty(false)\n    }, [])\n\n    const markSaved = useCallback(() => {\n        setIsDirty(false)\n    }, [])\n\n    return {\n        // State\n        project,\n        timelineState,\n        isDirty,\n\n        // Clip actions\n        addClip,\n        removeClip,\n        updateClip,\n        reorderClips,\n\n        // BGM actions\n        addBgm,\n        removeBgm,\n\n        // Playback\n        play,\n        pause,\n        seek,\n        selectClip,\n        setZoom,\n\n        // Project\n        resetProject,\n        loadProject,\n        markSaved,\n        setProject\n    }\n}\n"
  },
  {
    "path": "src/features/video-editor/index.ts",
    "content": "// ========================================\n// Video Editor Module - Public API\n// ========================================\n\n// Types\nexport type {\n    VideoEditorProject,\n    VideoClip,\n    BgmClip,\n    ClipAttachment,\n    ClipTransition,\n    ClipMetadata,\n    EditorConfig,\n    TimelineState,\n    ComputedClip,\n    SaveEditorProjectRequest,\n    RenderRequest,\n    RenderStatus\n} from './types/editor.types'\n\n// Utils\nexport {\n    calculateTimelineDuration,\n    computeClipPositions,\n    framesToTime,\n    timeToFrames,\n    generateClipId,\n    createDefaultProject\n} from './utils/time-utils'\n\nexport {\n    migrateProjectData,\n    validateProjectData\n} from './utils/migration'\n\n// Components\nexport { VideoEditorStage } from './components/VideoEditorStage'\nexport { TransitionPicker } from './components/TransitionPicker'\n\n// Hooks\nexport { useEditorState } from './hooks/useEditorState'\nexport { useEditorActions, createProjectFromPanels } from './hooks/useEditorActions'\n"
  },
  {
    "path": "src/features/video-editor/remotion/VideoComposition.tsx",
    "content": "import React from 'react'\nimport { AbsoluteFill, Sequence, Video, Audio, useCurrentFrame, interpolate } from 'remotion'\nimport { VideoClip, BgmClip, EditorConfig } from '../types/editor.types'\nimport { computeClipPositions } from '../utils/time-utils'\n\ninterface VideoCompositionProps {\n    clips: VideoClip[]\n    bgmTrack: BgmClip[]\n    config: EditorConfig\n}\n\n/**\n * Remotion 主合成组件\n * 使用 Sequence 实现磁性时间轴布局，支持转场效果\n */\nexport const VideoComposition: React.FC<VideoCompositionProps> = ({\n    clips,\n    bgmTrack,\n    config\n}) => {\n    const computedClips = computeClipPositions(clips)\n\n    return (\n        <AbsoluteFill style={{ backgroundColor: 'black' }}>\n            {/* 视频轨道 - 带转场效果 */}\n            {computedClips.map((clip, index) => {\n                const transitionDuration = clip.transition?.durationInFrames || 0\n\n                return (\n                    <Sequence\n                        key={clip.id}\n                        from={clip.startFrame}\n                        durationInFrames={clip.durationInFrames}\n                        name={`Clip ${index + 1}`}\n                    >\n                        <ClipRenderer\n                            clip={clip}\n                            config={config}\n                            transitionType={clip.transition?.type}\n                            transitionDuration={transitionDuration}\n                            isLastClip={index === computedClips.length - 1}\n                        />\n                    </Sequence>\n                )\n            })}\n\n            {/* BGM 轨道 */}\n            {bgmTrack.map((bgm) => (\n                <Sequence\n                    key={bgm.id}\n                    from={bgm.startFrame}\n                    durationInFrames={bgm.durationInFrames}\n                    name={`BGM: ${bgm.id}`}\n                >\n                    <BgmRenderer bgm={bgm} />\n                </Sequence>\n            ))}\n        </AbsoluteFill>\n    )\n}\n\n/**\n * BGM 渲染器 - 支持淡入淡出\n */\ninterface BgmRendererProps {\n    bgm: BgmClip\n}\n\nconst BgmRenderer: React.FC<BgmRendererProps> = ({ bgm }) => {\n    const frame = useCurrentFrame()\n    const fadeIn = bgm.fadeIn || 0\n    const fadeOut = bgm.fadeOut || 0\n\n    let volume = bgm.volume\n\n    // 淡入\n    if (fadeIn > 0 && frame < fadeIn) {\n        volume *= interpolate(frame, [0, fadeIn], [0, 1], { extrapolateRight: 'clamp' })\n    }\n\n    // 淡出\n    if (fadeOut > 0 && frame > bgm.durationInFrames - fadeOut) {\n        volume *= interpolate(\n            frame,\n            [bgm.durationInFrames - fadeOut, bgm.durationInFrames],\n            [1, 0],\n            { extrapolateLeft: 'clamp' }\n        )\n    }\n\n    return <Audio src={bgm.src} volume={volume} />\n}\n\n/**\n * 单个片段渲染器 - 支持转场效果\n */\ninterface ClipRendererProps {\n    clip: VideoClip & { startFrame: number; endFrame: number }\n    config: EditorConfig\n    transitionType?: 'none' | 'dissolve' | 'fade' | 'slide'\n    transitionDuration: number\n    isLastClip: boolean\n}\n\nconst ClipRenderer: React.FC<ClipRendererProps> = ({\n    clip,\n    config,\n    transitionType = 'none',\n    transitionDuration,\n    isLastClip\n}) => {\n    void config\n    const frame = useCurrentFrame()\n    const clipDuration = clip.durationInFrames\n\n    // 计算转场效果\n    let opacity = 1\n    let transform = 'none'\n\n    if (transitionType !== 'none' && transitionDuration > 0) {\n        // 出场转场效果 (在片段末尾)\n        if (!isLastClip && frame > clipDuration - transitionDuration) {\n            const exitProgress = interpolate(\n                frame,\n                [clipDuration - transitionDuration, clipDuration],\n                [0, 1],\n                { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }\n            )\n\n            switch (transitionType) {\n                case 'dissolve':\n                case 'fade':\n                    opacity = 1 - exitProgress\n                    break\n                case 'slide':\n                    transform = `translateX(${-exitProgress * 100}%)`\n                    break\n            }\n        }\n\n        // 入场转场效果 (在片段开头)\n        if (frame < transitionDuration) {\n            const enterProgress = interpolate(\n                frame,\n                [0, transitionDuration],\n                [0, 1],\n                { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }\n            )\n\n            switch (transitionType) {\n                case 'dissolve':\n                case 'fade':\n                    opacity = enterProgress\n                    break\n                case 'slide':\n                    transform = `translateX(${(1 - enterProgress) * 100}%)`\n                    break\n            }\n        }\n    }\n\n    return (\n        <AbsoluteFill style={{ opacity, transform }}>\n            {/* 视频 */}\n            <Video\n                src={clip.src}\n                startFrom={clip.trim?.from || 0}\n                style={{\n                    width: '100%',\n                    height: '100%',\n                    objectFit: 'cover'\n                }}\n            />\n\n            {/* 附属配音 */}\n            {clip.attachment?.audio && (\n                <Audio\n                    src={clip.attachment.audio.src}\n                    volume={clip.attachment.audio.volume}\n                />\n            )}\n\n            {/* 附属字幕 */}\n            {clip.attachment?.subtitle && (\n                <SubtitleOverlay\n                    text={clip.attachment.subtitle.text}\n                    style={clip.attachment.subtitle.style}\n                />\n            )}\n        </AbsoluteFill>\n    )\n}\n\n/**\n * 字幕叠加层\n */\ninterface SubtitleOverlayProps {\n    text: string\n    style: 'default' | 'cinematic'\n}\n\nconst SubtitleOverlay: React.FC<SubtitleOverlayProps> = ({ text, style }) => {\n    const styles = {\n        default: {\n            background: 'rgba(0, 0, 0, 0.7)',\n            padding: '8px 16px',\n            borderRadius: '4px',\n            fontSize: '24px',\n            color: 'white'\n        },\n        cinematic: {\n            background: 'transparent',\n            padding: '12px 24px',\n            fontSize: '28px',\n            color: 'white',\n            textShadow: '2px 2px 4px rgba(0, 0, 0, 0.8)',\n            fontWeight: 'bold' as const\n        }\n    }\n\n    return (\n        <AbsoluteFill\n            style={{\n                justifyContent: 'flex-end',\n                alignItems: 'center',\n                paddingBottom: '60px'\n            }}\n        >\n            <div style={styles[style]}>\n                {text}\n            </div>\n        </AbsoluteFill>\n    )\n}\n\nexport default VideoComposition\n"
  },
  {
    "path": "src/features/video-editor/remotion/transitions/index.tsx",
    "content": "import React from 'react'\nimport { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion'\n\ninterface TransitionWrapperProps {\n    type: 'dissolve' | 'fade' | 'slide' | 'none'\n    durationInFrames: number\n    isEntering: boolean  // true = entering transition, false = exiting\n    children: React.ReactNode\n}\n\n/**\n * 转场效果包装器\n * 为片段添加进入/退出动画\n */\nexport const TransitionWrapper: React.FC<TransitionWrapperProps> = ({\n    type,\n    durationInFrames,\n    isEntering,\n    children\n}) => {\n    const frame = useCurrentFrame()\n\n    if (type === 'none') {\n        return <AbsoluteFill>{children}</AbsoluteFill>\n    }\n\n    const progress = isEntering\n        ? interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: 'clamp' })\n        : interpolate(frame, [0, durationInFrames], [1, 0], { extrapolateRight: 'clamp' })\n\n    const getTransitionStyle = (): React.CSSProperties => {\n        switch (type) {\n            case 'dissolve':\n            case 'fade':\n                return { opacity: progress }\n\n            case 'slide':\n                const translateX = isEntering\n                    ? interpolate(progress, [0, 1], [100, 0])\n                    : interpolate(progress, [1, 0], [0, -100])\n                return {\n                    transform: `translateX(${translateX}%)`,\n                    opacity: 1\n                }\n\n            default:\n                return {}\n        }\n    }\n\n    return (\n        <AbsoluteFill style={getTransitionStyle()}>\n            {children}\n        </AbsoluteFill>\n    )\n}\n\n/**\n * 淡入淡出转场\n */\nexport const CrossDissolve: React.FC<{\n    durationInFrames: number\n    children: React.ReactNode\n}> = ({ durationInFrames, children }) => {\n    const frame = useCurrentFrame()\n    const opacity = interpolate(\n        frame,\n        [0, durationInFrames],\n        [0, 1],\n        { extrapolateRight: 'clamp' }\n    )\n\n    return (\n        <AbsoluteFill style={{ opacity }}>\n            {children}\n        </AbsoluteFill>\n    )\n}\n\n/**\n * 滑动转场\n */\nexport const SlideTransition: React.FC<{\n    direction: 'left' | 'right' | 'up' | 'down'\n    durationInFrames: number\n    children: React.ReactNode\n}> = ({ direction, durationInFrames, children }) => {\n    const frame = useCurrentFrame()\n\n    const getTransform = () => {\n        const progress = interpolate(\n            frame,\n            [0, durationInFrames],\n            [100, 0],\n            { extrapolateRight: 'clamp' }\n        )\n\n        switch (direction) {\n            case 'left': return `translateX(${progress}%)`\n            case 'right': return `translateX(-${progress}%)`\n            case 'up': return `translateY(${progress}%)`\n            case 'down': return `translateY(-${progress}%)`\n        }\n    }\n\n    return (\n        <AbsoluteFill style={{ transform: getTransform() }}>\n            {children}\n        </AbsoluteFill>\n    )\n}\n\nconst transitions = { TransitionWrapper, CrossDissolve, SlideTransition }\n\nexport default transitions\n"
  },
  {
    "path": "src/features/video-editor/types/editor.types.ts",
    "content": "// ========================================\n// Video Editor Core Types\n// Schema Version: 1.0\n// ========================================\n\n/**\n * 剪辑项目 - 顶层结构\n */\nexport interface VideoEditorProject {\n    id: string\n    episodeId: string\n    schemaVersion: '1.0'\n\n    config: EditorConfig\n\n    // 主时间轴 (磁性轨道) - 顺序即时间\n    timeline: VideoClip[]\n\n    // BGM 轨道 (绝对定位)\n    bgmTrack: BgmClip[]\n}\n\n/**\n * 编辑器配置\n */\nexport interface EditorConfig {\n    fps: number\n    width: number\n    height: number\n}\n\n/**\n * 视频片段 - 时间轴核心单元\n */\nexport interface VideoClip {\n    id: string\n    src: string                    // COS URL\n    durationInFrames: number       // 播放时长\n\n    // 素材内裁剪 (可选)\n    trim?: {\n        from: number                 // 素材起始帧\n        to: number                   // 素材结束帧\n    }\n\n    // 附属内容 - 跟随视频移动\n    attachment?: ClipAttachment\n\n    // 转场 (与下一个片段的过渡)\n    transition?: ClipTransition\n\n    // AI 元数据 (用于回溯)\n    metadata: ClipMetadata\n}\n\n/**\n * 片段附属内容 (配音 + 字幕)\n */\nexport interface ClipAttachment {\n    audio?: {\n        src: string\n        volume: number\n        voiceLineId?: string\n    }\n    subtitle?: {\n        text: string\n        style: 'default' | 'cinematic'\n    }\n}\n\n/**\n * 转场效果\n */\nexport interface ClipTransition {\n    type: 'none' | 'dissolve' | 'fade' | 'slide'\n    durationInFrames: number\n}\n\n/**\n * 片段元数据\n */\nexport interface ClipMetadata {\n    panelId: string\n    storyboardId: string\n    description?: string\n}\n\n/**\n * BGM 片段 - 独立轨道\n */\nexport interface BgmClip {\n    id: string\n    src: string\n    startFrame: number             // 绝对定位\n    durationInFrames: number\n    volume: number\n    fadeIn?: number\n    fadeOut?: number\n}\n\n// ========================================\n// 时间轴 UI 状态\n// ========================================\n\nexport interface TimelineState {\n    currentFrame: number\n    playing: boolean\n    selectedClipId: string | null\n    zoom: number                   // 缩放级别 (1 = 100%)\n}\n\n// ========================================\n// 计算工具类型\n// ========================================\n\nexport interface ComputedClip extends VideoClip {\n    startFrame: number             // 计算得出的起始帧\n    endFrame: number               // 计算得出的结束帧\n}\n\n// ========================================\n// API 相关类型\n// ========================================\n\nexport interface SaveEditorProjectRequest {\n    projectData: VideoEditorProject\n}\n\nexport interface RenderRequest {\n    editorProjectId: string\n    format: 'mp4' | 'webm'\n    quality: 'draft' | 'high'\n}\n\nexport interface RenderStatus {\n    status: 'pending' | 'rendering' | 'completed' | 'failed'\n    progress?: number\n    outputUrl?: string\n    error?: string\n}\n"
  },
  {
    "path": "src/features/video-editor/utils/migration.ts",
    "content": "import { logWarn as _ulogWarn } from '@/lib/logging/core'\nimport { VideoEditorProject } from '../types/editor.types'\n\n/**\n * 版本迁移函数\n * 将旧版本数据升级到最新版本\n */\nexport function migrateProjectData(data: unknown): VideoEditorProject {\n    const project = data as Record<string, unknown>\n\n    // 检查 schema 版本\n    const version = project.schemaVersion as string\n\n    switch (version) {\n        case '1.0':\n            // 当前最新版本，无需迁移\n            return project as unknown as VideoEditorProject\n\n        default:\n            // 未知版本或无版本，尝试作为 1.0 处理\n            _ulogWarn(`Unknown schema version: ${version}, treating as 1.0`)\n            return {\n                ...project,\n                schemaVersion: '1.0'\n            } as VideoEditorProject\n    }\n}\n\n/**\n * 验证项目数据完整性\n */\nexport function validateProjectData(data: unknown): { valid: boolean; errors: string[] } {\n    const errors: string[] = []\n    const project = data as Record<string, unknown>\n\n    if (!project.id) errors.push('Missing project id')\n    if (!project.episodeId) errors.push('Missing episodeId')\n    if (!project.schemaVersion) errors.push('Missing schemaVersion')\n    if (!project.config) errors.push('Missing config')\n    if (!Array.isArray(project.timeline)) errors.push('Invalid timeline')\n    if (!Array.isArray(project.bgmTrack)) errors.push('Invalid bgmTrack')\n\n    return {\n        valid: errors.length === 0,\n        errors\n    }\n}\n"
  },
  {
    "path": "src/features/video-editor/utils/time-utils.ts",
    "content": "import { VideoClip, ComputedClip, VideoEditorProject } from '../types/editor.types'\n\n/**\n * 计算时间轴总时长 (帧数)\n * 考虑转场重叠\n */\nexport function calculateTimelineDuration(clips: VideoClip[]): number {\n    if (clips.length === 0) return 0\n\n    return clips.reduce((total, clip, index) => {\n        let duration = clip.durationInFrames\n\n        // 最后一个片段不减去转场时间\n        if (index < clips.length - 1 && clip.transition) {\n            // 转场会让总时长减少（重叠部分）\n            duration -= Math.floor(clip.transition.durationInFrames / 2)\n        }\n\n        return total + duration\n    }, 0)\n}\n\n/**\n * 计算每个片段的起始帧位置\n * 用于渲染和 UI 显示\n */\nexport function computeClipPositions(clips: VideoClip[]): ComputedClip[] {\n    let currentFrame = 0\n\n    return clips.map((clip, index) => {\n        const startFrame = currentFrame\n        const endFrame = startFrame + clip.durationInFrames\n\n        // 计算下一个片段的起始位置（考虑转场重叠）\n        if (clip.transition && index < clips.length - 1) {\n            currentFrame = endFrame - Math.floor(clip.transition.durationInFrames / 2)\n        } else {\n            currentFrame = endFrame\n        }\n\n        return {\n            ...clip,\n            startFrame,\n            endFrame\n        }\n    })\n}\n\n/**\n * 帧数转时间字符串\n */\nexport function framesToTime(frames: number, fps: number): string {\n    const totalSeconds = frames / fps\n    const minutes = Math.floor(totalSeconds / 60)\n    const seconds = Math.floor(totalSeconds % 60)\n    const milliseconds = Math.floor((totalSeconds % 1) * 100)\n\n    return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`\n}\n\n/**\n * 时间字符串转帧数\n */\nexport function timeToFrames(time: string, fps: number): number {\n    const [minSec, ms] = time.split('.')\n    const [minutes, seconds] = minSec.split(':').map(Number)\n    const totalSeconds = minutes * 60 + seconds + (parseInt(ms || '0') / 100)\n    return Math.round(totalSeconds * fps)\n}\n\n/**\n * 生成唯一 ID\n */\nexport function generateClipId(): string {\n    return `clip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`\n}\n\n/**\n * 创建默认编辑器项目\n */\nexport function createDefaultProject(episodeId: string): VideoEditorProject {\n    return {\n        id: `editor_${Date.now()}`,\n        episodeId,\n        schemaVersion: '1.0',\n        config: {\n            fps: 30,\n            width: 1920,\n            height: 1080\n        },\n        timeline: [],\n        bgmTrack: []\n    }\n}\n"
  },
  {
    "path": "src/hooks/common/useCandidateSystem.ts",
    "content": "'use client'\n\n/**\n * useCandidateSystem - 统一候选图片管理 Hook\n * 适用于 Panel、Character、Location 等所有需要候选图片选择的实体\n * \n * 功能：\n * - 初始化候选图片列表\n * - 选择候选图片索引\n * - 获取当前显示图片\n * - 确认/取消选择\n * - 支持撤回（previousUrl）\n */\n\nimport { useState, useCallback } from 'react'\n\nexport interface CandidateState {\n    originalUrl: string | null      // 当前确认的图片 URL\n    candidates: string[]            // 候选图片列表\n    selectedIndex: number           // 当前选中 (-1=原图, 0-N=候选)\n    previousUrl: string | null      // 上一版本 URL（支持撤回）\n}\n\nexport function useCandidateSystem<TId extends string = string>() {\n    const [states, setStates] = useState<Map<TId, CandidateState>>(new Map())\n\n    /**\n     * 初始化某个实体的候选图片\n     */\n    const initCandidates = useCallback((\n        id: TId,\n        originalUrl: string | null,\n        candidates: string[],\n        previousUrl: string | null = null\n    ) => {\n        setStates(prev => {\n            const next = new Map(prev)\n            next.set(id, {\n                originalUrl,\n                candidates: candidates.filter(c => c && !c.startsWith('PENDING:')), // 过滤 PENDING 任务\n                selectedIndex: 0, // 默认选中第一张候选\n                previousUrl\n            })\n            return next\n        })\n    }, [])\n\n    /**\n     * 选择候选图片索引（本地状态更新）\n     * @param index -1 表示选择原图，0-N 表示候选图\n     */\n    const selectCandidate = useCallback((id: TId, index: number) => {\n        setStates(prev => {\n            const current = prev.get(id)\n            if (!current) return prev\n\n            const next = new Map(prev)\n            next.set(id, { ...current, selectedIndex: index })\n            return next\n        })\n    }, [])\n\n    /**\n     * 获取当前显示的图片 URL\n     */\n    const getDisplayImage = useCallback((id: TId, fallback: string | null = null): string | null => {\n        const state = states.get(id)\n        if (!state || state.candidates.length === 0) return fallback\n\n        if (state.selectedIndex === -1) {\n            return state.originalUrl || fallback\n        }\n\n        return state.candidates[state.selectedIndex] ?? fallback\n    }, [states])\n\n    /**\n     * 获取确认数据（用于 API 调用）\n     * @returns 选中的 URL，或 null 如果没有选中\n     */\n    const getConfirmData = useCallback((id: TId): { selectedUrl: string } | null => {\n        const state = states.get(id)\n        if (!state || state.candidates.length === 0) return null\n\n        if (state.selectedIndex === -1) {\n            // 选择原图\n            if (!state.originalUrl) return null\n            return { selectedUrl: state.originalUrl }\n        }\n\n        const selectedUrl = state.candidates[state.selectedIndex]\n        if (!selectedUrl) return null\n        return { selectedUrl }\n    }, [states])\n\n    /**\n     * 清除候选状态\n     */\n    const clearCandidates = useCallback((id: TId) => {\n        setStates(prev => {\n            if (!prev.has(id)) return prev\n            const next = new Map(prev)\n            next.delete(id)\n            return next\n        })\n    }, [])\n\n    /**\n     * 检查是否有候选图片\n     */\n    const hasCandidates = useCallback((id: TId): boolean => {\n        const state = states.get(id)\n        return !!state && state.candidates.length > 0\n    }, [states])\n\n    /**\n     * 检查是否可以撤回\n     */\n    const canUndo = useCallback((id: TId): boolean => {\n        const state = states.get(id)\n        return !!state?.previousUrl\n    }, [states])\n\n    /**\n     * 获取候选状态（用于 UI 渲染）\n     */\n    const getCandidateState = useCallback((id: TId): CandidateState | null => {\n        return states.get(id) ?? null\n    }, [states])\n\n    return {\n        states,\n        initCandidates,\n        selectCandidate,\n        getDisplayImage,\n        getConfirmData,\n        clearCandidates,\n        hasCandidates,\n        canUndo,\n        getCandidateState\n    }\n}\n"
  },
  {
    "path": "src/hooks/common/useGithubReleaseUpdate.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport {\n  checkGithubReleaseUpdate,\n  normalizeSemverTag,\n  shouldPulseUpdate,\n} from '@/lib/update-check'\nimport { APP_VERSION, GITHUB_REPOSITORY } from '@/lib/app-meta'\n\nconst ONE_HOUR_IN_MS = 60 * 60 * 1000\nconst MUTED_UPDATE_VERSION_KEY = 'waoowaoo:update:muted-version'\n\nexport interface ReleaseUpdateInfo {\n  latestVersion: string\n  releaseUrl: string\n  releaseName: string | null\n  publishedAt: string | null\n}\n\nexport interface UseGithubReleaseUpdateResult {\n  currentVersion: string\n  update: ReleaseUpdateInfo | null\n  shouldPulse: boolean\n  showModal: boolean\n  isChecking: boolean\n  checkError: string | null\n  openModal: () => void\n  dismissCurrentUpdate: () => void\n  checkNow: () => Promise<void>\n}\n\nfunction readMutedUpdateVersion(): string | null {\n  if (typeof window === 'undefined') return null\n  return window.localStorage.getItem(MUTED_UPDATE_VERSION_KEY)\n}\n\nfunction writeMutedUpdateVersion(version: string): void {\n  if (typeof window === 'undefined') return\n  window.localStorage.setItem(MUTED_UPDATE_VERSION_KEY, version)\n}\n\nexport function useGithubReleaseUpdate(): UseGithubReleaseUpdateResult {\n  const currentVersion = useMemo(() => normalizeSemverTag(APP_VERSION), [])\n\n  const [update, setUpdate] = useState<ReleaseUpdateInfo | null>(null)\n  const [shouldPulse, setShouldPulse] = useState(false)\n  const [checkError, setCheckError] = useState<string | null>(null)\n  const [showModal, setShowModal] = useState(false)\n  const [isChecking, setIsChecking] = useState(false)\n  const latestRequestRef = useRef(0)\n\n  const checkNow = useCallback(async () => {\n    const requestId = latestRequestRef.current + 1\n    latestRequestRef.current = requestId\n    setIsChecking(true)\n\n    const result = await checkGithubReleaseUpdate({\n      repository: GITHUB_REPOSITORY,\n      currentVersion,\n    })\n    if (requestId !== latestRequestRef.current) return\n\n    if (result.kind === 'error') {\n      setCheckError(result.message)\n      setUpdate(null)\n      setShouldPulse(false)\n      setShowModal(false)\n      setIsChecking(false)\n      return\n    }\n\n    setCheckError(null)\n\n    if (result.kind === 'no-release') {\n      setUpdate(null)\n      setShouldPulse(false)\n      setShowModal(false)\n      setIsChecking(false)\n      return\n    }\n\n    if (result.kind === 'no-update') {\n      setUpdate(null)\n      setShouldPulse(false)\n      setShowModal(false)\n      setIsChecking(false)\n      return\n    }\n\n    const nextUpdate: ReleaseUpdateInfo = {\n      latestVersion: result.latestVersion,\n      releaseUrl: result.release.htmlUrl,\n      releaseName: result.release.name,\n      publishedAt: result.release.publishedAt,\n    }\n\n    const mutedVersion = readMutedUpdateVersion()\n    setShouldPulse(shouldPulseUpdate(nextUpdate.latestVersion, mutedVersion))\n    setUpdate(nextUpdate)\n    setIsChecking(false)\n  }, [currentVersion])\n\n  useEffect(() => {\n    let cancelled = false\n\n    const run = async () => {\n      if (cancelled) return\n      await checkNow()\n    }\n\n    void run()\n    const timer = window.setInterval(() => {\n      void run()\n    }, ONE_HOUR_IN_MS)\n\n    return () => {\n      cancelled = true\n      window.clearInterval(timer)\n    }\n  }, [checkNow])\n\n  const dismissCurrentUpdate = useCallback(() => {\n    if (update) {\n      writeMutedUpdateVersion(update.latestVersion)\n    }\n    setShouldPulse(false)\n    setShowModal(false)\n  }, [update])\n\n  const openModal = useCallback(() => {\n    if (!update) return\n    setShowModal(true)\n  }, [update])\n\n  return {\n    currentVersion,\n    update,\n    shouldPulse,\n    showModal,\n    isChecking,\n    checkError,\n    openModal,\n    dismissCurrentUpdate,\n    checkNow,\n  }\n}\n"
  },
  {
    "path": "src/i18n/navigation.ts",
    "content": "import { createNavigation } from 'next-intl/navigation'\nimport { routing } from './routing'\n\nexport const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)\n"
  },
  {
    "path": "src/i18n/routing.ts",
    "content": "import { defineRouting } from 'next-intl/routing';\n\nexport const locales = ['zh', 'en'] as const;\nexport type Locale = (typeof locales)[number];\n\nexport const defaultLocale: Locale = 'zh';\n\nexport const routing = defineRouting({\n    // 支持的所有语言\n    locales,\n\n    // 默认语言\n    defaultLocale,\n\n    // URL 路径策略: 始终显示语言前缀\n    localePrefix: 'always'\n});\n"
  },
  {
    "path": "src/i18n.ts",
    "content": "import { notFound } from 'next/navigation';\nimport { getRequestConfig } from 'next-intl/server';\nimport { routing, locales, type Locale } from './i18n/routing';\n\n// Re-export for convenience\nexport { locales, type Locale, routing };\nexport const defaultLocale = routing.defaultLocale;\n\nexport default getRequestConfig(async ({ requestLocale }) => {\n    // 获取请求的 locale\n    const locale = await requestLocale;\n\n    // 验证传入的 locale 是否有效\n    if (!locale || !locales.includes(locale as Locale)) {\n        notFound();\n    }\n\n    // 加载所有模块化的翻译文件\n    const [\n        common,\n        stages,\n        assetLibrary,\n        smartImport,\n        nav,\n        apiConfig,\n        modelSection,\n        providerSection,\n        landing,\n        auth,\n        workspace,\n        workspaceDetail,\n        profile,\n        billing,\n        apiTypes,\n        actions,\n        video,\n        storyboard,\n        assets,\n        voice,\n        errors,\n        novelPromotion,\n        configModal,\n        worldContextModal,\n        progress,\n        scriptView,\n        assetHub,\n        assetModal,\n        assetPicker,\n        layout\n    ] = await Promise.all([\n        import(`../messages/${locale}/common.json`),\n        import(`../messages/${locale}/stages.json`),\n        import(`../messages/${locale}/assetLibrary.json`),\n        import(`../messages/${locale}/smartImport.json`),\n        import(`../messages/${locale}/nav.json`),\n        import(`../messages/${locale}/apiConfig.json`),\n        import(`../messages/${locale}/modelSection.json`),\n        import(`../messages/${locale}/providerSection.json`),\n        import(`../messages/${locale}/landing.json`),\n        import(`../messages/${locale}/auth.json`),\n        import(`../messages/${locale}/workspace.json`),\n        import(`../messages/${locale}/workspaceDetail.json`),\n        import(`../messages/${locale}/profile.json`),\n        import(`../messages/${locale}/billing.json`),\n        import(`../messages/${locale}/apiTypes.json`),\n        import(`../messages/${locale}/actions.json`),\n        import(`../messages/${locale}/video.json`),\n        import(`../messages/${locale}/storyboard.json`),\n        import(`../messages/${locale}/assets.json`),\n        import(`../messages/${locale}/voice.json`),\n        import(`../messages/${locale}/errors.json`),\n        import(`../messages/${locale}/novel-promotion.json`),\n        import(`../messages/${locale}/configModal.json`),\n        import(`../messages/${locale}/worldContextModal.json`),\n        import(`../messages/${locale}/progress.json`),\n        import(`../messages/${locale}/scriptView.json`),\n        import(`../messages/${locale}/assetHub.json`),\n        import(`../messages/${locale}/assetModal.json`),\n        import(`../messages/${locale}/assetPicker.json`),\n        import(`../messages/${locale}/layout.json`)\n    ]);\n\n    return {\n        locale,\n        messages: {\n            common: common.default,\n            stages: stages.default,\n            assetLibrary: assetLibrary.default,\n            smartImport: smartImport.default,\n            nav: nav.default,\n            apiConfig: apiConfig.default,\n            modelSection: modelSection.default,\n            providerSection: providerSection.default,\n            landing: landing.default,\n            auth: auth.default,\n            workspace: workspace.default,\n            workspaceDetail: workspaceDetail.default,\n            profile: profile.default,\n            billing: billing.default,\n            apiTypes: apiTypes.default,\n            actions: actions.default,\n            video: video.default,\n            storyboard: storyboard.default,\n            assets: assets.default,\n            voice: voice.default,\n            errors: errors.default,\n            novelPromotion: novelPromotion.default,\n            configModal: configModal.default,\n            worldContextModal: worldContextModal.default,\n            progress: progress.default,\n            scriptView: scriptView.default,\n            assetHub: assetHub.default,\n            assetModal: assetModal.default,\n            assetPicker: assetPicker.default,\n            layout: layout.default\n        }\n    };\n});\n"
  },
  {
    "path": "src/instrumentation.ts",
    "content": "// Next.js Instrumentation - 在应用启动时执行\n// https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation\n\nexport async function register() {\n  // 在 Edge Runtime 中直接返回，避免加载 Prisma（它使用了动态代码生成）\n  if (process.env.NEXT_RUNTIME === 'edge') {\n    return\n  }\n\n  // 只在 Node.js 服务端运行\n  if (process.env.NEXT_RUNTIME === 'nodejs') {\n    const { prisma } = await import('@/lib/prisma')\n    const { logInfo: _ulogInfo, logError: _ulogError } = await import('@/lib/logging/core')\n\n    // Phase 1: 将 processing 任务打回 queued\n    try {\n      const resetResult = await prisma.task.updateMany({\n        where: {\n          status: 'processing',\n        },\n        data: {\n          status: 'queued',\n          startedAt: null,\n          heartbeatAt: null,\n          // 保留 externalId，让 worker 重启后能从中断处继续轮询\n          // 而不是重新提交给外部 API（Kling 等），避免重复扣费\n        },\n      })\n\n      if (resetResult.count > 0) {\n        _ulogInfo(`[Instrumentation] Reset ${resetResult.count} processing tasks to queued`)\n      }\n    } catch (error) {\n      _ulogError('[Instrumentation] Failed to reset processing tasks:', error)\n    }\n\n    // Phase 2: 将所有 queued 任务重新加入 BullMQ 队列\n    // 解决 Redis 重启后 DB 仍为 queued 但 BullMQ Job 丢失的孤儿任务问题\n    try {\n      const { addTaskJob } = await import('@/lib/task/queues')\n      const { locales } = await import('@/i18n/routing')\n      const { TASK_STATUS, TASK_TYPE } = await import('@/lib/task/types')\n      type TaskBillingInfo = import('@/lib/task/types').TaskBillingInfo\n      type TaskJobData = import('@/lib/task/types').TaskJobData\n      type TaskType = import('@/lib/task/types').TaskType\n\n      const TASK_TYPE_SET: ReadonlySet<string> = new Set(Object.values(TASK_TYPE))\n\n      function toObject(value: unknown): Record<string, unknown> {\n        if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n        return value as Record<string, unknown>\n      }\n\n      function toTaskType(value: unknown): TaskType | null {\n        if (typeof value !== 'string') return null\n        if (!TASK_TYPE_SET.has(value)) return null\n        return value as TaskType\n      }\n\n      function toTaskPayload(value: unknown): Record<string, unknown> | null {\n        if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n        return value as Record<string, unknown>\n      }\n\n      function toTaskBillingInfo(value: unknown): TaskBillingInfo | null {\n        if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n        const billing = value as Record<string, unknown>\n        if (billing.billable !== true && billing.billable !== false) return null\n        return billing as TaskBillingInfo\n      }\n\n      async function markTaskEnqueued(taskId: string) {\n        await prisma.task.update({\n          where: { id: taskId },\n          data: {\n            enqueuedAt: new Date(),\n            lastEnqueueError: null,\n          },\n        })\n      }\n\n      async function markTaskEnqueueFailed(taskId: string, error: string) {\n        await prisma.task.update({\n          where: { id: taskId },\n          data: {\n            enqueueAttempts: { increment: 1 },\n            lastEnqueueError: error.slice(0, 500),\n          },\n        })\n      }\n\n      function resolveTaskLocaleFromPayload(payload: unknown): TaskJobData['locale'] | null {\n        const payloadObj = toObject(payload)\n        const payloadMeta = toObject(payloadObj.meta)\n        const raw = typeof payloadMeta.locale === 'string'\n          ? payloadMeta.locale\n          : typeof payloadObj.locale === 'string'\n            ? payloadObj.locale\n            : ''\n        if (!raw.trim()) return null\n        const normalized = raw.trim().toLowerCase()\n        for (const locale of locales) {\n          if (normalized === locale || normalized.startsWith(`${locale}-`)) {\n            return locale\n          }\n        }\n        return null\n      }\n\n      const RE_ENQUEUE_BATCH_SIZE = 100\n      const queuedTasks = await prisma.task.findMany({\n        where: { status: 'queued' },\n        select: {\n          id: true,\n          userId: true,\n          projectId: true,\n          episodeId: true,\n          type: true,\n          targetType: true,\n          targetId: true,\n          payload: true,\n          billingInfo: true,\n          priority: true,\n        },\n        orderBy: { createdAt: 'asc' },\n        take: RE_ENQUEUE_BATCH_SIZE,\n      })\n\n      if (queuedTasks.length > 0) {\n        _ulogInfo(`[Instrumentation] Found ${queuedTasks.length} queued tasks, re-enqueueing into BullMQ`)\n\n        let enqueued = 0\n        let failed = 0\n\n        for (const task of queuedTasks) {\n          try {\n            const taskType = toTaskType(task.type)\n            if (!taskType) {\n              await prisma.task.update({\n                where: { id: task.id },\n                data: {\n                  status: TASK_STATUS.FAILED,\n                  errorCode: 'INVALID_TASK_TYPE',\n                  errorMessage: `invalid task type: ${String(task.type)}`,\n                  finishedAt: new Date(),\n                },\n              })\n              failed++\n              continue\n            }\n\n            const locale = resolveTaskLocaleFromPayload(task.payload)\n            if (!locale) {\n              await prisma.task.update({\n                where: { id: task.id },\n                data: {\n                  status: TASK_STATUS.FAILED,\n                  errorCode: 'TASK_LOCALE_REQUIRED',\n                  errorMessage: 'task locale is missing',\n                  finishedAt: new Date(),\n                },\n              })\n              failed++\n              continue\n            }\n\n            const jobData: TaskJobData = {\n              taskId: task.id,\n              type: taskType,\n              locale,\n              projectId: task.projectId,\n              episodeId: task.episodeId || null,\n              targetType: task.targetType,\n              targetId: task.targetId,\n              payload: toTaskPayload(task.payload),\n              billingInfo: toTaskBillingInfo(task.billingInfo),\n              userId: task.userId,\n              trace: null,\n            }\n            await addTaskJob(jobData, {\n              priority: typeof task.priority === 'number' ? task.priority : 0,\n            })\n            await markTaskEnqueued(task.id)\n            enqueued++\n          } catch (error) {\n            const message = error instanceof Error ? error.message : String(error)\n            await markTaskEnqueueFailed(task.id, message || 're-enqueue failed')\n            _ulogError(`[Instrumentation] Failed to re-enqueue task ${task.id}:`, message)\n            failed++\n          }\n        }\n\n        if (enqueued > 0) {\n          _ulogInfo(`[Instrumentation] Re-enqueued ${enqueued} orphaned tasks into BullMQ`)\n        }\n        if (failed > 0) {\n          _ulogError(`[Instrumentation] Failed to re-enqueue ${failed} tasks`)\n        }\n      }\n    } catch (error) {\n      _ulogError('[Instrumentation] Failed to re-enqueue orphaned tasks:', error)\n    }\n\n    // ─── Phase 3: 启动 Task Watchdog（DB ↔ BullMQ 持续对账）───\n    try {\n      const { startTaskWatchdog } = await import('@/lib/task/reconcile')\n      startTaskWatchdog()\n      _ulogInfo('[Instrumentation] Task watchdog started')\n    } catch (error) {\n      _ulogError('[Instrumentation] Failed to start task watchdog:', error)\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/ai-runtime/client.ts",
    "content": "import type OpenAI from 'openai'\nimport { getCompletionContent } from '@/lib/llm-client'\nimport { getCompletionParts } from '@/lib/llm/completion-parts'\nimport {\n  runModelGatewayTextCompletion,\n  runModelGatewayVisionCompletion,\n} from '@/lib/model-gateway/llm'\nimport { toAiRuntimeError } from './errors'\nimport type {\n  AiStepExecutionInput,\n  AiStepExecutionResult,\n  AiVisionStepExecutionInput,\n  AiVisionStepExecutionResult,\n} from './types'\n\nfunction toInt(value: unknown): number {\n  if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value))\n  return 0\n}\n\nfunction extractUsage(completion: AiStepExecutionResult['completion']) {\n  const promptTokens = toInt(completion.usage?.prompt_tokens)\n  const completionTokens = toInt(completion.usage?.completion_tokens)\n  const totalTokens = toInt(completion.usage?.total_tokens) || (promptTokens + completionTokens)\n  return {\n    promptTokens,\n    completionTokens,\n    totalTokens,\n  }\n}\n\nfunction extractTextAndReasoning(completion: OpenAI.Chat.Completions.ChatCompletion): {\n  text: string\n  reasoning: string\n} {\n  try {\n    return getCompletionParts(completion)\n  } catch {\n    const text = typeof getCompletionContent === 'function'\n      ? (getCompletionContent(completion) || '')\n      : ''\n    return {\n      text,\n      reasoning: '',\n    }\n  }\n}\n\nexport async function executeAiTextStep(input: AiStepExecutionInput): Promise<AiStepExecutionResult> {\n  try {\n    const completion = await runModelGatewayTextCompletion({\n      userId: input.userId,\n      model: input.model,\n      messages: input.messages,\n      options: {\n        temperature: input.temperature,\n        reasoning: input.reasoning,\n        reasoningEffort: input.reasoningEffort,\n        projectId: input.projectId,\n        action: input.action,\n        streamStepId: input.meta.stepId,\n        streamStepAttempt: input.meta.stepAttempt || 1,\n        streamStepTitle: input.meta.stepTitle,\n        streamStepIndex: input.meta.stepIndex,\n        streamStepTotal: input.meta.stepTotal,\n      },\n    })\n\n    const parts = extractTextAndReasoning(completion)\n    return {\n      text: parts.text,\n      reasoning: parts.reasoning,\n      usage: extractUsage(completion),\n      completion,\n    }\n  } catch (error) {\n    throw toAiRuntimeError(error)\n  }\n}\n\nexport async function executeAiVisionStep(input: AiVisionStepExecutionInput): Promise<AiVisionStepExecutionResult> {\n  try {\n    const completion = await runModelGatewayVisionCompletion({\n      userId: input.userId,\n      model: input.model,\n      prompt: input.prompt,\n      imageUrls: input.imageUrls,\n      options: {\n        temperature: input.temperature,\n        reasoning: input.reasoning,\n        reasoningEffort: input.reasoningEffort,\n        projectId: input.projectId,\n        action: input.action,\n        streamStepId: input.meta?.stepId,\n        streamStepAttempt: input.meta?.stepAttempt || 1,\n        streamStepTitle: input.meta?.stepTitle,\n        streamStepIndex: input.meta?.stepIndex,\n        streamStepTotal: input.meta?.stepTotal,\n      },\n    })\n\n    const parts = extractTextAndReasoning(completion)\n    return {\n      text: parts.text,\n      reasoning: parts.reasoning,\n      usage: extractUsage(completion),\n      completion,\n    }\n  } catch (error) {\n    throw toAiRuntimeError(error)\n  }\n}\n"
  },
  {
    "path": "src/lib/ai-runtime/errors.ts",
    "content": "import { normalizeAnyError } from '@/lib/errors/normalize'\nimport type { AiRuntimeError, AiRuntimeErrorCode } from './types'\n\nfunction toCode(value: string): AiRuntimeErrorCode {\n  if (value === 'NETWORK_ERROR') return 'NETWORK_ERROR'\n  if (value === 'RATE_LIMIT') return 'RATE_LIMIT'\n  if (value === 'EMPTY_RESPONSE') return 'EMPTY_RESPONSE'\n  if (value === 'GENERATION_TIMEOUT') return 'TIMEOUT'\n  if (value === 'SENSITIVE_CONTENT') return 'SENSITIVE_CONTENT'\n  if (value === 'PARSING_ERROR') return 'PARSE_ERROR'\n  return 'INTERNAL_ERROR'\n}\n\nfunction inferEmptyResponse(message: string): boolean {\n  const normalized = message.toLowerCase()\n  return normalized.includes('stream_empty')\n    || normalized.includes('empty response')\n    || normalized.includes('no meaningful content')\n    || normalized.includes('channel:empty_response')\n}\n\nfunction hasEmptyResponseSignal(input: unknown): boolean {\n  const queue: unknown[] = [input]\n  const visited = new Set<object>()\n  let scannedCount = 0\n\n  while (queue.length > 0 && scannedCount < 100) {\n    const current = queue.shift()\n    scannedCount += 1\n    if (current === null || current === undefined) continue\n\n    if (typeof current === 'string') {\n      if (inferEmptyResponse(current)) return true\n      continue\n    }\n\n    if (current instanceof Error) {\n      if (inferEmptyResponse(current.message || '')) return true\n      const errorWithCause = current as Error & { cause?: unknown } & Record<string, unknown>\n      queue.push(errorWithCause.cause)\n      queue.push(errorWithCause.error)\n      queue.push(errorWithCause.details)\n      queue.push(errorWithCause.response)\n      continue\n    }\n\n    if (Array.isArray(current)) {\n      for (const item of current) {\n        queue.push(item)\n      }\n      continue\n    }\n\n    if (typeof current !== 'object') {\n      continue\n    }\n\n    if (visited.has(current)) {\n      continue\n    }\n    visited.add(current)\n\n    const record = current as Record<string, unknown>\n    for (const value of Object.values(record)) {\n      queue.push(value)\n    }\n  }\n\n  return false\n}\n\nexport function toAiRuntimeError(input: unknown): AiRuntimeError {\n  const normalized = normalizeAnyError(input, { context: 'worker' })\n  const message = normalized.message || 'AI request failed'\n  const isEmptyResponse = inferEmptyResponse(message) || hasEmptyResponseSignal(input)\n  const code = isEmptyResponse\n    ? 'EMPTY_RESPONSE'\n    : toCode(normalized.code)\n\n  const error = new Error(message) as AiRuntimeError\n  error.code = code\n  error.retryable = code === 'EMPTY_RESPONSE' ? true : normalized.retryable\n  error.provider = normalized.provider || null\n  error.cause = input\n  return error\n}\n"
  },
  {
    "path": "src/lib/ai-runtime/index.ts",
    "content": "export { executeAiTextStep, executeAiVisionStep } from './client'\nexport { toAiRuntimeError } from './errors'\nexport type {\n  AiRuntimeError,\n  AiRuntimeErrorCode,\n  AiStepExecutionInput,\n  AiStepExecutionResult,\n  AiStepMeta,\n  AiTextMessages,\n} from './types'\n"
  },
  {
    "path": "src/lib/ai-runtime/types.ts",
    "content": "import type OpenAI from 'openai'\n\nexport type AiRuntimeErrorCode =\n  | 'NETWORK_ERROR'\n  | 'RATE_LIMIT'\n  | 'EMPTY_RESPONSE'\n  | 'PARSE_ERROR'\n  | 'TIMEOUT'\n  | 'SENSITIVE_CONTENT'\n  | 'INTERNAL_ERROR'\n\nexport type AiRuntimeError = Error & {\n  code: AiRuntimeErrorCode\n  retryable: boolean\n  provider?: string | null\n  cause?: unknown\n}\n\nexport type AiStepMeta = {\n  stepId: string\n  stepAttempt?: number\n  stepTitle: string\n  stepIndex: number\n  stepTotal: number\n}\n\nexport type AiTextMessages = Array<{\n  role: 'user' | 'assistant' | 'system'\n  content: string\n}>\n\nexport type AiStepExecutionInput = {\n  userId: string\n  model: string\n  messages: AiTextMessages\n  projectId?: string\n  action: string\n  meta: AiStepMeta\n  temperature?: number\n  reasoning?: boolean\n  reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'\n}\n\nexport type AiStepExecutionResult = {\n  text: string\n  reasoning: string\n  usage: {\n    promptTokens: number\n    completionTokens: number\n    totalTokens: number\n  }\n  completion: OpenAI.Chat.Completions.ChatCompletion\n}\n\nexport type AiVisionStepExecutionInput = {\n  userId: string\n  model: string\n  prompt: string\n  imageUrls: string[]\n  projectId?: string\n  action?: string\n  meta?: AiStepMeta\n  temperature?: number\n  reasoning?: boolean\n  reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'\n}\n\nexport type AiVisionStepExecutionResult = {\n  text: string\n  reasoning: string\n  usage: {\n    promptTokens: number\n    completionTokens: number\n    totalTokens: number\n  }\n  completion: OpenAI.Chat.Completions.ChatCompletion\n}\n"
  },
  {
    "path": "src/lib/api-auth.ts",
    "content": "/**\n * 🔐 API 权限验证工具\n * 集中管理 Session 验证、项目权限检查等通用逻辑\n */\n\nimport { getServerSession } from 'next-auth/next'\nimport { NextResponse } from 'next/server'\nimport { headers as readHeaders } from 'next/headers'\nimport { authOptions } from '@/lib/auth'\nimport { prisma } from '@/lib/prisma'\nimport { withPrismaRetry } from '@/lib/prisma-retry'\nimport { extractModelKey } from '@/lib/config-service'\nimport { getErrorSpec, type UnifiedErrorCode } from '@/lib/errors/codes'\nimport { getLogContext, setLogContext } from '@/lib/logging/context'\n\n// ============================================================\n// 类型定义\n// ============================================================\n\nexport interface AuthSession {\n    user: {\n        id: string\n        name?: string | null\n        email?: string | null\n    }\n}\n\nfunction bindAuthLogContext(session: AuthSession, projectId?: string) {\n    const context = getLogContext()\n    if (!context.requestId) return\n    setLogContext({\n        userId: session.user.id,\n        ...(projectId ? { projectId } : {}),\n    })\n}\n\nasync function getInternalTaskSession(): Promise<AuthSession | null> {\n    const expectedToken = process.env.INTERNAL_TASK_TOKEN || ''\n\n    const incomingHeaders = await readHeaders()\n    const token = incomingHeaders.get('x-internal-task-token') || ''\n    const userId = incomingHeaders.get('x-internal-user-id') || ''\n    if (!userId) return null\n    if (expectedToken) {\n        if (token !== expectedToken) return null\n    } else if (process.env.NODE_ENV === 'production') {\n        return null\n    }\n\n    return {\n        user: {\n            id: userId,\n            name: 'internal-worker',\n            email: null,\n        }\n    }\n}\n\n/**\n * 可选的关联数据加载配置\n */\nexport type ProjectAuthIncludes = {\n    characters?: boolean\n    locations?: boolean\n    episodes?: boolean\n}\n\ninterface AuthCharacterLike {\n    name: string\n    introduction?: string | null\n    [key: string]: unknown\n}\n\ninterface AuthLocationLike {\n    name: string\n    [key: string]: unknown\n}\n\ninterface AuthEpisodeLike {\n    id: string\n    [key: string]: unknown\n}\n\n/**\n * 基础 novelData 类型\n */\nexport interface NovelDataBase {\n    id: string\n    [key: string]: unknown\n}\n\n/**\n * 根据 include 选项推断的 novelData 类型\n */\nexport type NovelDataWithIncludes<T extends ProjectAuthIncludes> = NovelDataBase\n    & (T['characters'] extends true ? { characters: AuthCharacterLike[] } : Record<string, never>)\n    & (T['locations'] extends true ? { locations: AuthLocationLike[] } : Record<string, never>)\n    & (T['episodes'] extends true ? { episodes: AuthEpisodeLike[] } : Record<string, never>)\n\n/**\n * 完整的认证上下文（带泛型）\n */\nexport interface ProjectAuthContextWithIncludes<T extends ProjectAuthIncludes = ProjectAuthIncludes> {\n    session: AuthSession\n    project: {\n        id: string\n        userId: string\n        name: string\n        [key: string]: unknown\n    }\n    novelData: NovelDataWithIncludes<T>\n}\n\n/**\n * 向后兼容的类型别名\n */\nexport type ProjectAuthContext = ProjectAuthContextWithIncludes<ProjectAuthIncludes>\n\n// ============================================================\n// 错误响应工具\n// ============================================================\n\nfunction buildErrorResponse(code: UnifiedErrorCode, message?: string, details: Record<string, unknown> = {}) {\n    const spec = getErrorSpec(code)\n    const finalMessage = message?.trim() || spec.defaultMessage\n    return NextResponse.json(\n        {\n            success: false,\n            error: {\n                code,\n                message: finalMessage,\n                retryable: spec.retryable,\n                category: spec.category,\n                userMessageKey: spec.userMessageKey,\n                details,\n            },\n            code,\n            message: finalMessage,\n            ...details,\n        },\n        { status: spec.httpStatus },\n    )\n}\n\nexport function unauthorized(message = 'Unauthorized') {\n    return buildErrorResponse('UNAUTHORIZED', message)\n}\n\nexport function forbidden(message = 'Forbidden') {\n    return buildErrorResponse('FORBIDDEN', message)\n}\n\nexport function notFound(resource = 'Resource') {\n    return buildErrorResponse('NOT_FOUND', `${resource} not found`)\n}\n\nexport function badRequest(message: string) {\n    return buildErrorResponse('INVALID_PARAMS', message)\n}\n\nexport function serverError(message = 'Internal server error') {\n    return buildErrorResponse('INTERNAL_ERROR', message)\n}\n\n// ============================================================\n// 权限验证函数\n// ============================================================\n\n/**\n * 验证用户 Session\n * @returns session 或 null\n */\nexport async function getAuthSession(): Promise<AuthSession | null> {\n    const internalSession = await getInternalTaskSession()\n    if (internalSession) return internalSession\n    const session = await getServerSession(authOptions)\n    return session as AuthSession | null\n}\n\n/**\n * 要求用户登录\n * @throws 返回 401 响应\n */\nexport async function requireAuth(): Promise<AuthSession> {\n    const session = await getAuthSession()\n    if (!session?.user?.id) {\n        throw { response: unauthorized() }\n    }\n    bindAuthLogContext(session)\n    return session\n}\n\n/**\n * 验证项目访问权限\n * 包含：Session 验证 + 项目存在检查 + 所有权验证 + NovelPromotionData 检查\n * \n * @param projectId 项目 ID\n * @param options 可选配置，支持按需加载关联数据\n * @returns 验证上下文（session, project, novelData）\n * @throws 返回对应的错误响应\n * \n * @example\n * ```typescript\n * // 基础用法（不加载关联数据）\n * const authResult = await requireProjectAuth(projectId)\n * \n * // 加载 characters 和 locations\n * const authResult = await requireProjectAuth(projectId, {\n *   include: { characters: true, locations: true }\n * })\n * // authResult.novelData.characters 和 locations 自动可用\n * ```\n */\nexport async function requireProjectAuth<T extends ProjectAuthIncludes = ProjectAuthIncludes>(\n    projectId: string,\n    options?: { include?: T }\n): Promise<ProjectAuthContextWithIncludes<T> | NextResponse> {\n    // 1. 验证 Session\n    const session = await getAuthSession()\n    if (!session?.user?.id) {\n        return unauthorized()\n    }\n    bindAuthLogContext(session, projectId)\n\n    // 2. 构建动态 include 对象\n    const novelPromotionIncludes: Record<string, boolean> = {}\n    if (options?.include?.characters) {\n        novelPromotionIncludes.characters = true\n    }\n    if (options?.include?.locations) {\n        novelPromotionIncludes.locations = true\n    }\n    if (options?.include?.episodes) {\n        novelPromotionIncludes.episodes = true\n    }\n\n    // 3. 获取项目（包含 novelPromotionData 及其可选关联）\n    const hasIncludes = Object.keys(novelPromotionIncludes).length > 0\n    const project = await withPrismaRetry(() =>\n        prisma.project.findUnique({\n            where: { id: projectId },\n            include: {\n                novelPromotionData: hasIncludes\n                    ? { include: novelPromotionIncludes }\n                    : true\n            }\n        })\n    )\n\n    // 4. 项目存在检查\n    if (!project) {\n        return notFound('Project')\n    }\n\n    // 5. 所有权验证\n    if (project.userId !== session.user.id) {\n        return forbidden()\n    }\n\n    // 6. NovelPromotionData 检查\n    if (!project.novelPromotionData) {\n        return notFound('Novel promotion data')\n    }\n\n    // 统一返回 modelKey（provider::modelId），禁止降级为纯 modelId\n    const rawNovelData = project.novelPromotionData as {\n        analysisModel?: string | null\n        characterModel?: string | null\n        locationModel?: string | null\n        storyboardModel?: string | null\n        editModel?: string | null\n        videoModel?: string | null\n        audioModel?: string | null\n        [key: string]: unknown\n    }\n    const processedNovelData = {\n        ...rawNovelData,\n        analysisModel: extractModelKey(rawNovelData.analysisModel),\n        characterModel: extractModelKey(rawNovelData.characterModel),\n        locationModel: extractModelKey(rawNovelData.locationModel),\n        storyboardModel: extractModelKey(rawNovelData.storyboardModel),\n        editModel: extractModelKey(rawNovelData.editModel),\n        videoModel: extractModelKey(rawNovelData.videoModel),\n        audioModel: extractModelKey(rawNovelData.audioModel),\n    }\n\n    return {\n        session,\n        project,\n        novelData: processedNovelData as unknown as NovelDataWithIncludes<T>\n    }\n}\n\n/**\n * 仅验证 Session，不检查项目权限\n * 适用于用户级 API（如资产库）\n * \n * @example\n * ```typescript\n * const authResult = await requireUserAuth()\n * if (authResult instanceof NextResponse) return authResult\n * \n * const { session } = authResult\n * ```\n */\nexport async function requireUserAuth(): Promise<{ session: AuthSession } | NextResponse> {\n    const session = await getAuthSession()\n    if (!session?.user?.id) {\n        return unauthorized()\n    }\n    bindAuthLogContext(session)\n    return { session }\n}\n\n/**\n * 验证项目权限（不要求 NovelPromotionData）\n * 适用于某些不需要 novelPromotionData 的 API\n */\nexport async function requireProjectAuthLight(\n    projectId: string\n): Promise<{ session: AuthSession; project: { id: string; userId: string; name: string; [key: string]: unknown } } | NextResponse> {\n    const session = await getAuthSession()\n    if (!session?.user?.id) {\n        return unauthorized()\n    }\n    bindAuthLogContext(session, projectId)\n\n    const project = await withPrismaRetry(() =>\n        prisma.project.findUnique({\n            where: { id: projectId }\n        })\n    )\n\n    if (!project) {\n        return notFound('Project')\n    }\n\n    if (project.userId !== session.user.id) {\n        return forbidden()\n    }\n\n    return { session, project }\n}\n\n// ============================================================\n// 类型守卫\n// ============================================================\n\n/**\n * 检查是否是错误响应\n */\nexport function isErrorResponse(result: unknown): result is NextResponse {\n    return result instanceof NextResponse\n}\n"
  },
  {
    "path": "src/lib/api-config.ts",
    "content": "/**\n * API 配置读取器（配置中心严格模式）\n *\n * 规则：\n * 1) 模型唯一键必须是 provider::modelId\n * 2) 禁止 provider 猜测、静态映射、默认降级\n * 3) 运行时只从配置中心读取 provider 与密钥\n */\n\nimport { prisma } from './prisma'\nimport { decryptApiKey } from './crypto-utils'\nimport {\n  composeModelKey,\n  parseModelKeyStrict,\n  type UnifiedModelType,\n} from './model-config-contract'\nimport type {\n  OpenAICompatMediaTemplate,\n  OpenAICompatMediaTemplateSource,\n} from './openai-compat-media-template'\nimport { validateOpenAICompatMediaTemplate } from './user-api/model-template/validator'\n\nexport interface CustomModel {\n  modelId: string\n  modelKey: string\n  name: string\n  type: UnifiedModelType\n  provider: string\n  llmProtocol?: 'responses' | 'chat-completions'\n  llmProtocolCheckedAt?: string\n  compatMediaTemplate?: OpenAICompatMediaTemplate\n  compatMediaTemplateCheckedAt?: string\n  compatMediaTemplateSource?: OpenAICompatMediaTemplateSource\n  // Non-authoritative display field; billing uses unified server pricing catalog.\n  price: number\n}\n\nexport type ModelMediaType = 'llm' | 'image' | 'video' | 'audio' | 'lipsync'\n\nexport interface ModelSelection {\n  provider: string\n  modelId: string\n  modelKey: string\n  mediaType: ModelMediaType\n  llmProtocol?: 'responses' | 'chat-completions'\n  compatMediaTemplate?: OpenAICompatMediaTemplate\n}\n\ntype GatewayRouteType = 'official' | 'openai-compat'\n\ninterface CustomProvider {\n  id: string\n  name: string\n  baseUrl?: string\n  apiKey?: string\n  apiMode?: 'gemini-sdk' | 'openai-official'\n  gatewayRoute?: GatewayRouteType\n}\n\ntype LlmProtocolType = 'responses' | 'chat-completions'\n\nfunction normalizeProviderBaseUrl(providerId: string, rawBaseUrl?: string): string | undefined {\n  const providerKey = getProviderKey(providerId)\n  if (providerKey === 'minimax') {\n    return 'https://api.minimaxi.com/v1'\n  }\n\n  const baseUrl = readTrimmedString(rawBaseUrl)\n  if (!baseUrl) return undefined\n  if (providerKey !== 'openai-compatible') return baseUrl\n\n  try {\n    const parsed = new URL(baseUrl)\n    const pathSegments = parsed.pathname.split('/').filter(Boolean)\n    const hasV1 = pathSegments.includes('v1')\n    if (hasV1) return baseUrl\n\n    const trimmedPath = parsed.pathname.replace(/\\/+$/, '')\n    parsed.pathname = `${trimmedPath === '' || trimmedPath === '/' ? '' : trimmedPath}/v1`\n    return parsed.toString()\n  } catch {\n    // Keep original value to avoid hiding invalid-config errors.\n    return baseUrl\n  }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction isUnifiedModelType(value: unknown): value is UnifiedModelType {\n  return (\n    value === 'llm'\n    || value === 'image'\n    || value === 'video'\n    || value === 'audio'\n    || value === 'lipsync'\n  )\n}\n\nfunction isGatewayRoute(value: unknown): value is GatewayRouteType {\n  return value === 'official' || value === 'openai-compat'\n}\n\nfunction isLlmProtocol(value: unknown): value is LlmProtocolType {\n  return value === 'responses' || value === 'chat-completions'\n}\n\nfunction assertModelKey(value: string, field: string): { provider: string; modelId: string; modelKey: string } {\n  const parsed = parseModelKeyStrict(value)\n  if (!parsed) {\n    throw new Error(`MODEL_KEY_INVALID: ${field} must be provider::modelId`)\n  }\n  return parsed\n}\n\nfunction parseCustomProviders(rawProviders: string | null | undefined): CustomProvider[] {\n  if (!rawProviders) return []\n\n  let parsedUnknown: unknown\n  try {\n    parsedUnknown = JSON.parse(rawProviders)\n  } catch {\n    throw new Error('PROVIDER_PAYLOAD_INVALID: customProviders is not valid JSON')\n  }\n\n  if (!Array.isArray(parsedUnknown)) {\n    throw new Error('PROVIDER_PAYLOAD_INVALID: customProviders must be an array')\n  }\n\n  const providers: CustomProvider[] = []\n  for (let index = 0; index < parsedUnknown.length; index += 1) {\n    const raw = parsedUnknown[index]\n    if (!isRecord(raw)) {\n      throw new Error(`PROVIDER_PAYLOAD_INVALID: providers[${index}] must be an object`)\n    }\n\n    const id = readTrimmedString(raw.id)\n    const name = readTrimmedString(raw.name)\n    if (!id || !name) {\n      throw new Error(`PROVIDER_PAYLOAD_INVALID: providers[${index}] missing id or name`)\n    }\n    const normalizedId = id.toLowerCase()\n    if (providers.some((provider) => provider.id.toLowerCase() === normalizedId)) {\n      throw new Error(`PROVIDER_DUPLICATE: providers[${index}].id duplicates id ${id}`)\n    }\n\n    const providerKey = getProviderKey(id).toLowerCase()\n    const apiModeRaw = raw.apiMode\n    let apiMode: 'gemini-sdk' | 'openai-official' | undefined\n    if (apiModeRaw === undefined) {\n      apiMode = undefined\n    } else if (apiModeRaw === 'gemini-sdk' || apiModeRaw === 'openai-official') {\n      if (providerKey === 'gemini-compatible' && apiModeRaw === 'openai-official') {\n        throw new Error(`PROVIDER_API_MODE_INVALID: providers[${index}].apiMode`)\n      }\n      apiMode = apiModeRaw\n    } else {\n      throw new Error(`PROVIDER_API_MODE_INVALID: providers[${index}].apiMode`)\n    }\n\n    const gatewayRouteRaw = raw.gatewayRoute\n    let gatewayRoute: GatewayRouteType | undefined\n    if (gatewayRouteRaw === undefined) {\n      gatewayRoute = undefined\n    } else if (!isGatewayRoute(gatewayRouteRaw)) {\n      throw new Error(`PROVIDER_GATEWAY_ROUTE_INVALID: providers[${index}].gatewayRoute`)\n    } else if (providerKey === 'openai-compatible' && gatewayRouteRaw === 'official') {\n      throw new Error(`PROVIDER_GATEWAY_ROUTE_INVALID: providers[${index}].gatewayRoute`)\n    } else if (providerKey !== 'openai-compatible' && gatewayRouteRaw === 'openai-compat') {\n      throw new Error(`PROVIDER_GATEWAY_ROUTE_INVALID: providers[${index}].gatewayRoute`)\n    } else {\n      gatewayRoute = gatewayRouteRaw\n    }\n\n    providers.push({\n      id,\n      name,\n      baseUrl: readTrimmedString(raw.baseUrl) || undefined,\n      apiKey: readTrimmedString(raw.apiKey) || undefined,\n      apiMode,\n      gatewayRoute,\n    })\n  }\n\n  return providers\n}\n\nfunction normalizeStoredModel(raw: unknown, index: number): CustomModel {\n  if (!isRecord(raw)) {\n    throw new Error(`MODEL_PAYLOAD_INVALID: models[${index}] must be an object`)\n  }\n\n  if (!isUnifiedModelType(raw.type)) {\n    throw new Error(`MODEL_TYPE_INVALID: models[${index}].type is invalid`)\n  }\n\n  const providerFromField = readTrimmedString(raw.provider)\n  const modelIdFromField = readTrimmedString(raw.modelId)\n  const modelKeyFromField = readTrimmedString(raw.modelKey)\n\n  const parsedFromKey = modelKeyFromField ? parseModelKeyStrict(modelKeyFromField) : null\n  const provider = providerFromField || parsedFromKey?.provider || ''\n  const modelId = modelIdFromField || parsedFromKey?.modelId || ''\n  const modelKey = composeModelKey(provider, modelId)\n\n  if (!modelKey) {\n    throw new Error(`MODEL_KEY_INVALID: models[${index}] must include provider and modelId`)\n  }\n\n  if (parsedFromKey && parsedFromKey.modelKey !== modelKey) {\n    throw new Error(`MODEL_KEY_MISMATCH: models[${index}].modelKey conflicts with provider/modelId`)\n  }\n\n  const llmProtocolRaw = raw.llmProtocol\n  let llmProtocol: LlmProtocolType | undefined\n  if (llmProtocolRaw !== undefined && llmProtocolRaw !== null) {\n    if (!isLlmProtocol(llmProtocolRaw)) {\n      throw new Error(`MODEL_LLM_PROTOCOL_INVALID: models[${index}].llmProtocol`)\n    }\n    llmProtocol = llmProtocolRaw\n  }\n  const llmProtocolCheckedAt = readTrimmedString(raw.llmProtocolCheckedAt) || undefined\n\n  const compatMediaTemplateRaw = raw.compatMediaTemplate\n  let compatMediaTemplate: OpenAICompatMediaTemplate | undefined\n  if (compatMediaTemplateRaw !== undefined && compatMediaTemplateRaw !== null) {\n    const validated = validateOpenAICompatMediaTemplate(compatMediaTemplateRaw)\n    if (!validated.ok || !validated.template) {\n      throw new Error(`MODEL_COMPAT_MEDIA_TEMPLATE_INVALID: models[${index}].compatMediaTemplate`)\n    }\n    compatMediaTemplate = validated.template\n  }\n  const compatMediaTemplateCheckedAt = readTrimmedString(raw.compatMediaTemplateCheckedAt) || undefined\n  const compatMediaTemplateSourceRaw = readTrimmedString(raw.compatMediaTemplateSource)\n  const compatMediaTemplateSource = compatMediaTemplateSourceRaw === 'ai' || compatMediaTemplateSourceRaw === 'manual'\n    ? compatMediaTemplateSourceRaw\n    : undefined\n\n  return {\n    modelId,\n    modelKey,\n    provider,\n    type: raw.type,\n    name: readTrimmedString(raw.name) || modelId,\n    ...(llmProtocol ? { llmProtocol } : {}),\n    ...(llmProtocolCheckedAt ? { llmProtocolCheckedAt } : {}),\n    ...(compatMediaTemplate ? { compatMediaTemplate } : {}),\n    ...(compatMediaTemplateCheckedAt ? { compatMediaTemplateCheckedAt } : {}),\n    ...(compatMediaTemplateSource ? { compatMediaTemplateSource } : {}),\n    price: 0,\n  }\n}\n\nfunction parseCustomModels(rawModels: string | null | undefined): CustomModel[] {\n  if (!rawModels) return []\n\n  let parsedUnknown: unknown\n  try {\n    parsedUnknown = JSON.parse(rawModels)\n  } catch {\n    throw new Error('MODEL_PAYLOAD_INVALID: customModels is not valid JSON')\n  }\n\n  if (!Array.isArray(parsedUnknown)) {\n    throw new Error('MODEL_PAYLOAD_INVALID: customModels must be an array')\n  }\n\n  const models: CustomModel[] = []\n  for (let index = 0; index < parsedUnknown.length; index += 1) {\n    models.push(normalizeStoredModel(parsedUnknown[index], index))\n  }\n\n  return models\n}\n\nfunction pickProviderStrict(\n  providers: CustomProvider[],\n  providerId: string,\n): CustomProvider {\n  const matched = providers.find((provider) => provider.id === providerId)\n  if (matched) return matched\n\n  throw new Error(`PROVIDER_NOT_FOUND: ${providerId} is not configured`)\n}\n\nasync function readUserConfig(userId: string): Promise<{ models: CustomModel[]; providers: CustomProvider[] }> {\n  const pref = await prisma.userPreference.findUnique({\n    where: { userId },\n    select: {\n      customModels: true,\n      customProviders: true,\n    },\n  })\n\n  return {\n    models: parseCustomModels(pref?.customModels),\n    providers: parseCustomProviders(pref?.customProviders),\n  }\n}\n\nfunction findModelByKey(models: CustomModel[], modelKey: string): CustomModel | null {\n  const parsed = assertModelKey(modelKey, 'model')\n  return models.find((model) => model.modelId === parsed.modelId && model.provider === parsed.provider) || null\n}\n\n/**\n * 提取提供商主键（用于多实例场景，如 gemini-compatible:uuid）\n */\nexport function getProviderKey(providerId?: string): string {\n  if (!providerId) return ''\n  const colonIndex = providerId.indexOf(':')\n  return colonIndex === -1 ? providerId : providerId.slice(0, colonIndex)\n}\n\n/**\n * 统一模型选择解析（严格模式）\n */\nexport async function resolveModelSelection(\n  userId: string,\n  model: string,\n  mediaType: ModelMediaType,\n): Promise<ModelSelection> {\n  const parsed = assertModelKey(model, `${mediaType} model`)\n  const models = await getModelsByType(userId, mediaType)\n\n  const exact = findModelByKey(models, parsed.modelKey)\n  if (!exact) {\n    throw new Error(`MODEL_NOT_FOUND: ${parsed.modelKey} is not enabled for ${mediaType}`)\n  }\n\n  const providerKey = getProviderKey(exact.provider).toLowerCase()\n  const llmProtocol = mediaType === 'llm' && providerKey === 'openai-compatible'\n    ? (exact.llmProtocol || 'chat-completions')\n    : undefined\n  const compatMediaTemplate = (mediaType === 'image' || mediaType === 'video') && providerKey === 'openai-compatible'\n    ? exact.compatMediaTemplate\n    : undefined\n\n  return {\n    provider: exact.provider,\n    modelId: exact.modelId,\n    modelKey: composeModelKey(exact.provider, exact.modelId),\n    mediaType,\n    ...(llmProtocol ? { llmProtocol } : {}),\n    ...(compatMediaTemplate ? { compatMediaTemplate } : {}),\n  }\n}\n\nasync function resolveSingleModelSelection(\n  userId: string,\n  mediaType: ModelMediaType,\n): Promise<ModelSelection> {\n  const models = await getModelsByType(userId, mediaType)\n  if (models.length === 0) {\n    throw new Error(`MODEL_NOT_CONFIGURED: no ${mediaType} model is enabled`)\n  }\n  if (models.length > 1) {\n    throw new Error(`MODEL_SELECTION_REQUIRED: multiple ${mediaType} models are enabled, provide model_key explicitly`)\n  }\n\n  const model = models[0]\n  const providerKey = getProviderKey(model.provider).toLowerCase()\n  const llmProtocol = mediaType === 'llm' && providerKey === 'openai-compatible'\n    ? (model.llmProtocol || 'chat-completions')\n    : undefined\n  const compatMediaTemplate = (mediaType === 'image' || mediaType === 'video') && providerKey === 'openai-compatible'\n    ? model.compatMediaTemplate\n    : undefined\n\n  return {\n    provider: model.provider,\n    modelId: model.modelId,\n    modelKey: composeModelKey(model.provider, model.modelId),\n    mediaType,\n    ...(llmProtocol ? { llmProtocol } : {}),\n    ...(compatMediaTemplate ? { compatMediaTemplate } : {}),\n  }\n}\n\n/**\n * 统一模型选择解析（允许显式 model_key；未传时仅允许单模型）\n */\nexport async function resolveModelSelectionOrSingle(\n  userId: string,\n  model: string | null | undefined,\n  mediaType: ModelMediaType,\n): Promise<ModelSelection> {\n  const modelKey = readTrimmedString(model)\n  if (!modelKey) {\n    return await resolveSingleModelSelection(userId, mediaType)\n  }\n  return await resolveModelSelection(userId, modelKey, mediaType)\n}\n\n/**\n * Provider 配置\n *\n * 返回 provider 的完整连接信息（apiKey 已解密）。\n * baseUrl 和 apiMode 为可选——不同 provider 需求不同，由调用方自行校验。\n *\n * ⚠️ 调用方必须先通过 resolveModelSelection 校验模型归属，\n * 再使用 selection.provider 调用本函数，禁止直接传入未校验的 providerId。\n */\nexport interface ProviderConfig {\n  id: string\n  name: string\n  apiKey: string\n  baseUrl?: string\n  apiMode?: 'gemini-sdk' | 'openai-official'\n  gatewayRoute?: GatewayRouteType\n}\n\nexport async function getProviderConfig(userId: string, providerId: string): Promise<ProviderConfig> {\n  const { providers } = await readUserConfig(userId)\n  const provider = pickProviderStrict(providers, providerId)\n\n  if (!provider.apiKey) {\n    throw new Error(`PROVIDER_API_KEY_MISSING: ${provider.id}`)\n  }\n\n  return {\n    id: provider.id,\n    name: provider.name,\n    apiKey: decryptApiKey(provider.apiKey),\n    baseUrl: normalizeProviderBaseUrl(provider.id, provider.baseUrl),\n    apiMode: provider.apiMode,\n    gatewayRoute: provider.gatewayRoute,\n  }\n}\n\n/**\n * 获取用户自定义模型列表\n */\nexport async function getUserModels(userId: string): Promise<CustomModel[]> {\n  const { models } = await readUserConfig(userId)\n  return models\n}\n\n/**\n * 获取模型关联 provider\n */\nexport async function getModelProvider(userId: string, model: string): Promise<string | null> {\n  const { models } = await readUserConfig(userId)\n  const matched = findModelByKey(models, model)\n  return matched?.provider || null\n}\n\n/**\n * 获取指定类型模型列表\n */\nexport async function getModelsByType(userId: string, type: ModelMediaType): Promise<CustomModel[]> {\n  const models = await getUserModels(userId)\n  return models.filter((model) => model.type === type)\n}\n\n/**\n * 解析模型 ID（严格从 model_key 提取）\n */\nexport async function resolveModelId(userId: string, model: string): Promise<string> {\n  const selection = await resolveModelSelection(userId, model, 'llm')\n  return selection.modelId\n}\n\n/**\n * 获取模型价格\n */\nexport async function getModelPrice(userId: string, model: string): Promise<number> {\n  const { models } = await readUserConfig(userId)\n  const matched = findModelByKey(models, model)\n  if (!matched) {\n    throw new Error(`MODEL_NOT_FOUND: ${model}`)\n  }\n  return matched.price\n}\n\n/**\n * 根据音频模型键获取音频 API Key（未传模型时要求仅存在单一音频模型）\n */\nexport async function getAudioApiKey(userId: string, model?: string | null): Promise<string> {\n  const selection = await resolveModelSelectionOrSingle(userId, model, 'audio')\n  return (await getProviderConfig(userId, selection.provider)).apiKey\n}\n\n/**\n * 根据口型同步模型键获取 API Key（未传模型时要求仅存在单一 lipsync 模型）\n */\nexport async function getLipSyncApiKey(userId: string, model?: string | null): Promise<string> {\n  const selection = await resolveModelSelectionOrSingle(userId, model, 'lipsync')\n  return (await getProviderConfig(userId, selection.provider)).apiKey\n}\n\n/**\n * 检查用户是否有任意 API 配置\n */\nexport async function hasApiConfig(userId: string): Promise<boolean> {\n  const pref = await prisma.userPreference.findUnique({\n    where: { userId },\n    select: { customProviders: true },\n  })\n\n  const providers = parseCustomProviders(pref?.customProviders)\n  return providers.some((provider) => !!provider.apiKey)\n}\n"
  },
  {
    "path": "src/lib/api-errors.ts",
    "content": "import { createScopedLogger } from '@/lib/logging/core'\nimport { withLogContext } from '@/lib/logging/context'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { getErrorSpec, type UnifiedErrorCode } from '@/lib/errors/codes'\nimport { normalizeAnyError } from '@/lib/errors/normalize'\nimport { publishTaskEvent, publishTaskStreamEvent } from '@/lib/task/publisher'\nimport { TASK_EVENT_TYPE } from '@/lib/task/types'\nimport {\n  withInternalLLMStreamCallbacks,\n  type InternalLLMStreamCallbacks,\n  type InternalLLMStreamStepMeta,\n} from '@/lib/llm-observe/internal-stream-context'\nimport { getTaskFlowMeta } from '@/lib/llm-observe/stage-pipeline'\n\ntype RouteParamValue = string | string[] | undefined\ntype RouteParams = Record<string, RouteParamValue>\n\ntype ApiHandler<TParams extends RouteParams = RouteParams> = (\n  req: NextRequest,\n  ctx: { params: Promise<TParams> }\n) => Promise<Response | NextResponse>\n\nconst REQUEST_ID_SYMBOL = Symbol.for('waoowaoo.request_id')\nconst MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])\nconst GENERATION_OPERATION_PATTERNS = [\n  /\\/generate(?:-|\\/|$)/,\n  /\\/regenerate(?:-|\\/|$)/,\n  /\\/analyze(?:-|\\/|$)/,\n  /\\/tts(?:\\/|$)/,\n  /\\/lip-sync(?:\\/|$)/,\n  /\\/story-to-script(?:-|\\/|$)/,\n  /\\/script-to-storyboard(?:-|\\/|$)/,\n  /\\/screenplay-conversion(?:\\/|$)/,\n  /\\/voice-(?:analyze|design|generate)(?:\\/|$)/,\n  /\\/ai-(?:create|modify)-/,\n  /\\/modify-(?:asset|storyboard)-image(?:\\/|$)/,\n  /\\/asset-hub\\/(?:generate-image|modify-image|voice-design)(?:\\/|$)/,\n]\n\nfunction isGenerationOperationPath(pathname: string): boolean {\n  const normalizedPath = pathname.toLowerCase()\n  return GENERATION_OPERATION_PATTERNS.some((pattern) => pattern.test(normalizedPath))\n}\n\nfunction shouldAuditUserOperation(method: string, status: number, pathname: string): boolean {\n  if (!MUTATION_METHODS.has(method.toUpperCase()) || status >= 500) {\n    return false\n  }\n  return isGenerationOperationPath(pathname)\n}\n\nfunction createRequestId() {\n  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n    return crypto.randomUUID()\n  }\n  return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`\n}\n\nfunction parseTrueFlag(value: string | null): boolean {\n  if (!value) return false\n  const normalized = value.trim().toLowerCase()\n  return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'\n}\n\nfunction buildInternalLLMStreamCallbacks(req: NextRequest): InternalLLMStreamCallbacks | null {\n  if (!parseTrueFlag(req.headers.get('x-internal-task-stream'))) return null\n  const expectedToken = process.env.INTERNAL_TASK_TOKEN || ''\n  const token = req.headers.get('x-internal-task-token') || ''\n  if (expectedToken) {\n    if (token !== expectedToken) return null\n  } else if (process.env.NODE_ENV === 'production') {\n    return null\n  }\n\n  const taskId = req.headers.get('x-internal-task-id') || ''\n  const projectId = req.headers.get('x-internal-project-id') || ''\n  const userId = req.headers.get('x-internal-user-id') || ''\n  const taskType = req.headers.get('x-internal-task-type') || null\n  const targetType = req.headers.get('x-internal-target-type') || null\n  const targetId = req.headers.get('x-internal-target-id') || null\n  const episodeId = req.headers.get('x-internal-episode-id') || null\n  if (!taskId || !projectId || !userId) return null\n\n  const route = req.nextUrl.pathname\n  const streamRunId = `run:${taskId}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`\n  const flowMeta = getTaskFlowMeta(taskType)\n  const streamSeqByStepLane = new Map<string, number>()\n  let activeStepMeta: InternalLLMStreamStepMeta | undefined\n  let publishQueue: Promise<void> = Promise.resolve()\n  const MAX_CHUNK_CHARS = 128\n\n  const normalizeStepMeta = (step?: InternalLLMStreamStepMeta) => {\n    const id = typeof step?.id === 'string' && step.id.trim() ? step.id.trim() : null\n    const title = typeof step?.title === 'string' && step.title.trim() ? step.title.trim() : null\n    const index =\n      typeof step?.index === 'number' && Number.isFinite(step.index) ? Math.max(1, Math.floor(step.index)) : null\n    const total =\n      typeof step?.total === 'number' && Number.isFinite(step.total)\n        ? Math.max(index || 1, Math.floor(step.total))\n        : null\n    return {\n      id,\n      title,\n      index,\n      total,\n    }\n  }\n\n  const hasStepMeta = (step?: InternalLLMStreamStepMeta | null) => {\n    if (!step) return false\n    return !!(\n      (typeof step.id === 'string' && step.id.trim()) ||\n      (typeof step.title === 'string' && step.title.trim()) ||\n      (typeof step.index === 'number' && Number.isFinite(step.index)) ||\n      (typeof step.total === 'number' && Number.isFinite(step.total))\n    )\n  }\n\n  const mergeStepMeta = (\n    prev?: InternalLLMStreamStepMeta,\n    next?: InternalLLMStreamStepMeta,\n  ): InternalLLMStreamStepMeta | undefined => {\n    if (!hasStepMeta(prev) && !hasStepMeta(next)) return undefined\n    const merged: InternalLLMStreamStepMeta = {\n      id:\n        (typeof next?.id === 'string' && next.id.trim()) ||\n        (typeof prev?.id === 'string' && prev.id.trim()) ||\n        undefined,\n      title:\n        (typeof next?.title === 'string' && next.title.trim()) ||\n        (typeof prev?.title === 'string' && prev.title.trim()) ||\n        undefined,\n      index:\n        typeof next?.index === 'number' && Number.isFinite(next.index)\n          ? Math.max(1, Math.floor(next.index))\n          : typeof prev?.index === 'number' && Number.isFinite(prev.index)\n            ? Math.max(1, Math.floor(prev.index))\n            : undefined,\n      total:\n        typeof next?.total === 'number' && Number.isFinite(next.total)\n          ? Math.max(1, Math.floor(next.total))\n          : typeof prev?.total === 'number' && Number.isFinite(prev.total)\n            ? Math.max(1, Math.floor(prev.total))\n            : undefined,\n    }\n    if (typeof merged.index === 'number' && typeof merged.total === 'number') {\n      merged.total = Math.max(merged.index, merged.total)\n    }\n    return merged\n  }\n\n  const withDefaultFlowAndStep = (\n    payload: Record<string, unknown>,\n    step?: InternalLLMStreamStepMeta,\n  ) => {\n    const normalizedStep = normalizeStepMeta(step)\n    return {\n      ...payload,\n      ...(normalizedStep.id ? { stepId: normalizedStep.id } : {}),\n      ...(normalizedStep.title ? { stepTitle: normalizedStep.title } : {}),\n      ...(normalizedStep.index ? { stepIndex: normalizedStep.index } : {}),\n      ...(normalizedStep.total ? { stepTotal: normalizedStep.total } : {}),\n      flowId:\n        typeof payload.flowId === 'string' && payload.flowId\n          ? payload.flowId\n          : flowMeta.flowId,\n      flowStageIndex:\n        typeof payload.flowStageIndex === 'number' && Number.isFinite(payload.flowStageIndex)\n          ? payload.flowStageIndex\n          : flowMeta.flowStageIndex,\n      flowStageTotal:\n        typeof payload.flowStageTotal === 'number' && Number.isFinite(payload.flowStageTotal)\n          ? payload.flowStageTotal\n          : flowMeta.flowStageTotal,\n      flowStageTitle:\n        typeof payload.flowStageTitle === 'string' && payload.flowStageTitle\n          ? payload.flowStageTitle\n          : flowMeta.flowStageTitle,\n    }\n  }\n\n  const enqueueProgress = (\n    payload: Record<string, unknown>,\n    persist = false,\n    step?: InternalLLMStreamStepMeta,\n  ) => {\n    const nextPayload = withDefaultFlowAndStep(payload, step)\n    publishQueue = publishQueue\n      .catch(() => undefined)\n      .then(async () => {\n        await publishTaskEvent({\n          taskId,\n          projectId,\n          userId,\n          type: TASK_EVENT_TYPE.PROGRESS,\n          taskType,\n          targetType,\n          targetId,\n          episodeId,\n          payload: nextPayload,\n          persist,\n        })\n      })\n  }\n\n  const enqueueStreamChunk = (\n    payload: Record<string, unknown>,\n    step?: InternalLLMStreamStepMeta,\n  ) => {\n    const nextPayload = withDefaultFlowAndStep(payload, step)\n    publishQueue = publishQueue\n      .catch(() => undefined)\n      .then(async () => {\n        await publishTaskStreamEvent({\n          taskId,\n          projectId,\n          userId,\n          taskType,\n          targetType,\n          targetId,\n          episodeId,\n          payload: nextPayload,\n        })\n      })\n  }\n\n  const nextStreamSeq = (step: InternalLLMStreamStepMeta | undefined, lane: string) => {\n    const stepId = typeof step?.id === 'string' && step.id.trim() ? step.id.trim() : '__default'\n    const key = `${stepId}|${lane}`\n    const current = streamSeqByStepLane.get(key) || 1\n    streamSeqByStepLane.set(key, current + 1)\n    return current\n  }\n\n  const emitStreamChunk = (\n    kind: 'text' | 'reasoning',\n    delta: string,\n    lane?: string | null,\n    step?: InternalLLMStreamStepMeta,\n  ) => {\n    if (!delta) return\n    const laneKey = lane || (kind === 'reasoning' ? 'reasoning' : 'main')\n    for (let i = 0; i < delta.length; i += MAX_CHUNK_CHARS) {\n      const piece = delta.slice(i, i + MAX_CHUNK_CHARS)\n      if (!piece) continue\n      enqueueStreamChunk(\n        {\n          displayMode: 'detail',\n          done: false,\n          message: kind === 'reasoning' ? 'progress.runtime.llm.reasoning' : 'progress.runtime.llm.output',\n          stream: {\n            kind,\n            delta: piece,\n            seq: nextStreamSeq(step, laneKey),\n            lane: laneKey,\n          },\n          streamRunId,\n          meta: {\n            route,\n          },\n        },\n        step,\n      )\n    }\n  }\n\n  const stageLabelMap: Record<string, string> = {\n    submit: 'progress.runtime.stage.llmSubmit',\n    streaming: 'progress.runtime.stage.llmStreaming',\n    fallback: 'progress.runtime.stage.llmFallbackNonStream',\n    completed: 'progress.runtime.stage.llmCompleted',\n  }\n\n  return {\n    onStage(stage) {\n      activeStepMeta = mergeStepMeta(activeStepMeta, stage.step)\n      enqueueProgress({\n        displayMode: 'detail',\n        stage: `llm_${stage.stage}`,\n        stageLabel: stageLabelMap[stage.stage] || stage.stage,\n        message: stage.stage === 'completed' ? 'progress.runtime.llm.completed' : 'progress.runtime.llm.processing',\n        streamRunId,\n        meta: {\n          route,\n          provider: stage.provider || null,\n        },\n      }, false, stage.step || activeStepMeta)\n    },\n    onChunk(chunk) {\n      if (typeof chunk.delta !== 'string' || !chunk.delta) return\n      activeStepMeta = mergeStepMeta(activeStepMeta, chunk.step)\n      if (chunk.kind === 'reasoning') {\n        emitStreamChunk('reasoning', chunk.delta, 'reasoning', chunk.step || activeStepMeta)\n        return\n      }\n      emitStreamChunk('text', chunk.delta, 'main', chunk.step || activeStepMeta)\n    },\n    onComplete() {\n      enqueueProgress({\n        displayMode: 'detail',\n        done: true,\n        stage: 'llm_completed',\n        stageLabel: stageLabelMap.completed,\n        message: 'progress.runtime.llm.completed',\n        streamRunId,\n        meta: {\n          route,\n        },\n      }, false, activeStepMeta)\n    },\n    onError(error) {\n      enqueueProgress({\n        displayMode: 'detail',\n        stage: 'llm_error',\n        stageLabel: 'progress.runtime.stage.llmFailed',\n        message: error instanceof Error ? error.message : String(error),\n        streamRunId,\n        meta: {\n          route,\n        },\n      }, false, activeStepMeta)\n    },\n    async flush() {\n      await publishQueue.catch(() => undefined)\n    },\n  }\n}\n\nfunction setRequestId(req: NextRequest, requestId: string) {\n  ;(req as NextRequest & { [REQUEST_ID_SYMBOL]?: string })[REQUEST_ID_SYMBOL] = requestId\n}\n\nexport function getRequestId(req: NextRequest): string | undefined {\n  const fromSymbol = (req as NextRequest & { [REQUEST_ID_SYMBOL]?: string })[REQUEST_ID_SYMBOL]\n  if (typeof fromSymbol === 'string' && fromSymbol) return fromSymbol\n  const fromHeader = req.headers.get('x-request-id')\n  if (typeof fromHeader === 'string' && fromHeader) return fromHeader\n  return undefined\n}\n\nexport function getIdempotencyKey(req: NextRequest): string | undefined {\n  const key =\n    req.headers.get('idempotency-key')\n    || req.headers.get('x-idempotency-key')\n  if (typeof key !== 'string') return undefined\n  const trimmed = key.trim()\n  return trimmed || undefined\n}\n\nasync function extractRouteContext<TParams extends RouteParams>(\n  req: NextRequest,\n  ctx: { params: Promise<TParams> },\n) {\n  let params: Record<string, unknown> = {}\n  try {\n    params = (await ctx.params) || {}\n  } catch {}\n\n  const projectId =\n    (typeof params.projectId === 'string' && params.projectId) ||\n    req.nextUrl.searchParams.get('projectId') ||\n    undefined\n  const taskId =\n    (typeof params.taskId === 'string' && params.taskId) ||\n    req.nextUrl.searchParams.get('taskId') ||\n    undefined\n\n  return { projectId, taskId }\n}\n\nexport const API_ERROR_CODES = {\n  UNAUTHORIZED: { status: getErrorSpec('UNAUTHORIZED').httpStatus },\n  FORBIDDEN: { status: getErrorSpec('FORBIDDEN').httpStatus },\n  NOT_FOUND: { status: getErrorSpec('NOT_FOUND').httpStatus },\n  INSUFFICIENT_BALANCE: { status: getErrorSpec('INSUFFICIENT_BALANCE').httpStatus },\n  RATE_LIMIT: { status: getErrorSpec('RATE_LIMIT').httpStatus },\n  MODEL_NOT_OPEN: { status: getErrorSpec('MODEL_NOT_OPEN').httpStatus },\n  QUOTA_EXCEEDED: { status: getErrorSpec('QUOTA_EXCEEDED').httpStatus },\n  GENERATION_FAILED: { status: getErrorSpec('GENERATION_FAILED').httpStatus },\n  GENERATION_TIMEOUT: { status: getErrorSpec('GENERATION_TIMEOUT').httpStatus },\n  SENSITIVE_CONTENT: { status: getErrorSpec('SENSITIVE_CONTENT').httpStatus },\n  INVALID_PARAMS: { status: getErrorSpec('INVALID_PARAMS').httpStatus },\n  MISSING_CONFIG: { status: getErrorSpec('MISSING_CONFIG').httpStatus },\n  TASK_NOT_READY: { status: getErrorSpec('TASK_NOT_READY').httpStatus },\n  NO_RESULT: { status: getErrorSpec('NO_RESULT').httpStatus },\n  EXTERNAL_ERROR: { status: getErrorSpec('EXTERNAL_ERROR').httpStatus },\n  CONFLICT: { status: getErrorSpec('CONFLICT').httpStatus },\n  INTERNAL_ERROR: { status: getErrorSpec('INTERNAL_ERROR').httpStatus },\n  NETWORK_ERROR: { status: getErrorSpec('NETWORK_ERROR').httpStatus },\n  EMPTY_RESPONSE: { status: getErrorSpec('EMPTY_RESPONSE').httpStatus },\n} as const\n\nexport type ApiErrorCode = UnifiedErrorCode\n\nexport class ApiError extends Error {\n  code: ApiErrorCode\n  status: number\n  details?: Record<string, unknown>\n  retryable: boolean\n  category: string\n  userMessageKey: string\n\n  constructor(code: ApiErrorCode, details?: Record<string, unknown>) {\n    const spec = getErrorSpec(code)\n    const message =\n      typeof details?.message === 'string' && details.message.trim()\n        ? details.message.trim()\n        : spec.defaultMessage\n\n    super(message)\n    this.name = 'ApiError'\n    this.code = code\n    this.status = spec.httpStatus\n    this.details = details\n    this.retryable = spec.retryable\n    this.category = spec.category\n    this.userMessageKey = spec.userMessageKey\n  }\n}\n\nexport function normalizeError(error: unknown): ApiError {\n  if (error instanceof ApiError) {\n    return error\n  }\n\n  const normalized = normalizeAnyError(error, { context: 'api' })\n  const details = {\n    ...(normalized.details || {}),\n    retryable: normalized.retryable,\n    category: normalized.category,\n    userMessageKey: normalized.userMessageKey,\n    provider: normalized.provider || undefined,\n    message: normalized.message,\n  }\n\n  return new ApiError(normalized.code, details)\n}\n\nexport function apiHandler<TParams extends RouteParams>(handler: ApiHandler<TParams>): ApiHandler<TParams> {\n  return async (req, ctx) => {\n    const startedAt = Date.now()\n    const requestId = getRequestId(req) || createRequestId()\n    setRequestId(req, requestId)\n    const routeContext = await extractRouteContext(req, ctx)\n    const logger = createScopedLogger({\n      module: 'api',\n      requestId,\n      projectId: routeContext.projectId,\n      taskId: routeContext.taskId,\n    })\n\n    return await withLogContext(\n      {\n        requestId,\n        projectId: routeContext.projectId,\n        taskId: routeContext.taskId,\n        module: 'api',\n        action: `${req.method} ${req.nextUrl.pathname}`,\n      },\n      async () => {\n        logger.debug({\n          action: 'api.request.start',\n          message: 'api request start',\n          details: {\n            method: req.method,\n            path: req.nextUrl.pathname,\n          },\n        })\n        const streamCallbacks = buildInternalLLMStreamCallbacks(req)\n\n        try {\n          const response = await withInternalLLMStreamCallbacks(streamCallbacks, async () => await handler(req, ctx))\n          await streamCallbacks?.flush?.()\n          response.headers.set('x-request-id', requestId)\n\n          logger.debug({\n            action: 'api.request.finish',\n            message: 'api request finished',\n            durationMs: Date.now() - startedAt,\n            details: {\n              method: req.method,\n              path: req.nextUrl.pathname,\n              status: response.status,\n            },\n          })\n          if (shouldAuditUserOperation(req.method, response.status, req.nextUrl.pathname)) {\n            logger.event({\n              level: 'INFO',\n              audit: true,\n              module: 'user.operation',\n              action: 'user.operation',\n              message: 'user operation completed',\n              durationMs: Date.now() - startedAt,\n              details: {\n                method: req.method,\n                path: req.nextUrl.pathname,\n                status: response.status,\n              },\n            })\n          }\n\n          return response\n        } catch (error: unknown) {\n          await streamCallbacks?.flush?.()\n          const apiError = normalizeError(error)\n          const errorType = error instanceof Error ? error.constructor.name : typeof error\n          logger.error({\n            action: 'api.request.error',\n            message: apiError.message,\n            errorCode: apiError.code,\n            retryable: apiError.retryable,\n            durationMs: Date.now() - startedAt,\n            details: {\n              method: req.method,\n              path: req.nextUrl.pathname,\n              errorType,\n            },\n            error:\n              error instanceof Error\n                ? {\n                    name: error.name,\n                    message: error.message,\n                    stack: error.stack,\n                    code: typeof (error as Error & { code?: unknown }).code === 'string'\n                      ? ((error as Error & { code?: string }).code as string)\n                      : undefined,\n                  }\n                : undefined,\n          })\n\n          const rawDetails = (apiError.details || {}) as Record<string, unknown>\n\n          const response = NextResponse.json(\n            {\n              success: false,\n              requestId,\n              error: {\n                code: apiError.code,\n                message: apiError.message,\n                retryable: apiError.retryable,\n                category: apiError.category,\n                userMessageKey: apiError.userMessageKey,\n                details: {\n                  ...rawDetails,\n                  requestId,\n                },\n              },\n              // Backward-compatible flattened fields.\n              code: apiError.code,\n              message: apiError.message,\n              ...rawDetails,\n            },\n            { status: apiError.status }\n          )\n          response.headers.set('x-request-id', requestId)\n          return response\n        }\n      },\n    )\n  }\n}\n\nexport function throwApiError(code: ApiErrorCode, details?: Record<string, unknown>): never {\n  throw new ApiError(code, details)\n}\n\nexport function isApiError(error: unknown): error is ApiError {\n  return error instanceof ApiError\n}\n"
  },
  {
    "path": "src/lib/api-fetch.ts",
    "content": "const LOCALE_PATH_PATTERN = /^\\/(zh|en)(\\/|$)/\n\nfunction resolveLocaleFromPath(pathname: string): string {\n  const match = pathname.match(LOCALE_PATH_PATTERN)\n  return match?.[1] ?? 'zh'\n}\n\nexport function getPageLocale(): string {\n  if (typeof window === 'undefined') return 'zh'\n  return resolveLocaleFromPath(window.location.pathname)\n}\n\nfunction resolveRequestPathname(input: RequestInfo | URL): string {\n  if (typeof input === 'string') {\n    if (input.startsWith('/')) return input\n    try {\n      return new URL(input).pathname\n    } catch {\n      return ''\n    }\n  }\n\n  if (input instanceof URL) {\n    return input.pathname\n  }\n\n  try {\n    return new URL(input.url).pathname\n  } catch {\n    return ''\n  }\n}\n\nfunction shouldInjectLocaleHeader(input: RequestInfo | URL): boolean {\n  const pathname = resolveRequestPathname(input)\n  return pathname === '/api' || pathname.startsWith('/api/')\n}\n\nexport function mergeLocaleHeader(init?: RequestInit): RequestInit {\n  const headers = new Headers(init?.headers)\n  if (!headers.has('Accept-Language')) {\n    headers.set('Accept-Language', getPageLocale())\n  }\n  return { ...init, headers }\n}\n\nexport async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {\n  if (!shouldInjectLocaleHeader(input)) {\n    return fetch(input, init)\n  }\n  return fetch(input, mergeLocaleHeader(init))\n}\n"
  },
  {
    "path": "src/lib/app-meta.ts",
    "content": "import packageJson from '../../package.json'\n\nconst GITHUB_REPOSITORY_VALUE = 'saturndec/waoowaoo'\n\nconst packageVersion = packageJson.version\nif (typeof packageVersion !== 'string' || packageVersion.trim().length === 0) {\n  throw new Error('Invalid package.json version')\n}\n\nexport const APP_VERSION = packageVersion.trim()\n\nexport const GITHUB_REPOSITORY = GITHUB_REPOSITORY_VALUE\n\nif (!GITHUB_REPOSITORY) {\n  throw new Error('Missing GitHub repository configuration')\n}\n"
  },
  {
    "path": "src/lib/ark-api.ts",
    "content": "import { getInternalBaseUrl } from '@/lib/env'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\n/**\n * 火山引擎 API 统一调用工具\n * \n * 解决问题：Vercel（海外）→ 火山引擎（北京）跨境网络超时\n * \n * 功能：\n * - 60秒超时配置（Vercel Pro 函数限制）\n * - 自动重试机制（最多3次，指数退避）\n * - 详细的错误日志\n */\n\nconst ARK_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3'\n\n// 超时配置\nconst DEFAULT_TIMEOUT_MS = 60 * 1000  // 60秒\nconst MAX_RETRIES = 3\nconst RETRY_DELAY_BASE_MS = 2000  // 2秒起始延迟\n\nfunction normalizeError(error: unknown): {\n    name?: string\n    message: string\n    cause?: string\n    status?: number\n} {\n    if (error instanceof Error) {\n        return {\n            name: error.name,\n            message: error.message,\n            cause: error.cause ? String(error.cause) : undefined,\n        }\n    }\n    if (typeof error === 'object' && error !== null) {\n        const e = error as {\n            name?: unknown\n            message?: unknown\n            cause?: unknown\n            status?: unknown\n        }\n        return {\n            name: typeof e.name === 'string' ? e.name : undefined,\n            message: typeof e.message === 'string' ? e.message : 'Unknown error',\n            cause: e.cause ? String(e.cause) : undefined,\n            status: typeof e.status === 'number' ? e.status : undefined,\n        }\n    }\n    return { message: 'Unknown error' }\n}\n\ninterface ArkImageGenerationRequest {\n    model: string\n    prompt: string\n    response_format?: 'url' | 'b64_json'\n    size?: string  // 支持 '1K' | '2K' | '4K' 或具体像素值如 '2560x1440'\n    aspect_ratio?: string  // 宽高比如 '3:2', '16:9', '1:1'\n    watermark?: boolean\n    image?: string[]  // 图生图时的参考图片\n    sequential_image_generation?: 'enabled' | 'disabled'\n    stream?: boolean\n}\n\ninterface ArkImageGenerationResponse {\n    data: Array<{\n        url?: string\n        b64_json?: string\n    }>\n}\n\ninterface ArkVideoTaskRequest {\n    model: string\n    content: Array<{\n        type: 'image_url' | 'text' | 'draft_task'\n        image_url?: { url: string }\n        text?: string\n        role?: 'first_frame' | 'last_frame' | 'reference_image'\n        draft_task?: { id: string }\n    }>\n    resolution?: '480p' | '720p' | '1080p'\n    ratio?: string\n    duration?: number\n    frames?: number\n    seed?: number\n    camera_fixed?: boolean\n    watermark?: boolean\n    return_last_frame?: boolean\n    service_tier?: 'default' | 'flex'\n    execution_expires_after?: number\n    generate_audio?: boolean\n    draft?: boolean\n}\n\ninterface ArkVideoTaskResponse {\n    id: string\n    model: string\n    status: 'processing' | 'succeeded' | 'failed'\n    content?: Array<{\n        type: 'video_url'\n        video_url: { url: string }\n    }>\n    error?: {\n        code: string\n        message: string\n    }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n    return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n    return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction isInteger(value: unknown): value is number {\n    return typeof value === 'number' && Number.isInteger(value)\n}\n\nfunction validateArkVideoTaskRequest(request: ArkVideoTaskRequest) {\n    const allowedTopLevelKeys = new Set([\n        'model',\n        'content',\n        'resolution',\n        'ratio',\n        'duration',\n        'frames',\n        'seed',\n        'camera_fixed',\n        'watermark',\n        'return_last_frame',\n        'service_tier',\n        'execution_expires_after',\n        'generate_audio',\n        'draft',\n    ])\n    for (const key of Object.keys(request)) {\n        if (!allowedTopLevelKeys.has(key)) {\n            throw new Error(`ARK_VIDEO_REQUEST_FIELD_UNSUPPORTED: ${key}`)\n        }\n    }\n\n    if (!isNonEmptyString(request.model)) {\n        throw new Error('ARK_VIDEO_REQUEST_INVALID: model is required')\n    }\n    if (!Array.isArray(request.content) || request.content.length === 0) {\n        throw new Error('ARK_VIDEO_REQUEST_INVALID: content must be a non-empty array')\n    }\n\n    const allowedRatios = new Set(['16:9', '4:3', '1:1', '3:4', '9:16', '21:9', 'adaptive'])\n    if (request.ratio !== undefined && !allowedRatios.has(request.ratio)) {\n        throw new Error(`ARK_VIDEO_REQUEST_INVALID: ratio=${request.ratio}`)\n    }\n\n    if (request.resolution !== undefined && request.resolution !== '480p' && request.resolution !== '720p' && request.resolution !== '1080p') {\n        throw new Error(`ARK_VIDEO_REQUEST_INVALID: resolution=${request.resolution}`)\n    }\n\n    if (request.duration !== undefined) {\n        if (!isInteger(request.duration)) {\n            throw new Error('ARK_VIDEO_REQUEST_INVALID: duration must be integer')\n        }\n        if (request.duration !== -1 && (request.duration < 2 || request.duration > 12)) {\n            throw new Error(`ARK_VIDEO_REQUEST_INVALID: duration=${request.duration}`)\n        }\n    }\n\n    if (request.frames !== undefined) {\n        if (!isInteger(request.frames)) {\n            throw new Error('ARK_VIDEO_REQUEST_INVALID: frames must be integer')\n        }\n        if (request.frames < 29 || request.frames > 289 || (request.frames - 25) % 4 !== 0) {\n            throw new Error(`ARK_VIDEO_REQUEST_INVALID: frames=${request.frames}`)\n        }\n    }\n\n    if (request.seed !== undefined) {\n        if (!isInteger(request.seed)) {\n            throw new Error('ARK_VIDEO_REQUEST_INVALID: seed must be integer')\n        }\n        if (request.seed < -1 || request.seed > 4294967295) {\n            throw new Error(`ARK_VIDEO_REQUEST_INVALID: seed=${request.seed}`)\n        }\n    }\n\n    if (request.execution_expires_after !== undefined) {\n        if (!isInteger(request.execution_expires_after)) {\n            throw new Error('ARK_VIDEO_REQUEST_INVALID: execution_expires_after must be integer')\n        }\n        if (request.execution_expires_after < 3600 || request.execution_expires_after > 259200) {\n            throw new Error(`ARK_VIDEO_REQUEST_INVALID: execution_expires_after=${request.execution_expires_after}`)\n        }\n    }\n\n    if (\n        request.service_tier !== undefined\n        && request.service_tier !== 'default'\n        && request.service_tier !== 'flex'\n    ) {\n        throw new Error(`ARK_VIDEO_REQUEST_INVALID: service_tier=${String(request.service_tier)}`)\n    }\n\n    if (request.draft === true) {\n        if (request.resolution !== undefined && request.resolution !== '480p') {\n            throw new Error('ARK_VIDEO_REQUEST_INVALID: draft only supports 480p')\n        }\n        if (request.return_last_frame === true) {\n            throw new Error('ARK_VIDEO_REQUEST_INVALID: return_last_frame is not supported when draft=true')\n        }\n        if (request.service_tier === 'flex') {\n            throw new Error('ARK_VIDEO_REQUEST_INVALID: service_tier=flex is not supported when draft=true')\n        }\n    }\n\n    for (let index = 0; index < request.content.length; index += 1) {\n        const item = request.content[index]\n        const path = `content[${index}]`\n        if (!isRecord(item)) {\n            throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path} must be object`)\n        }\n\n        if (item.type === 'text') {\n            if (!isNonEmptyString(item.text)) {\n                throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.text is required`)\n            }\n            continue\n        }\n\n        if (item.type === 'image_url') {\n            if (!isRecord(item.image_url) || !isNonEmptyString(item.image_url.url)) {\n                throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.image_url.url is required`)\n            }\n            if (\n                item.role !== undefined\n                && item.role !== 'first_frame'\n                && item.role !== 'last_frame'\n                && item.role !== 'reference_image'\n            ) {\n                throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.role=${String(item.role)}`)\n            }\n            continue\n        }\n\n        if (item.type === 'draft_task') {\n            if (!isRecord(item.draft_task) || !isNonEmptyString(item.draft_task.id)) {\n                throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.draft_task.id is required`)\n            }\n            continue\n        }\n\n        throw new Error(`ARK_VIDEO_REQUEST_INVALID: ${path}.type=${String((item as { type?: unknown }).type)}`)\n    }\n}\n\n/**\n * 带超时的 fetch 封装\n */\nasync function fetchWithTimeout(\n    url: string,\n    options: RequestInit,\n    timeoutMs: number = DEFAULT_TIMEOUT_MS\n): Promise<Response> {\n    const controller = new AbortController()\n    const timeoutId = setTimeout(() => controller.abort(), timeoutMs)\n\n    // 🔧 本地模式修复：相对路径需要补全完整 URL\n    let fullUrl = url\n    if (url.startsWith('/')) {\n        // 服务端 fetch 需要完整 URL，使用 localhost:3000 作为基础地址\n        const baseUrl = getInternalBaseUrl()\n        fullUrl = `${baseUrl}${url}`\n    }\n\n    try {\n        const response = await fetch(fullUrl, {\n            ...options,\n            signal: controller.signal\n        })\n        return response\n    } finally {\n        clearTimeout(timeoutId)\n    }\n}\n\n/**\n * 带重试的 fetch 封装\n */\nasync function fetchWithRetry(\n    url: string,\n    options: RequestInit,\n    maxRetries: number = MAX_RETRIES,\n    timeoutMs: number = DEFAULT_TIMEOUT_MS,\n    logPrefix: string = '[Ark API]'\n): Promise<Response> {\n    let lastError: Error | null = null\n\n    for (let attempt = 1; attempt <= maxRetries; attempt++) {\n        try {\n            _ulogInfo(`${logPrefix} 第 ${attempt}/${maxRetries} 次尝试请求`)\n\n            const response = await fetchWithTimeout(url, options, timeoutMs)\n\n            // 请求成功\n            if (response.ok) {\n                if (attempt > 1) {\n                    _ulogInfo(`${logPrefix} 第 ${attempt} 次尝试成功`)\n                }\n                return response\n            }\n\n            // HTTP 错误，但不是网络错误，可能是业务错误\n            const errorText = await response.text()\n            _ulogError(`${logPrefix} HTTP ${response.status}: ${errorText}`)\n\n            // 对于某些错误不重试（如 400 参数错误、403 权限错误）\n            if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) {\n                // 创建一个可以返回原始文本的 Response\n                return new Response(errorText, {\n                    status: response.status,\n                    statusText: response.statusText,\n                    headers: response.headers\n                })\n            }\n\n            lastError = new Error(`HTTP ${response.status}: ${errorText}`)\n        } catch (error: unknown) {\n            const normalized = normalizeError(error)\n            lastError = error instanceof Error ? error : new Error(normalized.message)\n\n            // 详细记录错误信息\n            const errorDetails = {\n                attempt,\n                maxRetries,\n                errorName: normalized.name,\n                errorMessage: normalized.message,\n                errorCause: normalized.cause,\n                isAbortError: normalized.name === 'AbortError',\n                isTimeoutError: normalized.name === 'AbortError' || normalized.message.includes('timeout'),\n                isNetworkError: normalized.message.includes('fetch failed') || normalized.name === 'TypeError'\n            }\n\n            _ulogError(`${logPrefix} 第 ${attempt}/${maxRetries} 次尝试失败:`, JSON.stringify(errorDetails, null, 2))\n        }\n\n        // 如果不是最后一次尝试，等待后重试\n        if (attempt < maxRetries) {\n            const delayMs = RETRY_DELAY_BASE_MS * Math.pow(2, attempt - 1)  // 指数退避：2s, 4s, 8s\n            _ulogInfo(`${logPrefix} 等待 ${delayMs / 1000} 秒后重试...`)\n            await new Promise(resolve => setTimeout(resolve, delayMs))\n        }\n    }\n\n    // 所有重试都失败\n    throw lastError || new Error(`${logPrefix} 所有 ${maxRetries} 次重试都失败`)\n}\n\n/**\n * 火山引擎图片生成 API\n */\nexport async function arkImageGeneration(\n    request: ArkImageGenerationRequest,\n    options?: {\n        apiKey: string  // 必须传入 API Key\n        timeoutMs?: number\n        maxRetries?: number\n        logPrefix?: string\n    }\n): Promise<ArkImageGenerationResponse> {\n    if (!options?.apiKey) {\n        throw new Error('请配置火山引擎 API Key')\n    }\n\n    const {\n        apiKey,\n        timeoutMs = DEFAULT_TIMEOUT_MS,\n        maxRetries = MAX_RETRIES,\n        logPrefix = '[Ark Image]'\n    } = options\n\n    const url = `${ARK_BASE_URL}/images/generations`\n\n    _ulogInfo(`${logPrefix} 开始图片生成请求, 模型: ${request.model}`)\n    _ulogInfo(`${logPrefix} 请求参数:`, JSON.stringify({\n        model: request.model,\n        size: request.size,\n        aspect_ratio: request.aspect_ratio,\n        watermark: request.watermark,\n        imageCount: request.image?.length || 0,\n        promptLength: request.prompt?.length || 0\n    }))\n\n    const response = await fetchWithRetry(\n        url,\n        {\n            method: 'POST',\n            headers: {\n                'Content-Type': 'application/json',\n                'Authorization': `Bearer ${apiKey}`\n            },\n            body: JSON.stringify(request)\n        },\n        maxRetries,\n        timeoutMs,\n        logPrefix\n    )\n\n    if (!response.ok) {\n        const errorText = await response.text()\n        throw new Error(`${logPrefix} 图片生成失败: ${response.status} - ${errorText}`)\n    }\n\n    const data = await response.json()\n    _ulogInfo(`${logPrefix} 图片生成成功`)\n    return data\n}\n\n/**\n * 火山引擎视频任务创建 API\n */\nexport async function arkCreateVideoTask(\n    request: ArkVideoTaskRequest,\n    options: {\n        apiKey: string  // 必须传入 API Key\n        timeoutMs?: number\n        maxRetries?: number\n        logPrefix?: string\n    }\n): Promise<{ id: string; [key: string]: unknown }> {\n    if (!options.apiKey) {\n        throw new Error('请配置火山引擎 API Key')\n    }\n    validateArkVideoTaskRequest(request)\n\n    const {\n        apiKey,\n        timeoutMs = DEFAULT_TIMEOUT_MS,\n        maxRetries = MAX_RETRIES,\n        logPrefix = '[Ark Video]'\n    } = options\n\n    const url = `${ARK_BASE_URL}/contents/generations/tasks`\n\n    _ulogInfo(`${logPrefix} 创建视频任务, 模型: ${request.model}`)\n\n    const response = await fetchWithRetry(\n        url,\n        {\n            method: 'POST',\n            headers: {\n                'Content-Type': 'application/json',\n                'Authorization': `Bearer ${apiKey}`\n            },\n            body: JSON.stringify(request)\n        },\n        maxRetries,\n        timeoutMs,\n        logPrefix\n    )\n\n    if (!response.ok) {\n        const errorText = await response.text()\n        throw new Error(`${logPrefix} 创建视频任务失败: ${response.status} - ${errorText}`)\n    }\n\n    const data = await response.json()\n    const taskId = data.id\n    _ulogInfo(`${logPrefix} 视频任务创建成功, taskId: ${taskId}`)\n    return { id: taskId, ...data }\n}\n\n/**\n * 火山引擎视频任务状态查询 API\n */\nexport async function arkQueryVideoTask(\n    taskId: string,\n    options: {\n        apiKey: string  // 必须传入 API Key\n        timeoutMs?: number\n        maxRetries?: number\n        logPrefix?: string\n    }\n): Promise<ArkVideoTaskResponse> {\n    if (!options.apiKey) {\n        throw new Error('请配置火山引擎 API Key')\n    }\n\n    const {\n        apiKey,\n        timeoutMs = DEFAULT_TIMEOUT_MS,\n        maxRetries = MAX_RETRIES,\n        logPrefix = '[Ark Video]'\n    } = options\n\n    const url = `${ARK_BASE_URL}/contents/generations/tasks/${taskId}`\n\n    const response = await fetchWithRetry(\n        url,\n        {\n            method: 'GET',\n            headers: {\n                'Authorization': `Bearer ${apiKey}`\n            }\n        },\n        maxRetries,\n        timeoutMs,\n        logPrefix\n    )\n\n    if (!response.ok) {\n        const errorText = await response.text()\n        throw new Error(`${logPrefix} 查询视频任务失败: ${response.status} - ${errorText}`)\n    }\n\n    return await response.json()\n}\n\n/**\n * 通用的带超时和重试的 fetch 函数\n * 用于下载图片、视频等\n */\nexport async function fetchWithTimeoutAndRetry(\n    url: string,\n    options?: RequestInit & {\n        timeoutMs?: number\n        maxRetries?: number\n        logPrefix?: string\n    }\n): Promise<Response> {\n    const {\n        timeoutMs = DEFAULT_TIMEOUT_MS,\n        maxRetries = MAX_RETRIES,\n        logPrefix = '[Fetch]',\n        ...fetchOptions\n    } = options || {}\n\n    return fetchWithRetry(url, fetchOptions, maxRetries, timeoutMs, logPrefix)\n}\n\n// 导出常量，供其他模块参考\nexport const ARK_API_TIMEOUT_MS = DEFAULT_TIMEOUT_MS\nexport const ARK_API_MAX_RETRIES = MAX_RETRIES\n"
  },
  {
    "path": "src/lib/ark-llm.ts",
    "content": "/**\n * 火山引擎 Ark LLM (Responses API) 封装\n */\n\nexport interface ArkResponsesOptions {\n    apiKey: string\n    model: string\n    input: unknown[]\n    thinking?: {\n        type: 'enabled' | 'disabled'\n    }\n}\n\nexport interface ArkResponsesResult {\n    text: string\n    reasoning: string\n    usage: {\n        promptTokens: number\n        completionTokens: number\n    }\n    raw: unknown\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | null {\n    return value && typeof value === 'object' ? (value as Record<string, unknown>) : null\n}\n\nfunction collectText(node: unknown, acc: string[]) {\n    if (!node) return\n    if (typeof node === 'string') {\n        acc.push(node)\n        return\n    }\n    if (Array.isArray(node)) {\n        node.forEach((item) => collectText(item, acc))\n        return\n    }\n    const obj = asRecord(node)\n    if (!obj) return\n\n    const type = typeof obj.type === 'string' ? obj.type : undefined\n    if (type === 'reasoning' || type === 'function_call') return\n    if (typeof obj.output_text === 'string') acc.push(obj.output_text)\n    if (typeof obj.text === 'string' && type !== 'reasoning') acc.push(obj.text)\n    if (typeof obj.content === 'string') acc.push(obj.content)\n    if (obj.content && typeof obj.content !== 'string') collectText(obj.content, acc)\n    if (typeof obj.message === 'string') acc.push(obj.message)\n    if (obj.message && typeof obj.message !== 'string') collectText(obj.message, acc)\n}\n\nfunction collectReasoning(node: unknown, acc: string[]) {\n    if (!node) return\n    if (typeof node === 'string') return\n    if (Array.isArray(node)) {\n        node.forEach((item) => collectReasoning(item, acc))\n        return\n    }\n    const obj = asRecord(node)\n    if (!obj) return\n\n    const type = typeof obj.type === 'string' ? obj.type : undefined\n    const isReasoning = type === 'reasoning' || type === 'reasoning_content'\n    if (isReasoning) {\n        if (typeof obj.text === 'string') acc.push(obj.text)\n        if (typeof obj.content === 'string') acc.push(obj.content)\n        if (obj.content && typeof obj.content !== 'string') collectReasoning(obj.content, acc)\n    }\n\n    if (obj.reasoning) collectReasoning(obj.reasoning, acc)\n    if (obj.reasoning_content) collectReasoning(obj.reasoning_content, acc)\n    if (obj.thinking) collectReasoning(obj.thinking, acc)\n}\n\nfunction extractArkText(data: unknown): string {\n    const obj = asRecord(data)\n    if (!obj) return ''\n    if (typeof obj.output_text === 'string') return obj.output_text\n    const output = obj.output ?? obj.outputs ?? []\n    const acc: string[] = []\n    collectText(output, acc)\n    return acc.filter(Boolean).join('')\n}\n\nfunction extractArkReasoning(data: unknown): string {\n    const obj = asRecord(data)\n    if (!obj) return ''\n    const output = obj.output ?? obj.outputs ?? []\n    const acc: string[] = []\n    collectReasoning(output, acc)\n    return acc.filter(Boolean).join('')\n}\n\nfunction extractArkUsage(data: unknown): { promptTokens: number; completionTokens: number } {\n    const usage = asRecord(asRecord(data)?.usage) || {}\n    const toNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)\n    const promptTokens = toNumber(usage.input_tokens ?? usage.prompt_tokens ?? usage.promptTokens)\n    const completionTokens = toNumber(usage.output_tokens ?? usage.completion_tokens ?? usage.completionTokens)\n    return {\n        promptTokens,\n        completionTokens\n    }\n}\n\nexport async function arkResponsesCompletion(options: ArkResponsesOptions): Promise<ArkResponsesResult> {\n    if (!options.apiKey) {\n        throw new Error('请配置火山引擎 API Key')\n    }\n\n    const thinking = options.thinking\n        ? {\n            type: options.thinking.type,\n        }\n        : undefined\n\n    const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/responses', {\n        method: 'POST',\n        headers: {\n            'Content-Type': 'application/json',\n            'Authorization': `Bearer ${options.apiKey}`\n        },\n        body: JSON.stringify({\n            model: options.model,\n            input: options.input,\n            ...(thinking && { thinking })\n        })\n    })\n\n    if (!response.ok) {\n        const errorText = await response.text()\n        throw new Error(`Ark Responses 调用失败: ${response.status} - ${errorText}`)\n    }\n\n    const data = await response.json()\n    return {\n        text: extractArkText(data),\n        reasoning: extractArkReasoning(data),\n        usage: extractArkUsage(data),\n        raw: data\n    }\n}\n\n// ============================================================\n// 消息格式转换：OpenAI messages → Responses API input\n// ============================================================\n\ntype ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string }\n\ninterface ArkResponsesInputItem {\n    role: string\n    content: Array<{ type: string; text: string }>\n}\n\n/**\n * 将 OpenAI 格式的 messages 转为 Responses API 的 input 格式。\n * system 消息合并到第一条 user 消息前面（Responses API 用 instructions 传递系统提示，\n * 这里简化为注入到首条 user 消息）。\n */\nexport function convertChatMessagesToArkInput(messages: ChatMessage[]): ArkResponsesInputItem[] {\n    const systemParts: string[] = []\n    const input: ArkResponsesInputItem[] = []\n\n    for (const msg of messages) {\n        if (msg.role === 'system') {\n            systemParts.push(msg.content)\n            continue\n        }\n        const role = msg.role === 'assistant' ? 'assistant' : 'user'\n        const contentItems: Array<{ type: string; text: string }> = []\n\n        // 把 system 消息注入到首条 user 消息前\n        if (role === 'user' && systemParts.length > 0 && input.length === 0) {\n            contentItems.push({ type: 'input_text', text: systemParts.join('\\n') })\n            systemParts.length = 0\n        }\n        contentItems.push({\n            type: role === 'assistant' ? 'output_text' : 'input_text',\n            text: msg.content,\n        })\n        input.push({ role, content: contentItems })\n    }\n\n    // 如果只有 system 消息没有 user 消息\n    if (systemParts.length > 0) {\n        input.unshift({\n            role: 'user',\n            content: [{ type: 'input_text', text: systemParts.join('\\n') }],\n        })\n    }\n\n    return input\n}\n\n// ============================================================\n// thinking 参数构建\n// ============================================================\n\nexport function buildArkThinkingParam(\n    _modelId: string,\n    reasoning: boolean,\n): { thinking: { type: 'enabled' | 'disabled' } } {\n    // Ark Responses 对 reasoning_effort 的模型支持在实际环境存在不一致。\n    // 为避免请求参数不兼容导致 400，统一仅发送 thinking.type。\n    return { thinking: { type: reasoning ? 'enabled' : 'disabled' } }\n}\n\n// ============================================================\n// 流式 Responses API\n// ============================================================\n\nexport interface ArkStreamDelta {\n    kind: 'reasoning' | 'text'\n    delta: string\n}\n\nexport interface ArkStreamResult {\n    text: string\n    reasoning: string\n    usage: { promptTokens: number; completionTokens: number }\n}\n\n/**\n * 流式调用 Responses API，返回 AsyncIterable<ArkStreamDelta> + 最终结果 Promise。\n */\nexport function arkResponsesStream(options: ArkResponsesOptions & { temperature?: number }): {\n    stream: AsyncIterable<ArkStreamDelta>\n    result: () => Promise<ArkStreamResult>\n} {\n    let resolveResult!: (value: ArkStreamResult) => void\n    let rejectResult!: (error: Error) => void\n    const resultPromise = new Promise<ArkStreamResult>((resolve, reject) => {\n        resolveResult = resolve\n        rejectResult = reject\n    })\n\n    const thinking = options.thinking\n        ? {\n            type: options.thinking.type,\n        }\n        : undefined\n\n    const body: Record<string, unknown> = {\n        model: options.model,\n        input: options.input,\n        stream: true,\n        ...(thinking && { thinking }),\n        ...(options.temperature !== undefined && { temperature: options.temperature }),\n    }\n\n    async function* generateStream(): AsyncIterable<ArkStreamDelta> {\n        const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/responses', {\n            method: 'POST',\n            headers: {\n                'Content-Type': 'application/json',\n                Authorization: `Bearer ${options.apiKey}`,\n            },\n            body: JSON.stringify(body),\n        })\n\n        if (!response.ok) {\n            const errorText = await response.text()\n            const err = new Error(`Ark Responses 调用失败: ${response.status} - ${errorText}`)\n            rejectResult(err)\n            throw err\n        }\n\n        if (!response.body) {\n            const err = new Error('Ark Responses: response body is null')\n            rejectResult(err)\n            throw err\n        }\n\n        const reader = response.body.getReader()\n        const decoder = new TextDecoder()\n        let buffer = ''\n        let text = ''\n        let reasoning = ''\n        let usage = { promptTokens: 0, completionTokens: 0 }\n\n        try {\n            while (true) {\n                const { done, value } = await reader.read()\n                if (done) break\n\n                buffer += decoder.decode(value, { stream: true })\n\n                const parts = buffer.split('\\n\\n')\n                buffer = parts.pop() || ''\n\n                for (const part of parts) {\n                    const dataLine = part.split('\\n').find(line => line.startsWith('data: '))\n                    if (!dataLine) continue\n\n                    const jsonStr = dataLine.slice(6)\n                    let event: Record<string, unknown>\n                    try {\n                        event = JSON.parse(jsonStr) as Record<string, unknown>\n                    } catch {\n                        continue\n                    }\n\n                    const eventType = event.type as string\n\n                    if (eventType === 'response.reasoning_summary_text.delta') {\n                        const delta = event.delta as string\n                        if (delta) {\n                            reasoning += delta\n                            yield { kind: 'reasoning', delta }\n                        }\n                    }\n\n                    if (eventType === 'response.output_text.delta') {\n                        const delta = event.delta as string\n                        if (delta) {\n                            text += delta\n                            yield { kind: 'text', delta }\n                        }\n                    }\n\n                    if (eventType === 'response.completed') {\n                        const resp = event.response as Record<string, unknown> | undefined\n                        if (resp) {\n                            usage = extractArkUsage(resp)\n                        }\n                    }\n                }\n            }\n\n            resolveResult({ text, reasoning, usage })\n        } catch (error) {\n            rejectResult(error instanceof Error ? error : new Error(String(error)))\n            throw error\n        }\n    }\n\n    return {\n        stream: generateStream(),\n        result: () => resultPromise,\n    }\n}\n"
  },
  {
    "path": "src/lib/asset-utils/ai-design.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\n/**\n * AI 设计共享工具函数\n * 统一处理 Asset Hub 和 Novel Promotion 的 AI 设计逻辑\n */\n\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { withTextBilling } from '@/lib/billing'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport type { Locale } from '@/i18n/routing'\n\nexport type AssetType = 'character' | 'location'\n\nexport interface AIDesignOptions {\n    userId: string\n    locale: Locale\n    analysisModel: string\n    userInstruction: string\n    assetType: AssetType\n    /** 用于计费的上下文：'asset-hub' 或实际的 projectId */\n    projectId?: string\n    /** 任务 worker 内执行时使用，避免和任务计费重复 */\n    skipBilling?: boolean\n}\n\nexport interface AIDesignResult {\n    success: boolean\n    prompt?: string\n    error?: string\n}\n\n/**\n * AI 设计通用函数\n * 根据用户指令生成角色或场景的 prompt 描述\n */\nexport async function aiDesign(options: AIDesignOptions): Promise<AIDesignResult> {\n    const {\n        userId,\n        locale,\n        analysisModel,\n        userInstruction,\n        assetType,\n        projectId = 'asset-hub',\n        skipBilling = false,\n    } = options\n\n    if (!userInstruction?.trim()) {\n        return {\n            success: false,\n            error: assetType === 'character' ? '请输入人物设计需求' : '请输入场景设计需求'\n        }\n    }\n\n    if (!analysisModel) {\n        return {\n            success: false,\n            error: '请先在用户配置中设置分析模型'\n        }\n    }\n\n    let finalPrompt: string\n    try {\n        finalPrompt = buildPrompt({\n            promptId: assetType === 'character'\n                ? PROMPT_IDS.NP_CHARACTER_CREATE\n                : PROMPT_IDS.NP_LOCATION_CREATE,\n            locale,\n            variables: {\n                user_input: userInstruction,\n            },\n        })\n    } catch {\n        _ulogError('[AI Design] 提示词加载失败')\n        return { success: false, error: '系统配置错误' }\n    }\n\n    // 调用 LLM\n    const action = assetType === 'character' ? 'ai_design_character' : 'ai_design_location'\n    const maxInputTokens = Math.max(1200, Math.ceil(finalPrompt.length * 1.2))\n    const maxOutputTokens = 1200\n    const runCompletion = async () =>\n        await executeAiTextStep({\n            userId,\n            model: analysisModel,\n            messages: [{ role: 'user', content: finalPrompt }],\n            temperature: 0.7,\n            projectId,\n            action,\n            meta: {\n                stepId: action,\n                stepTitle: assetType === 'character' ? '角色设计' : '场景设计',\n                stepIndex: 1,\n                stepTotal: 1,\n            },\n        })\n    const completion = skipBilling\n        ? await runCompletion()\n        : await withTextBilling(\n            userId,\n            analysisModel,\n            maxInputTokens,\n            maxOutputTokens,\n            { projectId, action, metadata: { assetType } },\n            runCompletion,\n        )\n\n    const aiResponse = completion.text\n\n    if (!aiResponse) {\n        return { success: false, error: 'AI返回内容为空' }\n    }\n\n    // 解析 JSON 响应\n    let parsedResponse\n    try {\n        parsedResponse = JSON.parse(aiResponse)\n    } catch {\n        const jsonMatch = aiResponse.match(/\\{[\\s\\S]*\\}/)\n        if (jsonMatch) {\n            try {\n                parsedResponse = JSON.parse(jsonMatch[0])\n            } catch {\n                _ulogError('[AI Design] AI 响应解析失败:', aiResponse)\n                return { success: false, error: 'AI返回格式错误' }\n            }\n        } else {\n            _ulogError('[AI Design] AI 响应解析失败:', aiResponse)\n            return { success: false, error: 'AI返回格式错误' }\n        }\n    }\n\n    if (!parsedResponse.prompt) {\n        return { success: false, error: 'AI返回缺少prompt字段' }\n    }\n\n    return {\n        success: true,\n        prompt: parsedResponse.prompt\n    }\n}\n"
  },
  {
    "path": "src/lib/asset-utils/index.ts",
    "content": "/**\n * 资产共享工具函数\n * 统一 Asset Hub 和 Novel Promotion 的资产处理逻辑\n */\n\nexport { aiDesign, type AssetType, type AIDesignOptions, type AIDesignResult } from './ai-design'\n"
  },
  {
    "path": "src/lib/assets/description-fields.ts",
    "content": "import { parseJsonStringArray } from '@/lib/workers/handlers/image-task-handler-shared'\n\nfunction trimText(value: string | null | undefined): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nexport function readIndexedDescription(params: {\n  descriptions: string | null | undefined\n  fallbackDescription: string | null | undefined\n  index: number\n}): string {\n  const list = parseJsonStringArray(params.descriptions).map((item) => item.trim()).filter(Boolean)\n  const fallback = trimText(params.fallbackDescription)\n  if (typeof list[params.index] === 'string' && list[params.index]) {\n    return list[params.index]\n  }\n  if (typeof list[0] === 'string' && list[0]) {\n    return list[0]\n  }\n  return fallback\n}\n\nexport function buildCharacterDescriptionFields(params: {\n  descriptions: string | null | undefined\n  fallbackDescription: string | null | undefined\n  index: number\n  nextDescription: string\n}): { description: string; descriptions: string } {\n  const nextDescription = trimText(params.nextDescription)\n  const list = parseJsonStringArray(params.descriptions).map((item) => item.trim()).filter(Boolean)\n  const fallback = trimText(params.fallbackDescription) || nextDescription\n  const nextLength = Math.max(list.length, params.index + 1, 1)\n  const values = Array.from({ length: nextLength }, (_, index) => list[index] || list[0] || fallback)\n  values[params.index] = nextDescription\n  if (!values[0]) {\n    values[0] = nextDescription\n  }\n  return {\n    description: values[0],\n    descriptions: JSON.stringify(values),\n  }\n}\n"
  },
  {
    "path": "src/lib/assistant-platform/errors.ts",
    "content": "export type AssistantPlatformErrorCode =\n  | 'ASSISTANT_INVALID_REQUEST'\n  | 'ASSISTANT_UNSUPPORTED_PROVIDER'\n  | 'ASSISTANT_MODEL_NOT_CONFIGURED'\n  | 'ASSISTANT_MODEL_PROVIDER_UNSUPPORTED'\n  | 'ASSISTANT_CONTEXT_REQUIRED'\n  | 'ASSISTANT_SKILL_NOT_FOUND'\n\nexport class AssistantPlatformError extends Error {\n  readonly code: AssistantPlatformErrorCode\n\n  constructor(code: AssistantPlatformErrorCode, message: string) {\n    super(message)\n    this.code = code\n    this.name = 'AssistantPlatformError'\n  }\n}\n"
  },
  {
    "path": "src/lib/assistant-platform/index.ts",
    "content": "export { createAssistantChatResponse } from './runtime'\nexport { getAssistantSkill, isAssistantId } from './registry'\nexport { AssistantPlatformError } from './errors'\nexport type {\n  AssistantContext,\n  AssistantId,\n  AssistantRuntimeContext,\n  AssistantSkillDefinition,\n  AssistantToolResult,\n} from './types'\n"
  },
  {
    "path": "src/lib/assistant-platform/registry.ts",
    "content": "import type { AssistantId, AssistantSkillDefinition } from './types'\nimport { apiConfigTemplateSkill } from './skills/api-config-template'\nimport { tutorialSkill } from './skills/tutorial'\n\nconst SKILLS: Record<AssistantId, AssistantSkillDefinition> = {\n  'api-config-template': apiConfigTemplateSkill,\n  tutorial: tutorialSkill,\n}\n\nexport function getAssistantSkill(id: AssistantId): AssistantSkillDefinition {\n  return SKILLS[id]\n}\n\nexport function isAssistantId(value: unknown): value is AssistantId {\n  return value === 'api-config-template' || value === 'tutorial'\n}\n"
  },
  {
    "path": "src/lib/assistant-platform/runtime.ts",
    "content": "import { convertToModelMessages, safeValidateUIMessages, stepCountIs, streamText, type LanguageModel, type UIMessage } from 'ai'\nimport { createOpenAI } from '@ai-sdk/openai'\nimport { createGoogleGenerativeAI } from '@ai-sdk/google'\nimport { getProviderConfig, getProviderKey } from '@/lib/api-config'\nimport { getUserModelConfig } from '@/lib/config-service'\nimport { resolveLlmRuntimeModel } from '@/lib/llm/runtime-shared'\nimport { AssistantPlatformError } from './errors'\nimport { getAssistantSkill } from './registry'\nimport type {\n  AssistantContext,\n  AssistantId,\n  AssistantResolvedModel,\n  AssistantRuntimeContext,\n} from './types'\n\nfunction normalizeAssistantContext(raw: unknown): AssistantContext {\n  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n  const record = raw as Record<string, unknown>\n  const providerId = typeof record.providerId === 'string' ? record.providerId.trim() : ''\n  const locale = typeof record.locale === 'string' ? record.locale.trim() : ''\n  return {\n    ...(providerId ? { providerId } : {}),\n    ...(locale ? { locale } : {}),\n  }\n}\n\nasync function toModelMessages(messages: UIMessage[]): Promise<Awaited<ReturnType<typeof convertToModelMessages>>> {\n  const withoutIds = messages.map((message) => {\n    const { id: _id, ...rest } = message\n    return rest\n  })\n  return await convertToModelMessages(withoutIds)\n}\n\nasync function resolveAssistantLanguageModel(input: {\n  userId: string\n  analysisModelKey: string\n}): Promise<{\n  resolvedModel: AssistantResolvedModel\n  languageModel: LanguageModel\n}> {\n  const selection = await resolveLlmRuntimeModel(input.userId, input.analysisModelKey)\n  const providerConfig = await getProviderConfig(input.userId, selection.provider)\n  const providerKey = getProviderKey(selection.provider)\n\n  if (providerKey === 'google' || providerKey === 'gemini-compatible') {\n    const google = createGoogleGenerativeAI({\n      apiKey: providerConfig.apiKey,\n      ...(providerConfig.baseUrl ? { baseURL: providerConfig.baseUrl } : {}),\n      name: providerKey,\n    })\n    return {\n      resolvedModel: {\n        providerId: selection.provider,\n        providerKey,\n        modelId: selection.modelId,\n      },\n      languageModel: google.chat(selection.modelId),\n    }\n  }\n\n  const openai = createOpenAI({\n    apiKey: providerConfig.apiKey,\n    ...(providerConfig.baseUrl ? { baseURL: providerConfig.baseUrl } : {}),\n    name: providerKey,\n  })\n  return {\n    resolvedModel: {\n      providerId: selection.provider,\n      providerKey,\n      modelId: selection.modelId,\n    },\n    languageModel: openai.chat(selection.modelId),\n  }\n}\n\nexport async function createAssistantChatResponse(input: {\n  userId: string\n  assistantId: AssistantId\n  context: unknown\n  messages: unknown\n}): Promise<Response> {\n  const validation = await safeValidateUIMessages({ messages: input.messages })\n  if (!validation.success) {\n    throw new AssistantPlatformError('ASSISTANT_INVALID_REQUEST', 'messages payload is invalid')\n  }\n\n  const normalizedMessages = validation.data\n  if (normalizedMessages.length === 0) {\n    throw new AssistantPlatformError('ASSISTANT_INVALID_REQUEST', 'messages must not be empty')\n  }\n\n  const userConfig = await getUserModelConfig(input.userId)\n  const analysisModelKey = userConfig.analysisModel?.trim() || ''\n  if (!analysisModelKey) {\n    throw new AssistantPlatformError('ASSISTANT_MODEL_NOT_CONFIGURED', 'analysisModel is required')\n  }\n\n  const context = normalizeAssistantContext(input.context)\n  const skill = getAssistantSkill(input.assistantId)\n  const resolved = await resolveAssistantLanguageModel({\n    userId: input.userId,\n    analysisModelKey,\n  })\n  const runtimeContext: AssistantRuntimeContext = {\n    userId: input.userId,\n    assistantId: input.assistantId,\n    context,\n    analysisModelKey,\n    resolvedModel: resolved.resolvedModel,\n  }\n\n  const tools = skill.tools ? skill.tools(runtimeContext) : undefined\n\n  const result = streamText({\n    model: resolved.languageModel,\n    system: skill.systemPrompt(runtimeContext),\n    messages: await toModelMessages(normalizedMessages),\n    ...(tools ? { tools } : {}),\n    stopWhen: stepCountIs(skill.maxSteps ?? 4),\n    temperature: skill.temperature ?? 0.2,\n  })\n\n  return result.toUIMessageStreamResponse({\n    onError: (error) => {\n      if (error instanceof Error && error.message) return error.message\n      return 'assistant stream failed'\n    },\n  })\n}\n"
  },
  {
    "path": "src/lib/assistant-platform/skills/api-config-template.ts",
    "content": "import { jsonSchema, tool, type ToolSet } from 'ai'\nimport type { JSONSchema7 } from 'json-schema'\nimport { getProviderKey } from '@/lib/api-config'\nimport type { OpenAICompatMediaTemplate } from '@/lib/openai-compat-media-template'\nimport { saveModelTemplateConfiguration } from '@/lib/user-api/model-template/save'\nimport { validateOpenAICompatMediaTemplate } from '@/lib/user-api/model-template/validator'\nimport type { AssistantRuntimeContext, AssistantSkillDefinition, AssistantToolResult } from '../types'\nimport { AssistantPlatformError } from '../errors'\nimport { renderAssistantSystemPrompt } from '../system-prompts'\n\ninterface SaveModelTemplateToolInput {\n  modelId: string\n  name: string\n  type: 'image' | 'video'\n  compatMediaTemplate: unknown\n}\n\ninterface SaveModelTemplatesToolInput {\n  models: SaveModelTemplateToolInput[]\n}\n\nconst saveModelTemplateItemSchema: JSONSchema7 = {\n  type: 'object',\n  additionalProperties: false,\n  properties: {\n    modelId: { type: 'string', minLength: 1 },\n    name: { type: 'string', minLength: 1 },\n    type: { type: 'string', enum: ['image', 'video'] },\n    compatMediaTemplate: { type: 'object' },\n  },\n  required: ['modelId', 'name', 'type', 'compatMediaTemplate'],\n}\n\nconst saveModelTemplateInputSchema = jsonSchema<SaveModelTemplateToolInput>(saveModelTemplateItemSchema)\n\nconst saveModelTemplatesInputSchema = jsonSchema<SaveModelTemplatesToolInput>({\n  type: 'object',\n  additionalProperties: false,\n  properties: {\n    models: {\n      type: 'array',\n      minItems: 1,\n      maxItems: 8,\n      items: saveModelTemplateItemSchema,\n    },\n  },\n  required: ['models'],\n})\n\nfunction buildSystemPrompt(ctx: AssistantRuntimeContext): string {\n  return renderAssistantSystemPrompt('api-config-template', {\n    providerId: ctx.context.providerId || '',\n  })\n}\n\nfunction createApiConfigTemplateTools(ctx: AssistantRuntimeContext): ToolSet {\n  const providerId = ctx.context.providerId?.trim() || ''\n  if (!providerId) {\n    throw new AssistantPlatformError('ASSISTANT_CONTEXT_REQUIRED', 'providerId is required for api-config-template assistant')\n  }\n  if (getProviderKey(providerId) !== 'openai-compatible') {\n    throw new AssistantPlatformError('ASSISTANT_CONTEXT_REQUIRED', 'api-config-template assistant requires openai-compatible providerId')\n  }\n\n  return {\n    saveModelTemplates: tool({\n      description: '当用户一次要配置多个模型时调用，批量校验并保存到当前 provider。',\n      inputSchema: saveModelTemplatesInputSchema,\n      execute: async (input): Promise<AssistantToolResult> => {\n        const normalizedItems: Array<{\n          modelId: string\n          name: string\n          type: 'image' | 'video'\n          template: OpenAICompatMediaTemplate\n        }> = []\n\n        for (let index = 0; index < input.models.length; index += 1) {\n          const item = input.models[index]\n          const normalizedModelId = item.modelId.trim()\n          const normalizedName = item.name.trim() || normalizedModelId\n          if (!normalizedModelId) {\n            return {\n              status: 'invalid',\n              code: 'MODEL_TEMPLATE_INVALID',\n              message: `models[${index}].modelId is required`,\n              issues: [{\n                code: 'MODEL_TEMPLATE_INVALID',\n                field: `models[${index}].modelId`,\n                message: 'modelId is required',\n              }],\n            }\n          }\n\n          const validated = validateOpenAICompatMediaTemplate(item.compatMediaTemplate)\n          if (!validated.ok || !validated.template) {\n            return {\n              status: 'invalid',\n              code: 'MODEL_TEMPLATE_INVALID',\n              message: `models[${index}] template validation failed`,\n              issues: validated.issues.map((issue) => ({\n                ...issue,\n                field: `models[${index}].${issue.field}`,\n              })),\n            }\n          }\n\n          if (validated.template.mediaType !== item.type) {\n            return {\n              status: 'invalid',\n              code: 'MODEL_TEMPLATE_MEDIATYPE_MISMATCH',\n              message: `models[${index}] template mediaType does not match model type`,\n              issues: [\n                {\n                  code: 'MODEL_TEMPLATE_MEDIATYPE_MISMATCH',\n                  field: `models[${index}].compatMediaTemplate.mediaType`,\n                  message: 'template mediaType does not match model type',\n                },\n              ],\n            }\n          }\n\n          normalizedItems.push({\n            modelId: normalizedModelId,\n            name: normalizedName,\n            type: item.type,\n            template: validated.template,\n          })\n        }\n\n        const savedResults: Array<{\n          savedModelKey: string\n          draftModel: NonNullable<AssistantToolResult['draftModel']>\n        }> = []\n\n        for (const item of normalizedItems) {\n          const saved = await saveModelTemplateConfiguration({\n            userId: ctx.userId,\n            providerId,\n            modelId: item.modelId,\n            name: item.name,\n            type: item.type,\n            template: item.template,\n            source: 'ai',\n          })\n          savedResults.push({\n            savedModelKey: saved.modelKey,\n            draftModel: {\n              modelId: item.modelId,\n              name: item.name,\n              type: item.type,\n              provider: providerId,\n              compatMediaTemplate: item.template,\n            },\n          })\n        }\n\n        const first = savedResults[0]\n        if (!first) {\n          return {\n            status: 'error',\n            code: 'MODEL_TEMPLATE_INVALID',\n            message: 'no models saved',\n          }\n        }\n\n        return {\n          status: 'saved',\n          message: `模型已批量保存：${savedResults.length} 个`,\n          savedModelKey: first.savedModelKey,\n          draftModel: first.draftModel,\n          savedModelKeys: savedResults.map((item) => item.savedModelKey),\n          draftModels: savedResults.map((item) => item.draftModel),\n        }\n      },\n    }),\n    saveModelTemplate: tool({\n      description: '当模型模板字段完整且可执行时调用，自动保存到当前 provider。',\n      inputSchema: saveModelTemplateInputSchema,\n      execute: async (input): Promise<AssistantToolResult> => {\n        const normalizedModelId = input.modelId.trim()\n        const normalizedName = input.name.trim() || normalizedModelId\n        if (!normalizedModelId) {\n          return {\n            status: 'invalid',\n            code: 'MODEL_TEMPLATE_INVALID',\n            message: 'modelId is required',\n            issues: [{ code: 'MODEL_TEMPLATE_INVALID', field: 'modelId', message: 'modelId is required' }],\n          }\n        }\n\n        const validated = validateOpenAICompatMediaTemplate(input.compatMediaTemplate)\n        if (!validated.ok || !validated.template) {\n          return {\n            status: 'invalid',\n            code: 'MODEL_TEMPLATE_INVALID',\n            message: 'template validation failed',\n            issues: validated.issues,\n          }\n        }\n\n        if (validated.template.mediaType !== input.type) {\n          return {\n            status: 'invalid',\n            code: 'MODEL_TEMPLATE_MEDIATYPE_MISMATCH',\n            message: 'template mediaType does not match model type',\n            issues: [\n              {\n                code: 'MODEL_TEMPLATE_MEDIATYPE_MISMATCH',\n                field: 'compatMediaTemplate.mediaType',\n                message: 'template mediaType does not match model type',\n              },\n            ],\n          }\n        }\n\n        const saved = await saveModelTemplateConfiguration({\n          userId: ctx.userId,\n          providerId,\n          modelId: normalizedModelId,\n          name: normalizedName,\n          type: input.type,\n          template: validated.template,\n          source: 'ai',\n        })\n\n        return {\n          status: 'saved',\n          message: `模型已保存：${saved.modelKey}`,\n          savedModelKey: saved.modelKey,\n          draftModel: {\n            modelId: normalizedModelId,\n            name: normalizedName,\n            type: input.type,\n            provider: providerId,\n            compatMediaTemplate: validated.template,\n          },\n        }\n      },\n    }),\n  }\n}\n\nexport const apiConfigTemplateSkill: AssistantSkillDefinition = {\n  id: 'api-config-template',\n  systemPrompt: buildSystemPrompt,\n  tools: createApiConfigTemplateTools,\n  temperature: 0.2,\n  maxSteps: 6,\n}\n"
  },
  {
    "path": "src/lib/assistant-platform/skills/tutorial.ts",
    "content": "import type { AssistantRuntimeContext, AssistantSkillDefinition } from '../types'\nimport { renderAssistantSystemPrompt } from '../system-prompts'\n\nfunction buildTutorialPrompt(_ctx: AssistantRuntimeContext): string {\n  return renderAssistantSystemPrompt('tutorial')\n}\n\nexport const tutorialSkill: AssistantSkillDefinition = {\n  id: 'tutorial',\n  systemPrompt: buildTutorialPrompt,\n  temperature: 0.2,\n  maxSteps: 4,\n}\n"
  },
  {
    "path": "src/lib/assistant-platform/system-prompts.ts",
    "content": "import fs from 'fs'\nimport path from 'path'\n\nexport type AssistantPromptId = 'api-config-template' | 'tutorial'\n\nconst PROMPT_FILE_BY_ID: Record<AssistantPromptId, string> = {\n  'api-config-template': 'api-config-template.system.txt',\n  tutorial: 'tutorial.system.txt',\n}\n\nconst promptCache = new Map<AssistantPromptId, string>()\n\nfunction loadPromptTemplate(promptId: AssistantPromptId): string {\n  const cached = promptCache.get(promptId)\n  if (cached) return cached\n\n  const fileName = PROMPT_FILE_BY_ID[promptId]\n  const filePath = path.resolve(process.cwd(), 'lib', 'prompts', 'skills', fileName)\n  if (!fs.existsSync(filePath)) {\n    throw new Error(`ASSISTANT_SYSTEM_PROMPT_FILE_MISSING: ${filePath}`)\n  }\n\n  const content = fs.readFileSync(filePath, 'utf8').trim()\n  if (!content) {\n    throw new Error(`ASSISTANT_SYSTEM_PROMPT_EMPTY: ${filePath}`)\n  }\n\n  promptCache.set(promptId, content)\n  return content\n}\n\nfunction replacePromptVariables(template: string, vars: Record<string, string>): string {\n  return template.replace(/\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}/g, (_match, keyRaw: string) => {\n    const key = keyRaw.trim()\n    return vars[key] || ''\n  })\n}\n\nexport function renderAssistantSystemPrompt(\n  promptId: AssistantPromptId,\n  vars?: Record<string, string>,\n): string {\n  const template = loadPromptTemplate(promptId)\n  if (!vars || Object.keys(vars).length === 0) return template\n  return replacePromptVariables(template, vars)\n}\n"
  },
  {
    "path": "src/lib/assistant-platform/types.ts",
    "content": "import type { ToolSet } from 'ai'\nimport type { OpenAICompatMediaTemplate } from '@/lib/openai-compat-media-template'\n\nexport type AssistantId = 'api-config-template' | 'tutorial'\n\nexport interface AssistantContext {\n  providerId?: string\n  locale?: string\n}\n\nexport interface AssistantResolvedModel {\n  providerId: string\n  providerKey: string\n  modelId: string\n}\n\nexport interface AssistantRuntimeContext {\n  userId: string\n  assistantId: AssistantId\n  context: AssistantContext\n  analysisModelKey: string\n  resolvedModel: AssistantResolvedModel\n}\n\nexport interface AssistantToolResult {\n  status: 'saved' | 'invalid' | 'error'\n  message: string\n  code?: string\n  savedModelKey?: string\n  savedModelKeys?: string[]\n  issues?: Array<{\n    code: string\n    field: string\n    message: string\n  }>\n  draftModel?: {\n    modelId: string\n    name: string\n    type: 'image' | 'video'\n    provider: string\n    compatMediaTemplate: OpenAICompatMediaTemplate\n  }\n  draftModels?: Array<{\n    modelId: string\n    name: string\n    type: 'image' | 'video'\n    provider: string\n    compatMediaTemplate: OpenAICompatMediaTemplate\n  }>\n}\n\nexport interface AssistantSkillDefinition {\n  id: AssistantId\n  systemPrompt: (ctx: AssistantRuntimeContext) => string\n  tools?: (ctx: AssistantRuntimeContext) => ToolSet\n  temperature?: number\n  maxSteps?: number\n}\n"
  },
  {
    "path": "src/lib/async/map-with-concurrency.ts",
    "content": "export async function mapWithConcurrency<TItem, TResult>(\n  items: readonly TItem[],\n  concurrency: number,\n  mapper: (item: TItem, index: number) => Promise<TResult>,\n): Promise<TResult[]> {\n  if (items.length === 0) return []\n\n  const normalizedConcurrency = Number.isFinite(concurrency)\n    ? Math.max(1, Math.floor(concurrency))\n    : 1\n  const workerCount = Math.min(normalizedConcurrency, items.length)\n  const results = new Array<TResult>(items.length)\n  let nextIndex = 0\n\n  const workers = Array.from({ length: workerCount }, async () => {\n    while (true) {\n      const currentIndex = nextIndex\n      nextIndex += 1\n      if (currentIndex >= items.length) return\n      results[currentIndex] = await mapper(items[currentIndex], currentIndex)\n    }\n  })\n\n  await Promise.all(workers)\n  return results\n}\n"
  },
  {
    "path": "src/lib/async-poll.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\n\n/**\n * 统一异步任务轮询模块\n * \n * 🔥 统一格式：PROVIDER:TYPE:REQUEST_ID\n * \n * 例如：\n * - FAL:VIDEO:fal-ai/wan/v2.6:abc123\n * - FAL:IMAGE:fal-ai/nano-banana-pro:def456\n * - ARK:VIDEO:task_789\n * - ARK:IMAGE:task_xyz\n * - GEMINI:BATCH:batches/ghi012\n * \n * 注意：\n * - 仅接受标准 externalId（不再兼容历史拼装格式）\n */\n\nimport { queryFalStatus } from './async-submit'\nimport { queryGeminiBatchStatus, querySeedanceVideoStatus, queryGoogleVideoStatus } from './async-task-utils'\nimport { getProviderConfig, getUserModels } from './api-config'\nimport { buildRenderedTemplateRequest, buildTemplateVariables, normalizeResponseJson, readJsonPath } from './openai-compat-template-runtime'\nimport { composeModelKey } from './model-config-contract'\n\nconst OPENAI_COMPAT_PROVIDER_PREFIX = 'openai-compatible:'\nconst PROVIDER_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nexport interface PollResult {\n    status: 'pending' | 'completed' | 'failed'\n    resultUrl?: string\n    imageUrl?: string\n    videoUrl?: string\n    downloadHeaders?: Record<string, string>\n    error?: string\n}\n\nfunction getErrorMessage(error: unknown): string {\n    if (error instanceof Error) return error.message\n    if (typeof error === 'object' && error !== null) {\n        const candidate = (error as { message?: unknown }).message\n        if (typeof candidate === 'string') return candidate\n    }\n    return '查询异常'\n}\n\n/**\n * 解析 externalId 获取 provider、type 和请求信息\n */\nexport function parseExternalId(externalId: string): {\n    provider: 'FAL' | 'ARK' | 'GEMINI' | 'GOOGLE' | 'MINIMAX' | 'VIDU' | 'OPENAI' | 'OCOMPAT' | 'BAILIAN' | 'SILICONFLOW' | 'UNKNOWN'\n    type: 'VIDEO' | 'IMAGE' | 'BATCH' | 'UNKNOWN'\n    endpoint?: string\n    requestId: string\n    providerToken?: string\n    modelKeyToken?: string\n} {\n    // 标准格式：PROVIDER:TYPE:...\n    if (externalId.startsWith('FAL:')) {\n        const parts = externalId.split(':')\n\n        if (parts[1] === 'VIDEO' || parts[1] === 'IMAGE') {\n            if (parts.length < 4) {\n                throw new Error(`无效 FAL externalId: \"${externalId}\"，应为 FAL:TYPE:endpoint:requestId`)\n            }\n            const endpoint = parts.slice(2, -1).join(':')\n            const requestId = parts[parts.length - 1]\n            if (!endpoint || !requestId) {\n                throw new Error(`无效 FAL externalId: \"${externalId}\"，缺少 endpoint 或 requestId`)\n            }\n            return {\n                provider: 'FAL',\n                type: parts[1] as 'VIDEO' | 'IMAGE',\n                endpoint,\n                requestId,\n            }\n        }\n        throw new Error(`无效 FAL externalId: \"${externalId}\"，TYPE 仅支持 VIDEO/IMAGE`)\n    }\n\n    if (externalId.startsWith('ARK:')) {\n        const parts = externalId.split(':')\n        const type = parts[1]\n        const requestId = parts.slice(2).join(':')\n        if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {\n            throw new Error(`无效 ARK externalId: \"${externalId}\"，应为 ARK:TYPE:requestId`)\n        }\n        return {\n            provider: 'ARK',\n            type: type as 'VIDEO' | 'IMAGE',\n            requestId,\n        }\n    }\n\n    if (externalId.startsWith('GEMINI:')) {\n        const parts = externalId.split(':')\n        const type = parts[1]\n        const requestId = parts.slice(2).join(':')\n        if (type !== 'BATCH' || !requestId) {\n            throw new Error(`无效 GEMINI externalId: \"${externalId}\"，应为 GEMINI:BATCH:batchName`)\n        }\n        return {\n            provider: 'GEMINI',\n            type: 'BATCH',\n            requestId,\n        }\n    }\n\n    if (externalId.startsWith('GOOGLE:')) {\n        const parts = externalId.split(':')\n        const type = parts[1]\n        const requestId = parts.slice(2).join(':')\n        if (type !== 'VIDEO' || !requestId) {\n            throw new Error(`无效 GOOGLE externalId: \"${externalId}\"，应为 GOOGLE:VIDEO:operationName`)\n        }\n        return {\n            provider: 'GOOGLE',\n            type: 'VIDEO',\n            requestId,\n        }\n    }\n\n    if (externalId.startsWith('MINIMAX:')) {\n        const parts = externalId.split(':')\n        const type = parts[1]\n        const requestId = parts.slice(2).join(':')\n        if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {\n            throw new Error(`无效 MINIMAX externalId: \"${externalId}\"，应为 MINIMAX:TYPE:taskId`)\n        }\n        return {\n            provider: 'MINIMAX',\n            type: type as 'VIDEO' | 'IMAGE',\n            requestId,\n        }\n    }\n\n    if (externalId.startsWith('VIDU:')) {\n        const parts = externalId.split(':')\n        const type = parts[1]\n        const requestId = parts.slice(2).join(':')\n        if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {\n            throw new Error(`无效 VIDU externalId: \"${externalId}\"，应为 VIDU:TYPE:taskId`)\n        }\n        return {\n            provider: 'VIDU',\n            type: type as 'VIDEO' | 'IMAGE',\n            requestId,\n        }\n    }\n\n    if (externalId.startsWith('OPENAI:')) {\n        const parts = externalId.split(':')\n        const type = parts[1]\n        const providerToken = parts[2]\n        const requestId = parts.slice(3).join(':')\n        if (type !== 'VIDEO' || !providerToken || !requestId) {\n            throw new Error(`无效 OPENAI externalId: \"${externalId}\"，应为 OPENAI:VIDEO:providerToken:videoId`)\n        }\n        return {\n            provider: 'OPENAI',\n            type: 'VIDEO',\n            providerToken,\n            requestId,\n        }\n    }\n\n    if (externalId.startsWith('OCOMPAT:')) {\n        const parts = externalId.split(':')\n        const type = parts[1]\n        const providerToken = parts[2]\n        const modelKeyToken = parts[3]\n        const requestId = parts.slice(4).join(':')\n        if ((type !== 'VIDEO' && type !== 'IMAGE') || !providerToken || !modelKeyToken || !requestId) {\n            throw new Error(`无效 OCOMPAT externalId: \"${externalId}\"，应为 OCOMPAT:TYPE:providerToken:modelKeyToken:taskId`)\n        }\n        return {\n            provider: 'OCOMPAT',\n            type: type as 'VIDEO' | 'IMAGE',\n            providerToken,\n            modelKeyToken,\n            requestId,\n        }\n    }\n\n    if (externalId.startsWith('BAILIAN:')) {\n        const parts = externalId.split(':')\n        const type = parts[1]\n        const requestId = parts.slice(2).join(':')\n        if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {\n            throw new Error(`无效 BAILIAN externalId: \"${externalId}\"，应为 BAILIAN:TYPE:requestId`)\n        }\n        return {\n            provider: 'BAILIAN',\n            type: type as 'VIDEO' | 'IMAGE',\n            requestId,\n        }\n    }\n\n    if (externalId.startsWith('SILICONFLOW:')) {\n        const parts = externalId.split(':')\n        const type = parts[1]\n        const requestId = parts.slice(2).join(':')\n        if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {\n            throw new Error(`无效 SILICONFLOW externalId: \"${externalId}\"，应为 SILICONFLOW:TYPE:requestId`)\n        }\n        return {\n            provider: 'SILICONFLOW',\n            type: type as 'VIDEO' | 'IMAGE',\n            requestId,\n        }\n    }\n\n    throw new Error(\n        `无法识别的 externalId 格式: \"${externalId}\". ` +\n        `支持的格式: FAL:TYPE:endpoint:requestId, ARK:TYPE:requestId, GEMINI:BATCH:batchName, GOOGLE:VIDEO:operationName, MINIMAX:TYPE:taskId, VIDU:TYPE:taskId, OPENAI:VIDEO:providerToken:videoId, OCOMPAT:TYPE:providerToken:modelKeyToken:taskId, BAILIAN:TYPE:requestId, SILICONFLOW:TYPE:requestId`\n    )\n}\n\n/**\n * 统一轮询入口\n * 根据 externalId 格式自动选择正确的查询函数\n */\nexport async function pollAsyncTask(\n    externalId: string,\n    userId: string\n): Promise<PollResult> {\n    if (!userId) {\n        throw new Error('缺少用户ID，无法获取 API Key')\n    }\n\n    const parsed = parseExternalId(externalId)\n    _ulogInfo(`[Poll] 解析 ${externalId.slice(0, 30)}... → provider=${parsed.provider}, type=${parsed.type}`)\n\n    switch (parsed.provider) {\n        case 'FAL':\n            return await pollFalTask(parsed.endpoint!, parsed.requestId, userId)\n        case 'ARK':\n            return await pollArkTask(parsed.requestId, userId)\n        case 'GEMINI':\n            return await pollGeminiTask(parsed.requestId, userId)\n        case 'GOOGLE':\n            return await pollGoogleVideoTask(parsed.requestId, userId)\n        case 'MINIMAX':\n            return await pollMinimaxTask(parsed.requestId, userId)\n        case 'VIDU':\n            return await pollViduTask(parsed.requestId, userId)\n        case 'OPENAI':\n            return await pollOpenAIVideoTask(parsed.requestId, userId, parsed.providerToken)\n        case 'OCOMPAT':\n            return await pollOCompatTask(parsed.type, parsed.requestId, userId, parsed.providerToken, parsed.modelKeyToken)\n        case 'BAILIAN':\n            return await pollBailianTask(parsed.requestId, userId)\n        case 'SILICONFLOW':\n            return await pollSiliconFlowTask(parsed.requestId)\n        default:\n            // 🔥 移除 fallback：未知 provider 直接抛出错误\n            throw new Error(`未知的 Provider: ${parsed.provider}`)\n    }\n}\n\nfunction decodeProviderId(token: string): string {\n    const value = token.trim()\n    if (!value) {\n        throw new Error('OPENAI_PROVIDER_TOKEN_INVALID')\n    }\n    if (value.startsWith('u_')) {\n        const uuid = value.slice(2).trim()\n        if (!PROVIDER_UUID_PATTERN.test(uuid)) {\n            throw new Error('OPENAI_PROVIDER_TOKEN_INVALID')\n        }\n        return `${OPENAI_COMPAT_PROVIDER_PREFIX}${uuid.toLowerCase()}`\n    }\n    if (PROVIDER_UUID_PATTERN.test(value)) {\n        return `${OPENAI_COMPAT_PROVIDER_PREFIX}${value.toLowerCase()}`\n    }\n    const encoded = value.startsWith('b64_') ? value.slice(4) : value\n    try {\n        const decoded = Buffer.from(encoded, 'base64url').toString('utf8').trim()\n        if (!decoded) {\n            throw new Error('OPENAI_PROVIDER_TOKEN_INVALID')\n        }\n        return decoded\n    } catch {\n        throw new Error('OPENAI_PROVIDER_TOKEN_INVALID')\n    }\n}\n\nfunction decodeModelKey(token: string): string {\n    try {\n        return Buffer.from(token, 'base64url').toString('utf8')\n    } catch {\n        throw new Error('OCOMPAT_MODEL_KEY_TOKEN_INVALID')\n    }\n}\n\nfunction resolveOCompatModelKey(providerId: string, token: string): string {\n    const decoded = decodeModelKey(token).trim()\n    if (!decoded) {\n        throw new Error('OCOMPAT_MODEL_KEY_TOKEN_INVALID')\n    }\n    if (decoded.includes('::')) {\n        return decoded\n    }\n    const composed = composeModelKey(providerId, decoded)\n    if (!composed) {\n        throw new Error('OCOMPAT_MODEL_KEY_TOKEN_INVALID')\n    }\n    return composed\n}\n\nasync function pollOCompatTask(\n    type: 'VIDEO' | 'IMAGE' | 'BATCH' | 'UNKNOWN',\n    taskId: string,\n    userId: string,\n    providerToken?: string,\n    modelKeyToken?: string,\n): Promise<PollResult> {\n    if (!providerToken) throw new Error('OCOMPAT_PROVIDER_TOKEN_MISSING')\n    if (!modelKeyToken) throw new Error('OCOMPAT_MODEL_KEY_TOKEN_MISSING')\n    const providerId = decodeProviderId(providerToken)\n    const modelKey = resolveOCompatModelKey(providerId, modelKeyToken)\n    const config = await getProviderConfig(userId, providerId)\n    if (!config.baseUrl) throw new Error(`PROVIDER_BASE_URL_MISSING: ${providerId}`)\n\n    const models = await getUserModels(userId)\n    const model = models.find((item) => item.modelKey === modelKey)\n    if (!model || !model.compatMediaTemplate) {\n        throw new Error(`OCOMPAT_TEMPLATE_NOT_FOUND: ${modelKey}`)\n    }\n    const template = model.compatMediaTemplate\n    if (template.mode !== 'async' || !template.status) {\n        throw new Error(`OCOMPAT_TEMPLATE_NOT_ASYNC: ${modelKey}`)\n    }\n\n    const variables = buildTemplateVariables({\n        model: model.modelId,\n        prompt: '',\n        taskId,\n    })\n    const statusRequest = await buildRenderedTemplateRequest({\n        baseUrl: config.baseUrl,\n        endpoint: template.status,\n        variables,\n        defaultAuthHeader: `Bearer ${config.apiKey}`,\n    })\n    const response = await fetch(statusRequest.endpointUrl, {\n        method: statusRequest.method,\n        headers: statusRequest.headers,\n    })\n    const rawText = await response.text().catch(() => '')\n    if (!response.ok) {\n        return {\n            status: 'failed',\n            error: `OCOMPAT status request failed: ${response.status}`,\n        }\n    }\n    const payload = normalizeResponseJson(rawText)\n    const statusRaw = readJsonPath(payload, template.response.statusPath)\n    const status = typeof statusRaw === 'string' ? statusRaw.trim().toLowerCase() : ''\n    if (!status) {\n        return {\n            status: 'failed',\n            error: 'OCOMPAT status path resolve failed',\n        }\n    }\n    const doneStates = (template.polling?.doneStates || []).map((item) => item.toLowerCase())\n    const failStates = (template.polling?.failStates || []).map((item) => item.toLowerCase())\n    if (doneStates.includes(status)) {\n        const outputUrl = readJsonPath(payload, template.response.outputUrlPath)\n        if (typeof outputUrl === 'string' && outputUrl.trim()) {\n            return {\n                status: 'completed',\n                resultUrl: outputUrl.trim(),\n                ...(type === 'VIDEO'\n                    ? { videoUrl: outputUrl.trim() }\n                    : { imageUrl: outputUrl.trim() }),\n            }\n        }\n        if (template.content) {\n            const contentRequest = await buildRenderedTemplateRequest({\n                baseUrl: config.baseUrl,\n                endpoint: template.content,\n                variables,\n                defaultAuthHeader: `Bearer ${config.apiKey}`,\n            })\n            return {\n                status: 'completed',\n                resultUrl: contentRequest.endpointUrl,\n                ...(type === 'VIDEO'\n                    ? { videoUrl: contentRequest.endpointUrl }\n                    : { imageUrl: contentRequest.endpointUrl }),\n                downloadHeaders: {\n                    ...contentRequest.headers,\n                },\n            }\n        }\n        return {\n            status: 'failed',\n            error: 'OCOMPAT completed but output URL missing',\n        }\n    }\n    if (failStates.includes(status)) {\n        const errorRaw = readJsonPath(payload, template.response.errorPath)\n        return {\n            status: 'failed',\n            error: typeof errorRaw === 'string' && errorRaw.trim() ? errorRaw.trim() : `OCOMPAT task failed: ${status}`,\n        }\n    }\n    return { status: 'pending' }\n}\n\nasync function pollOpenAIVideoTask(\n    videoId: string,\n    userId: string,\n    providerToken?: string,\n): Promise<PollResult> {\n    if (!providerToken) {\n        throw new Error('OPENAI_PROVIDER_TOKEN_MISSING')\n    }\n    const providerId = decodeProviderId(providerToken)\n    const config = await getProviderConfig(userId, providerId)\n    if (!config.baseUrl) {\n        throw new Error(`PROVIDER_BASE_URL_MISSING: ${config.id}`)\n    }\n\n    // Use raw fetch instead of SDK to handle varying response formats across gateways\n    const baseUrl = config.baseUrl.replace(/\\/+$/, '')\n    const pollUrl = `${baseUrl}/videos/${encodeURIComponent(videoId)}`\n    const response = await fetch(pollUrl, {\n        method: 'GET',\n        headers: { Authorization: `Bearer ${config.apiKey}` },\n    })\n\n    if (!response.ok) {\n        const text = await response.text().catch(() => '')\n        throw new Error(`OPENAI_VIDEO_POLL_FAILED: ${response.status} ${text.slice(0, 200)}`)\n    }\n\n    const task = await response.json() as Record<string, unknown>\n    const status = typeof task.status === 'string' ? task.status : ''\n\n    // Pending statuses: OpenAI uses \"queued\"/\"in_progress\", some gateways use \"processing\"\n    if (status === 'queued' || status === 'in_progress' || status === 'processing') {\n        return { status: 'pending' }\n    }\n\n    if (status === 'failed') {\n        const errorObj = task.error as Record<string, unknown> | undefined\n        const message = (typeof errorObj?.message === 'string' ? errorObj.message : '')\n            || (typeof task.error === 'string' ? task.error : '')\n            || `OpenAI video task failed: ${videoId}`\n        return { status: 'failed', error: message }\n    }\n\n    if (status !== 'completed') {\n        // Unknown status, treat as pending\n        return { status: 'pending' }\n    }\n\n    // Completed: prefer video_url from response body (some gateways provide it directly)\n    const videoUrl = typeof task.video_url === 'string' ? task.video_url.trim() : ''\n    if (videoUrl) {\n        return {\n            status: 'completed',\n            videoUrl,\n            resultUrl: videoUrl,\n        }\n    }\n\n    // Fallback: OpenAI standard /videos/:id/content endpoint\n    const taskId = typeof task.id === 'string' ? task.id : videoId\n    const contentUrl = `${baseUrl}/videos/${encodeURIComponent(taskId)}/content`\n    return {\n        status: 'completed',\n        videoUrl: contentUrl,\n        resultUrl: contentUrl,\n        downloadHeaders: {\n            Authorization: `Bearer ${config.apiKey}`,\n        },\n    }\n}\n\n/**\n * FAL 任务轮询\n */\nasync function pollFalTask(\n    endpoint: string,\n    requestId: string,\n    userId: string\n): Promise<PollResult> {\n    const { apiKey } = await getProviderConfig(userId, 'fal')\n    const result = await queryFalStatus(endpoint, requestId, apiKey)\n\n    return {\n        status: result.completed ? (result.failed ? 'failed' : 'completed') : 'pending',\n        resultUrl: result.resultUrl,\n        imageUrl: result.resultUrl,\n        videoUrl: result.resultUrl,\n        error: result.error\n    }\n}\n\n/**\n * Ark 任务轮询\n */\nasync function pollArkTask(\n    taskId: string,\n    userId: string\n): Promise<PollResult> {\n    const { apiKey } = await getProviderConfig(userId, 'ark')\n    const result = await querySeedanceVideoStatus(taskId, apiKey)\n\n    return {\n        status: result.status,\n        videoUrl: result.videoUrl,\n        resultUrl: result.videoUrl,\n        error: result.error\n    }\n}\n\n/**\n * Gemini Batch 任务轮询\n */\nasync function pollGeminiTask(\n    batchName: string,\n    userId: string\n): Promise<PollResult> {\n    const { apiKey } = await getProviderConfig(userId, 'google')\n    const result = await queryGeminiBatchStatus(batchName, apiKey)\n\n    return {\n        status: result.status,\n        imageUrl: result.imageUrl,\n        resultUrl: result.imageUrl,\n        error: result.error\n    }\n}\n\n/**\n * Google Veo 视频任务轮询\n */\nasync function pollGoogleVideoTask(\n    operationName: string,\n    userId: string\n): Promise<PollResult> {\n    const { apiKey } = await getProviderConfig(userId, 'google')\n    const result = await queryGoogleVideoStatus(operationName, apiKey)\n\n    return {\n        status: result.status,\n        videoUrl: result.videoUrl,\n        resultUrl: result.videoUrl,\n        error: result.error\n    }\n}\n\n/**\n * MiniMax 任务轮询\n */\nasync function pollMinimaxTask(\n    taskId: string,\n    userId: string\n): Promise<PollResult> {\n    const { apiKey } = await getProviderConfig(userId, 'minimax')\n    const result = await queryMinimaxTaskStatus(taskId, apiKey)\n\n    return {\n        status: result.status,\n        videoUrl: result.videoUrl,\n        imageUrl: result.imageUrl,\n        resultUrl: result.videoUrl || result.imageUrl,\n        error: result.error\n    }\n}\n\n/**\n * 查询 MiniMax 任务状态\n */\nasync function queryMinimaxTaskStatus(\n    taskId: string,\n    apiKey: string\n): Promise<{ status: 'pending' | 'completed' | 'failed'; videoUrl?: string; imageUrl?: string; error?: string }> {\n    const logPrefix = '[MiniMax Query]'\n\n    try {\n        const response = await fetch(`https://api.minimaxi.com/v1/query/video_generation?task_id=${taskId}`, {\n            headers: {\n                'Authorization': `Bearer ${apiKey}`\n            }\n        })\n\n        if (!response.ok) {\n            const errorText = await response.text()\n            _ulogError(`${logPrefix} 查询失败:`, response.status, errorText)\n            return {\n                status: 'failed',\n                error: `查询失败: ${response.status}`\n            }\n        }\n\n        const data = await response.json()\n\n        // 检查响应\n        if (data.base_resp?.status_code !== 0) {\n            const errMsg = data.base_resp?.status_msg || '未知错误'\n            _ulogError(`${logPrefix} task_id=${taskId} 错误:`, errMsg)\n            return {\n                status: 'failed',\n                error: errMsg\n            }\n        }\n\n        const status = data.status\n\n        if (status === 'Success') {\n            const fileId = data.file_id\n            if (!fileId) {\n                _ulogError(`${logPrefix} task_id=${taskId} 成功但无file_id`)\n                return {\n                    status: 'failed',\n                    error: '任务完成但未返回视频'\n                }\n            }\n\n            // 🔥 使用 file_id 调用文件检索API获取真实下载URL\n            _ulogInfo(`${logPrefix} task_id=${taskId} 完成，正在获取下载URL...`)\n            try {\n                const fileResponse = await fetch(`https://api.minimaxi.com/v1/files/retrieve?file_id=${fileId}`, {\n                    headers: {\n                        'Authorization': `Bearer ${apiKey}`\n                    }\n                })\n\n                if (!fileResponse.ok) {\n                    const errorText = await fileResponse.text()\n                    _ulogError(`${logPrefix} 文件检索失败:`, fileResponse.status, errorText)\n                    return {\n                        status: 'failed',\n                        error: `文件检索失败: ${fileResponse.status}`\n                    }\n                }\n\n                const fileData = await fileResponse.json()\n                const downloadUrl = fileData.file?.download_url\n\n                if (!downloadUrl) {\n                    _ulogError(`${logPrefix} 文件检索成功但无download_url:`, fileData)\n                    return {\n                        status: 'failed',\n                        error: '无法获取视频下载链接'\n                    }\n                }\n\n                _ulogInfo(`${logPrefix} 获取下载URL成功: ${downloadUrl.substring(0, 80)}...`)\n                return {\n                    status: 'completed',\n                    videoUrl: downloadUrl\n                }\n            } catch (error: unknown) {\n                const errorMessage = getErrorMessage(error)\n                _ulogError(`${logPrefix} 文件检索异常:`, error)\n                return {\n                    status: 'failed',\n                    error: `文件检索失败: ${errorMessage}`\n                }\n            }\n        } else if (status === 'Failed') {\n            const errMsg = data.error_message || '生成失败'\n            _ulogError(`${logPrefix} task_id=${taskId} 失败:`, errMsg)\n            return {\n                status: 'failed',\n                error: errMsg\n            }\n        } else {\n            // Processing 或其他状态都视为 pending\n            return {\n                status: 'pending'\n            }\n        }\n    } catch (error: unknown) {\n        const errorMessage = getErrorMessage(error)\n        _ulogError(`${logPrefix} task_id=${taskId} 异常:`, error)\n        return {\n            status: 'failed',\n            error: errorMessage\n        }\n    }\n}\n\n/**\n * Vidu 任务轮询\n */\nasync function pollViduTask(\n    taskId: string,\n    userId: string\n): Promise<PollResult> {\n    _ulogInfo(`[Poll Vidu] 开始轮询 task_id=${taskId}, userId=${userId}`)\n\n    const { apiKey } = await getProviderConfig(userId, 'vidu')\n    _ulogInfo(`[Poll Vidu] API Key 长度: ${apiKey?.length || 0}`)\n\n    const result = await queryViduTaskStatus(taskId, apiKey)\n    _ulogInfo(`[Poll Vidu] 查询结果:`, result)\n\n    return {\n        status: result.status,\n        videoUrl: result.videoUrl,\n        resultUrl: result.videoUrl,\n        error: result.error\n    }\n}\n\ninterface BailianTaskQueryResultItem {\n    url?: string\n    video_url?: string\n    image_url?: string\n}\n\ninterface BailianTaskQueryResponse {\n    code?: string\n    message?: string\n    task_status?: string\n    output?: {\n        task_status?: string\n        code?: string\n        message?: string\n        video_url?: string\n        image_url?: string\n        results?: BailianTaskQueryResultItem[]\n    }\n}\n\nfunction readBailianTaskQueryMediaUrl(data: BailianTaskQueryResponse): {\n    mediaUrl?: string\n    videoUrl?: string\n    imageUrl?: string\n} {\n    const output = data.output\n    const videoUrl = typeof output?.video_url === 'string' ? output.video_url.trim() : ''\n    if (videoUrl) {\n        return { mediaUrl: videoUrl, videoUrl }\n    }\n\n    const imageUrl = typeof output?.image_url === 'string' ? output.image_url.trim() : ''\n    if (imageUrl) {\n        return { mediaUrl: imageUrl, imageUrl }\n    }\n\n    const firstResult = Array.isArray(output?.results) ? output.results[0] : undefined\n    if (!firstResult || typeof firstResult !== 'object') {\n        return {}\n    }\n    const firstVideoUrl = typeof firstResult.video_url === 'string' ? firstResult.video_url.trim() : ''\n    if (firstVideoUrl) {\n        return { mediaUrl: firstVideoUrl, videoUrl: firstVideoUrl }\n    }\n    const firstImageUrl = typeof firstResult.image_url === 'string' ? firstResult.image_url.trim() : ''\n    if (firstImageUrl) {\n        return { mediaUrl: firstImageUrl, imageUrl: firstImageUrl }\n    }\n    const firstUrl = typeof firstResult.url === 'string' ? firstResult.url.trim() : ''\n    if (firstUrl) {\n        return { mediaUrl: firstUrl }\n    }\n\n    return {}\n}\n\nasync function pollBailianTask(requestId: string, userId: string): Promise<PollResult> {\n    const logPrefix = '[Bailian Query]'\n\n    try {\n        const { apiKey } = await getProviderConfig(userId, 'bailian')\n        const response = await fetch(\n            `https://dashscope.aliyuncs.com/api/v1/tasks/${encodeURIComponent(requestId)}`,\n            {\n                headers: {\n                    'Authorization': `Bearer ${apiKey}`,\n                },\n            },\n        )\n\n        const raw = await response.text()\n        let data: BailianTaskQueryResponse = {}\n        if (raw) {\n            try {\n                const parsed = JSON.parse(raw) as unknown\n                if (parsed && typeof parsed === 'object') {\n                    data = parsed as BailianTaskQueryResponse\n                } else {\n                    throw new Error('BAILIAN_TASK_QUERY_RESPONSE_INVALID')\n                }\n            } catch {\n                throw new Error('BAILIAN_TASK_QUERY_RESPONSE_INVALID_JSON')\n            }\n        }\n\n        const outputCode = typeof data.output?.code === 'string' ? data.output.code.trim() : ''\n        const outputMessage = typeof data.output?.message === 'string' ? data.output.message.trim() : ''\n        const topLevelCode = typeof data.code === 'string' ? data.code.trim() : ''\n        const topLevelMessage = typeof data.message === 'string' ? data.message.trim() : ''\n        const resolvedCode = outputCode || topLevelCode\n        const resolvedMessage = outputMessage || topLevelMessage\n\n        if (!response.ok) {\n            return {\n                status: 'failed',\n                error: `Bailian: 查询失败 ${response.status} ${resolvedCode || resolvedMessage}`.trim(),\n            }\n        }\n\n        const taskStatus = (typeof data.output?.task_status === 'string'\n            ? data.output.task_status\n            : typeof data.task_status === 'string'\n                ? data.task_status\n                : '').trim().toUpperCase()\n\n        if (taskStatus === 'FAILED' || taskStatus === 'CANCELED' || taskStatus === 'CANCELLED') {\n            return {\n                status: 'failed',\n                error: `Bailian: ${resolvedCode || resolvedMessage || '任务失败'}`,\n            }\n        }\n\n        if (taskStatus === 'SUCCEEDED' || taskStatus === 'SUCCESS') {\n            const { mediaUrl, videoUrl, imageUrl } = readBailianTaskQueryMediaUrl(data)\n            if (!mediaUrl) {\n                return {\n                    status: 'failed',\n                    error: 'Bailian: 任务完成但未返回结果URL',\n                }\n            }\n            return {\n                status: 'completed',\n                resultUrl: mediaUrl,\n                videoUrl,\n                imageUrl,\n            }\n        }\n\n        return {\n            status: 'pending',\n        }\n    } catch (error: unknown) {\n        const errorMessage = getErrorMessage(error)\n        _ulogError(`${logPrefix} task_id=${requestId} 异常:`, error)\n        return {\n            status: 'failed',\n            error: `Bailian: ${errorMessage}`,\n        }\n    }\n}\n\nasync function pollSiliconFlowTask(requestId: string): Promise<PollResult> {\n    return {\n        status: 'failed',\n        error: `ASYNC_POLL_NOT_IMPLEMENTED: SILICONFLOW task polling not implemented (${requestId})`,\n    }\n}\n\n/**\n * 查询 Vidu 任务状态\n */\nasync function queryViduTaskStatus(\n    taskId: string,\n    apiKey: string\n): Promise<{ status: 'pending' | 'completed' | 'failed'; videoUrl?: string; error?: string }> {\n    const logPrefix = '[Vidu Query]'\n\n    try {\n        _ulogInfo(`${logPrefix} 查询任务 task_id=${taskId}`)\n\n        // 🔥 正确的查询接口路径：/tasks/{id}/creations\n        const response = await fetch(`https://api.vidu.cn/ent/v2/tasks/${taskId}/creations`, {\n            headers: {\n                'Authorization': `Token ${apiKey}`\n            }\n        })\n\n        _ulogInfo(`${logPrefix} HTTP状态: ${response.status}`)\n\n        if (!response.ok) {\n            const errorText = await response.text()\n            _ulogError(`${logPrefix} 查询失败:`, response.status, errorText)\n            return {\n                status: 'failed',\n                error: `Vidu: 查询失败 ${response.status}`\n            }\n        }\n\n        const data = await response.json()\n        _ulogInfo(`${logPrefix} 响应数据:`, JSON.stringify(data, null, 2))\n\n        // 检查任务状态\n        const state = data.state\n\n        if (state === 'success') {\n            // 🔥 任务成功，从 creations 数组中获取视频URL\n            const creations = data.creations\n            if (!creations || creations.length === 0) {\n                _ulogError(`${logPrefix} task_id=${taskId} 成功但无生成物`)\n                return {\n                    status: 'failed',\n                    error: 'Vidu: 任务完成但未返回视频'\n                }\n            }\n\n            const videoUrl = creations[0].url\n            if (!videoUrl) {\n                _ulogError(`${logPrefix} task_id=${taskId} 成功但生成物无URL`)\n                return {\n                    status: 'failed',\n                    error: 'Vidu: 任务完成但未返回视频URL'\n                }\n            }\n\n            _ulogInfo(`${logPrefix} task_id=${taskId} 完成，视频URL: ${videoUrl.substring(0, 80)}...`)\n            return {\n                status: 'completed',\n                videoUrl: videoUrl\n            }\n        } else if (state === 'failed') {\n            // 🔥 使用 err_code 作为错误消息，添加 Vidu: 前缀便于错误码映射\n            const errCode = data.err_code || 'Unknown'\n            _ulogError(`${logPrefix} task_id=${taskId} 失败: ${errCode}`)\n            return {\n                status: 'failed',\n                error: `Vidu: ${errCode}`  // 添加前缀以便错误映射识别\n            }\n        } else {\n            // created, queueing, processing 都视为 pending\n            return {\n                status: 'pending'\n            }\n        }\n    } catch (error: unknown) {\n        const errorMessage = getErrorMessage(error)\n        _ulogError(`${logPrefix} task_id=${taskId} 异常:`, error)\n        return {\n            status: 'failed',\n            error: `Vidu: ${errorMessage}`  // 添加前缀\n        }\n    }\n}\n\n// ==================== 格式化辅助函数 ====================\n\n/**\n * 创建标准格式的 externalId\n */\nexport function formatExternalId(\n    provider: 'FAL' | 'ARK' | 'GEMINI' | 'GOOGLE' | 'MINIMAX' | 'VIDU' | 'OPENAI' | 'OCOMPAT' | 'BAILIAN' | 'SILICONFLOW',\n    type: 'VIDEO' | 'IMAGE' | 'BATCH',\n    requestId: string,\n    endpoint?: string,\n    providerToken?: string,\n    modelKeyToken?: string,\n): string {\n    if (provider === 'FAL') {\n        if (!endpoint) {\n            throw new Error('FAL externalId requires endpoint')\n        }\n        return `FAL:${type}:${endpoint}:${requestId}`\n    }\n    if (provider === 'OPENAI') {\n        if (!providerToken) {\n            throw new Error('OPENAI externalId requires providerToken')\n        }\n        return `OPENAI:${type}:${providerToken}:${requestId}`\n    }\n    if (provider === 'OCOMPAT') {\n        if (!providerToken) {\n            throw new Error('OCOMPAT externalId requires providerToken')\n        }\n        if (!modelKeyToken) {\n            throw new Error('OCOMPAT externalId requires modelKeyToken')\n        }\n        return `OCOMPAT:${type}:${providerToken}:${modelKeyToken}:${requestId}`\n    }\n    return `${provider}:${type}:${requestId}`\n}\n"
  },
  {
    "path": "src/lib/async-submit.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { buildFalQueueUrl } from '@/lib/providers/fal/base-url'\n/**\n * 异步任务提交工具\n * \n * 核心功能：\n * 1. 提交任务到外部平台（FAL/Ark）\n * 2. 查询任务状态\n * 3. 下载并保存结果\n */\n\n// 注意：API Key 现在通过参数传入，不再使用环境变量\n\n// ==================== FAL 队列模式 ====================\n\n/**\n * 提交FAL任务到队列\n * @param endpoint FAL端点，如 'wan/v2.6/image-to-video'\n * @param input 请求参数\n * @param apiKey FAL API Key\n * @returns request_id\n */\nexport async function submitFalTask(endpoint: string, input: Record<string, unknown>, apiKey: string): Promise<string> {\n    if (!apiKey) {\n        throw new Error('请配置 FAL API Key')\n    }\n\n    const response = await fetch(buildFalQueueUrl(endpoint), {\n        method: 'POST',\n        headers: {\n            'Content-Type': 'application/json',\n            'Authorization': `Key ${apiKey}`\n        },\n        body: JSON.stringify(input)\n    })\n\n    if (!response.ok) {\n        const errorText = await response.text()\n        throw new Error(`FAL提交失败 (${response.status}): ${errorText}`)\n    }\n\n    const data = await response.json()\n    const requestId = data.request_id\n\n    if (!requestId) {\n        throw new Error('FAL未返回request_id')\n    }\n\n    _ulogInfo(`[FAL Queue] 任务已提交: ${requestId}`)\n    return requestId\n}\n\n/**\n * 解析 FAL 端点 ID\n * 根据官方客户端逻辑，端点格式为: owner/alias/path\n * 例如: fal-ai/veo3.1/fast/image-to-video\n *   -> owner = fal-ai\n *   -> alias = veo3.1\n *   -> path = fast/image-to-video (状态查询时忽略)\n */\nfunction parseFalEndpointId(endpoint: string): { owner: string; alias: string; path?: string } {\n    const parts = endpoint.split('/')\n    return {\n        owner: parts[0],\n        alias: parts[1],\n        path: parts.slice(2).join('/') || undefined\n    }\n}\n\n/**\n * 查询FAL任务状态\n * @param endpoint FAL端点\n * @param requestId 任务ID\n * @param apiKey FAL API Key\n */\nexport async function queryFalStatus(endpoint: string, requestId: string, apiKey: string): Promise<{\n    status: 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'\n    completed: boolean\n    failed: boolean\n    resultUrl?: string\n    error?: string\n}> {\n    if (!apiKey) {\n        throw new Error('请配置 FAL API Key')\n    }\n\n    // 🔥 根据 FAL 官方客户端逻辑解析端点 ID\n    // 端点格式: owner/alias/path (path 部分在状态查询时忽略)\n    // 例如: fal-ai/veo3.1/fast/image-to-video -> fal-ai/veo3.1\n    const parsed = parseFalEndpointId(endpoint)\n    const baseEndpoint = `${parsed.owner}/${parsed.alias}`\n\n    if (parsed.path) {\n        _ulogInfo(`[FAL Status] 解析端点 ${endpoint} -> ${baseEndpoint} (忽略路径: ${parsed.path})`)\n    }\n\n    const statusUrl = buildFalQueueUrl(`${baseEndpoint}/requests/${requestId}/status?logs=0`)\n\n    // FAL 状态查询使用 GET 方法\n    const response = await fetch(statusUrl, {\n        method: 'GET',\n        headers: {\n            'Authorization': `Key ${apiKey}`\n        }\n    })\n\n    if (!response.ok) {\n        return {\n            status: 'IN_PROGRESS',\n            completed: false,\n            failed: false\n        }\n    }\n\n    const data = await response.json()\n    const status = data.status as 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'\n\n    // 🔥 诊断日志：查看 FAL 返回的真实状态\n    _ulogInfo(`[FAL Status] requestId=${requestId.slice(0, 16)}... 状态=${status}`)\n\n    if (status === 'COMPLETED') {\n        // 🔥 尝试获取完整结果\n        // 优先使用返回的 response_url，如果没有则构建 URL\n        // 注意：获取结果必须使用完整的原始端点（包括 /edit 等路径），而不是 baseEndpoint\n        // 否则 FAL 会把请求当作新任务处理，导致 422 错误（缺少 image_urls 等必需参数）\n        const resultUrl = data.response_url || buildFalQueueUrl(`${endpoint}/requests/${requestId}`)\n        _ulogInfo(`[FAL Status] 任务已完成，获取结果: ${resultUrl}`)\n\n        const resultResponse = await fetch(resultUrl, {\n            method: 'GET',\n            headers: {\n                'Authorization': `Key ${apiKey}`,\n                'Accept': 'application/json'\n            }\n        })\n\n        if (resultResponse.ok) {\n            const resultData = await resultResponse.json()\n\n            // 根据类型提取URL\n            const videoUrl = resultData.video?.url\n            const audioUrl = resultData.audio?.url\n            const imageUrl = resultData.images?.[0]?.url\n\n            _ulogInfo(`[FAL Status] 获取结果成功: video=${!!videoUrl}, audio=${!!audioUrl}, image=${!!imageUrl}`)\n\n            return {\n                status: 'COMPLETED',\n                completed: true,\n                failed: false,\n                resultUrl: videoUrl || audioUrl || imageUrl\n            }\n        } else {\n            // 🔥 获取结果失败，记录详细错误\n            const errorText = await resultResponse.text()\n            _ulogError(`[FAL Status] 获取结果失败 (${resultResponse.status}): ${errorText.slice(0, 300)}`)\n\n            // 如果是 422 错误，可能是内容审核未通过或结果已过期\n            if (resultResponse.status === 422) {\n                // 尝试解析具体错误类型\n                let errorMessage = '无法获取结果'\n                try {\n                    const errorJson = JSON.parse(errorText)\n                    const errorType = errorJson.detail?.[0]?.type\n                    if (errorType === 'content_policy_violation') {\n                        errorMessage = '⚠️ 内容审核未通过：生成结果被拦截'\n                    } else if (errorType) {\n                        errorMessage = `FAL 错误: ${errorType}`\n                    }\n                } catch { }\n\n                _ulogError(`[FAL Status] 422 错误: ${errorMessage}`)\n                return {\n                    status: 'COMPLETED',\n                    completed: true,\n                    failed: true,\n                    error: errorMessage\n                }\n            }\n\n            // 🔥 500 下游服务错误，标记为失败，避免无限重试\n            if (resultResponse.status === 500) {\n                // 尝试解析错误详情\n                let errorDetail = '下游服务错误'\n                try {\n                    const errorJson = JSON.parse(errorText)\n                    if (errorJson.detail?.[0]?.type === 'downstream_service_error') {\n                        errorDetail = 'FAL 下游服务错误：上游模型处理失败'\n                    }\n                } catch { }\n\n                _ulogError(`[FAL Status] 500 错误，标记任务为失败: ${errorDetail}`)\n                return {\n                    status: 'COMPLETED',\n                    completed: true,\n                    failed: true,\n                    error: errorDetail\n                }\n            }\n\n            // 其他错误，暂时返回进行中状态，下次轮询重试\n            return {\n                status: 'IN_PROGRESS',\n                completed: false,\n                failed: false\n            }\n        }\n    }\n\n    if (status === 'FAILED') {\n        return {\n            status: 'FAILED',\n            completed: false,\n            failed: true,\n            error: data.error || '任务失败'\n        }\n    }\n\n    return {\n        status,\n        completed: false,\n        failed: false\n    }\n}\n\n// ==================== Ark 视频任务 ====================\n\n/**\n * 查询Ark视频任务状态\n * @param taskId Ark任务ID\n * @param apiKey ARK API Key\n */\nexport async function queryArkVideoStatus(taskId: string, apiKey: string): Promise<{\n    status: string\n    completed: boolean\n    failed: boolean\n    resultUrl?: string\n    error?: string\n}> {\n    if (!apiKey) {\n        throw new Error('请配置火山引擎 API Key')\n    }\n\n    const response = await fetch(\n        `https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/${taskId}`,\n        {\n            headers: {\n                'Content-Type': 'application/json',\n                'Authorization': `Bearer ${apiKey}`\n            }\n        }\n    )\n\n    if (!response.ok) {\n        return {\n            status: 'unknown',\n            completed: false,\n            failed: false\n        }\n    }\n\n    const data = await response.json()\n    const status = data.status\n\n    if (status === 'succeeded') {\n        return {\n            status: 'succeeded',\n            completed: true,\n            failed: false,\n            resultUrl: data.content?.video_url\n        }\n    }\n\n    if (status === 'failed') {\n        const errorObj = data.error || {}\n        let errorMessage = errorObj.message || '任务失败'\n\n        // 友好的错误信息\n        if (errorObj.code === 'OutputVideoSensitiveContentDetected') {\n            errorMessage = '视频生成失败：内容审核未通过'\n        } else if (errorObj.code === 'InputImageSensitiveContentDetected') {\n            errorMessage = '视频生成失败：输入图片审核未通过'\n        }\n\n        return {\n            status: 'failed',\n            completed: false,\n            failed: true,\n            error: errorMessage\n        }\n    }\n\n    return {\n        status,\n        completed: false,\n        failed: false\n    }\n}\n\n// ==================== 通用接口 ====================\n\nexport type AsyncTaskProvider = 'fal' | 'ark'\nexport type AsyncTaskType = 'video' | 'image' | 'tts' | 'lipsync'\n\n/**\n * 统一查询任务状态\n * @param provider 服务提供商\n * @param taskId 任务ID\n * @param apiKey API Key\n * @param endpoint FAL端点（仅FAL需要）\n */\nexport async function queryAsyncTaskStatus(\n    provider: AsyncTaskProvider,\n    taskId: string,\n    apiKey: string,\n    endpoint?: string\n): Promise<{\n    status: string\n    completed: boolean\n    failed: boolean\n    resultUrl?: string\n    error?: string\n}> {\n    if (provider === 'fal' && endpoint) {\n        return queryFalStatus(endpoint, taskId, apiKey)\n    } else if (provider === 'ark') {\n        return queryArkVideoStatus(taskId, apiKey)\n    }\n\n    return {\n        status: 'unknown',\n        completed: false,\n        failed: false\n    }\n}\n"
  },
  {
    "path": "src/lib/async-task-utils.ts",
    "content": "/**\n * 异步任务工具函数\n * 用于查询第三方 AI 服务的任务状态\n * \n * 注意：API Key 现在通过参数传入，不再使用环境变量\n */\n\nimport { logInternal } from './logging/semantic'\nimport { buildFalQueueUrl } from '@/lib/providers/fal/base-url'\n\nexport interface TaskStatus {\n    status: 'pending' | 'completed' | 'failed'\n    imageUrl?: string\n    videoUrl?: string\n    error?: string\n}\n\ntype UnknownRecord = Record<string, unknown>\n\nfunction asRecord(value: unknown): UnknownRecord | null {\n    return value && typeof value === 'object' ? (value as UnknownRecord) : null\n}\n\nfunction getErrorMessage(error: unknown): string {\n    if (error instanceof Error) return error.message\n    const record = asRecord(error)\n    if (record && typeof record.message === 'string') return record.message\n    return String(error)\n}\n\nfunction getErrorStatus(error: unknown): number | undefined {\n    const record = asRecord(error)\n    if (!record) return undefined\n    return typeof record.status === 'number' ? record.status : undefined\n}\n\ninterface GeminiBatchClient {\n    batches: {\n        get(args: { name: string }): Promise<unknown>\n    }\n}\n\n/**\n * 查询 FAL Banana 任务状态\n * @param requestId 任务ID\n * @param apiKey FAL API Key\n */\nexport async function queryBananaTaskStatus(requestId: string, apiKey: string): Promise<TaskStatus> {\n    if (!apiKey) {\n        throw new Error('请配置 FAL API Key')\n    }\n\n    try {\n        const statusResponse = await fetch(\n            buildFalQueueUrl(`fal-ai/nano-banana-pro/requests/${requestId}/status`),\n            {\n                headers: { 'Authorization': `Key ${apiKey}` },\n                cache: 'no-store'\n            }\n        )\n\n        if (!statusResponse.ok) {\n            logInternal('Banana', 'ERROR', `Status query failed: ${statusResponse.status}`)\n            return { status: 'pending' }\n        }\n\n        const data = await statusResponse.json()\n\n        if (data.status === 'COMPLETED') {\n            // 获取结果\n            const resultResponse = await fetch(\n                buildFalQueueUrl(`fal-ai/nano-banana-pro/requests/${requestId}`),\n                {\n                    headers: { 'Authorization': `Key ${apiKey}` },\n                    cache: 'no-store'\n                }\n            )\n\n            if (resultResponse.ok) {\n                const result = await resultResponse.json()\n                const imageUrl = result.images?.[0]?.url\n\n                if (imageUrl) {\n                    return { status: 'completed', imageUrl }\n                }\n            }\n\n            return { status: 'failed', error: 'No image URL in result' }\n        } else if (data.status === 'FAILED') {\n            return { status: 'failed', error: data.error || 'Banana generation failed' }\n        }\n\n        return { status: 'pending' }\n    } catch (error: unknown) {\n        logInternal('Banana', 'ERROR', 'Query error', { error: getErrorMessage(error) })\n        return { status: 'pending' }\n    }\n}\n\n/**\n * 查询 Gemini Batch 任务状态\n * 使用 ai.batches.get() 方法查询任务状态\n * @param batchName 任务名称（如 batches/xxx）\n * @param apiKey Google AI API Key\n */\nexport async function queryGeminiBatchStatus(batchName: string, apiKey: string): Promise<TaskStatus> {\n    if (!apiKey) {\n        throw new Error('请配置 Google AI API Key')\n    }\n\n    try {\n        const { GoogleGenAI } = await import('@google/genai')\n        const ai = new GoogleGenAI({ apiKey })\n\n        // 🔥 使用 ai.batches.get 查询任务状态\n        const batchClient = ai as unknown as GeminiBatchClient\n        const batchJob = await batchClient.batches.get({ name: batchName })\n        const batchRecord = asRecord(batchJob) || {}\n\n        const state = typeof batchRecord.state === 'string' ? batchRecord.state : 'UNKNOWN'\n        logInternal('GeminiBatch', 'INFO', `查询状态: ${batchName} -> ${state}`)\n\n        // 检查完成状态\n        if (state === 'JOB_STATE_SUCCEEDED') {\n            // 从 inlinedResponses 中提取图片\n            const dest = asRecord(batchRecord.dest)\n            const responses = Array.isArray(dest?.inlinedResponses) ? dest.inlinedResponses : []\n\n            if (responses.length > 0) {\n                const firstResponse = asRecord(responses[0])\n                const response = asRecord(firstResponse?.response)\n                const candidates = Array.isArray(response?.candidates) ? response.candidates : []\n                const firstCandidate = asRecord(candidates[0])\n                const content = asRecord(firstCandidate?.content)\n                const parts = Array.isArray(content?.parts) ? content.parts : []\n\n                for (const part of parts) {\n                    const partRecord = asRecord(part)\n                    const inlineData = asRecord(partRecord?.inlineData)\n                    if (typeof inlineData?.data === 'string') {\n                        const imageBase64 = inlineData.data\n                        const mimeType = typeof inlineData.mimeType === 'string' ? inlineData.mimeType : 'image/png'\n                        const imageUrl = `data:${mimeType};base64,${imageBase64}`\n\n                        logInternal('GeminiBatch', 'INFO', `✅ 获取到图片，MIME 类型: ${mimeType}`, { batchName })\n                        return { status: 'completed', imageUrl }\n                    }\n                }\n            }\n\n            return { status: 'failed', error: 'No image data in batch result' }\n        } else if (state === 'JOB_STATE_FAILED' || state === 'JOB_STATE_CANCELLED' || state === 'JOB_STATE_EXPIRED') {\n            return { status: 'failed', error: `Gemini Batch failed: ${state}` }\n        }\n\n        // 仍在处理中 (PENDING, RUNNING 等)\n        return { status: 'pending' }\n    } catch (error: unknown) {\n        const message = getErrorMessage(error)\n        const status = getErrorStatus(error)\n        logInternal('GeminiBatch', 'ERROR', 'Query error', { batchName, error: message, status })\n        // 如果是 404 或任务不存在，标记为失败（不再重试）\n        if (status === 404 || message.includes('404') || message.includes('not found') || message.includes('NOT_FOUND')) {\n            return { status: 'failed', error: `Batch task not found` }\n        }\n        return { status: 'pending' }\n    }\n}\n\n/**\n * 查询 Google Veo 视频任务状态\n * @param operationName 操作名称（如 operations/xxx）\n * @param apiKey Google AI API Key\n */\nexport async function queryGoogleVideoStatus(operationName: string, apiKey: string): Promise<TaskStatus> {\n    if (!apiKey) {\n        throw new Error('请配置 Google AI API Key')\n    }\n\n    const logPrefix = '[Veo Query]'\n\n    try {\n        const { GoogleGenAI, GenerateVideosOperation } = await import('@google/genai')\n        const ai = new GoogleGenAI({ apiKey })\n        const operation = new GenerateVideosOperation()\n        operation.name = operationName\n        const op = await ai.operations.getVideosOperation({ operation })\n\n        // 打印完整响应以便调试\n        logInternal('Veo', 'INFO', `${logPrefix} 原始响应`, {\n            operationName,\n            done: op.done,\n            hasError: !!op.error,\n            hasResponse: !!op.response,\n            responseKeys: op.response ? Object.keys(op.response) : [],\n            generatedVideosCount: op.response?.generatedVideos?.length ?? 0,\n            raiFilteredCount: (op.response as Record<string, unknown>)?.raiMediaFilteredCount ?? null,\n            raiFilteredReasons: (op.response as Record<string, unknown>)?.raiMediaFilteredReasons ?? null,\n        })\n\n        if (!op.done) {\n            return { status: 'pending' }\n        }\n\n        // 检查操作级错误\n        if (op.error) {\n            const errRecord = asRecord(op.error)\n            const message = (typeof errRecord?.message === 'string' && errRecord.message)\n                || (typeof errRecord?.statusMessage === 'string' && errRecord.statusMessage)\n                || 'Veo 任务失败'\n            logInternal('Veo', 'ERROR', `${logPrefix} 操作级错误`, { operationName, error: op.error })\n            return { status: 'failed', error: message }\n        }\n\n        const response = op.response\n        if (!response) {\n            logInternal('Veo', 'ERROR', `${logPrefix} done=true 但 response 为空`, { operationName })\n            return { status: 'failed', error: 'Veo 任务完成但响应体为空' }\n        }\n\n        // 检查 RAI 内容过滤\n        const responseRecord = asRecord(response) || {}\n        const raiFilteredCount = responseRecord.raiMediaFilteredCount\n        const raiFilteredReasons = responseRecord.raiMediaFilteredReasons\n\n        if (typeof raiFilteredCount === 'number' && raiFilteredCount > 0) {\n            const reasons = Array.isArray(raiFilteredReasons)\n                ? raiFilteredReasons.join(', ')\n                : '未知原因'\n            logInternal('Veo', 'ERROR', `${logPrefix} 视频被 RAI 安全策略过滤`, {\n                operationName,\n                raiFilteredCount,\n                raiFilteredReasons: reasons,\n            })\n            return {\n                status: 'failed',\n                error: `Veo 视频被安全策略过滤 (${raiFilteredCount} 个视频被过滤, 原因: ${reasons})`,\n            }\n        }\n\n        // 提取视频 URL\n        const generatedVideos = response.generatedVideos\n        if (Array.isArray(generatedVideos) && generatedVideos.length > 0) {\n            const first = generatedVideos[0]\n            const videoUri = first?.video?.uri\n\n            if (videoUri) {\n                logInternal('Veo', 'INFO', `${logPrefix} 成功获取视频`, {\n                    operationName,\n                    videoUri: videoUri.substring(0, 80),\n                })\n                return { status: 'completed', videoUrl: videoUri }\n            }\n\n            // video 对象存在但没有 uri，打印完整结构以便调试\n            logInternal('Veo', 'ERROR', `${logPrefix} generatedVideos[0] 存在但无 video.uri`, {\n                operationName,\n                firstVideo: JSON.stringify(first, null, 2),\n            })\n            return { status: 'failed', error: 'Veo 视频对象存在但缺少 URI' }\n        }\n\n        // generatedVideos 为空或不存在，打印完整 response 以便诊断\n        logInternal('Veo', 'ERROR', `${logPrefix} 无 generatedVideos`, {\n            operationName,\n            responseKeys: Object.keys(responseRecord),\n            fullResponse: JSON.stringify(responseRecord, null, 2).substring(0, 2000),\n            raiFilteredCount: raiFilteredCount ?? 'N/A',\n            raiFilteredReasons: raiFilteredReasons ?? 'N/A',\n        })\n        return { status: 'failed', error: 'Veo 任务完成但未返回视频 (generatedVideos 为空)' }\n    } catch (error: unknown) {\n        const message = getErrorMessage(error)\n        logInternal('Veo', 'ERROR', `${logPrefix} 查询异常`, { operationName, error: message })\n        return { status: 'failed', error: message }\n    }\n}\n\n/**\n * 查询 Seedance 视频任务状态\n * @param taskId 任务ID\n * @param apiKey 火山引擎 API Key\n */\nexport async function querySeedanceVideoStatus(taskId: string, apiKey: string): Promise<TaskStatus> {\n    if (!apiKey) {\n        throw new Error('请配置火山引擎 API Key')\n    }\n\n    try {\n        const queryResponse = await fetch(\n            `https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/${taskId}`,\n            {\n                method: 'GET',\n                headers: {\n                    'Content-Type': 'application/json',\n                    'Authorization': `Bearer ${apiKey}`\n                },\n                cache: 'no-store'\n            }\n        )\n\n        if (!queryResponse.ok) {\n            logInternal('Seedance', 'ERROR', `Status query failed: ${queryResponse.status}`)\n            return { status: 'pending' }\n        }\n\n        const queryData = await queryResponse.json()\n        const status = queryData.status\n\n        if (status === 'succeeded') {\n            const videoUrl = queryData.content?.video_url\n\n            if (videoUrl) {\n                return { status: 'completed', videoUrl }\n            }\n\n            return { status: 'failed', error: 'No video URL in response' }\n        } else if (status === 'failed') {\n            const errorObj = queryData.error || {}\n            const errorMessage = errorObj.message || 'Unknown error'\n            return { status: 'failed', error: errorMessage }\n        }\n\n        return { status: 'pending' }\n    } catch (error: unknown) {\n        logInternal('Seedance', 'ERROR', 'Query error', { error: getErrorMessage(error) })\n        return { status: 'pending' }\n    }\n}\n"
  },
  {
    "path": "src/lib/auth.ts",
    "content": "import { PrismaAdapter } from \"@next-auth/prisma-adapter\"\nimport CredentialsProvider from \"next-auth/providers/credentials\"\nimport bcrypt from \"bcryptjs\"\nimport { logAuthAction } from './logging/semantic'\nimport { prisma } from './prisma'\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const authOptions: any = {\n  adapter: PrismaAdapter(prisma),\n  // 🔥 允许从任意 Host 访问（解决局域网访问问题）\n  trustHost: true,\n  // 🔥 根据 URL 协议决定是否使用 Secure Cookie\n  // 局域网 HTTP 访问时需要关闭，否则 Cookie 无法设置\n  useSecureCookies: (process.env.NEXTAUTH_URL || '').startsWith('https://'),\n  providers: [\n    CredentialsProvider({\n      name: \"credentials\",\n      credentials: {\n        username: { label: \"Username\", type: \"text\" },\n        password: { label: \"Password\", type: \"password\" }\n      },\n      async authorize(credentials) {\n        if (!credentials?.username || !credentials?.password) {\n          logAuthAction('LOGIN', credentials?.username || 'unknown', { error: 'Missing credentials' })\n          return null\n        }\n\n        const user = await prisma.user.findUnique({\n          where: {\n            name: credentials.username\n          }\n        })\n\n        if (!user || !user.password) {\n          logAuthAction('LOGIN', credentials.username, { error: 'User not found' })\n          return null\n        }\n\n        // 验证密码\n        const isPasswordValid = await bcrypt.compare(credentials.password, user.password)\n\n        if (!isPasswordValid) {\n          logAuthAction('LOGIN', credentials.username, { error: 'Invalid password' })\n          return null\n        }\n\n        logAuthAction('LOGIN', user.name, { userId: user.id, success: true })\n\n        return {\n          id: user.id,\n          name: user.name,\n        }\n      }\n    })\n  ],\n  session: {\n    strategy: \"jwt\"\n  },\n  pages: {\n    signIn: \"/auth/signin\",\n  },\n  callbacks: {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    async jwt({ token, user }: any) {\n      if (user) {\n        token.id = user.id\n      }\n      return token\n    },\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    async session({ session, token }: any) {\n      if (token && session.user) {\n        session.user.id = token.id as string\n      }\n      return session\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/billing/cost.ts",
    "content": "/**\n * Billing cost center.\n *\n * Pricing is resolved from unified pricing catalog only.\n * No implicit fallback to hardcoded model tables is allowed.\n */\n\nimport { BillingOperationError } from './errors'\nimport {\n  parseModelKeyStrict,\n  type CapabilityValue,\n  type ModelCapabilities,\n} from '@/lib/model-config-contract'\nimport {\n  findBuiltinCapabilities,\n  listBuiltinCapabilityCatalog,\n} from '@/lib/model-capabilities/catalog'\nimport { validateCapabilitySelectionForModel } from '@/lib/model-capabilities/lookup'\nimport { resolveBuiltinPricing } from '@/lib/model-pricing/lookup'\nimport type { PricingApiType } from '@/lib/model-pricing/catalog'\n\nexport const USD_TO_CNY = 7.2\n\nexport const MARKUP = {\n  global: 1.0,\n  text: 1.0,\n  image: 1.0,\n  video: 1.0,\n  voice: 1.0,\n  voiceDesign: 1.0,\n  lipSync: 1.0,\n} as const\n\nexport type MarkupCategory = keyof typeof MARKUP\n\nexport type ApiType = 'text' | 'image' | 'video' | 'voice' | 'voice-design' | 'lip-sync'\nexport type UsageUnit = 'token' | 'image' | 'video' | 'second' | 'call'\n\nexport interface LlmCustomPricing {\n  inputPerMillion?: number\n  outputPerMillion?: number\n}\n\nexport interface MediaCustomPricing {\n  basePrice?: number\n  optionPrices?: Record<string, Record<string, number>>\n}\n\nexport interface ModelCustomPricing {\n  llm?: LlmCustomPricing\n  image?: MediaCustomPricing\n  video?: MediaCustomPricing\n}\n\nconst DEFAULT_VOICE_MODEL_ID = 'index-tts2'\nconst DEFAULT_VOICE_DESIGN_MODEL_ID = 'bailian-voice-design'\nconst DEFAULT_LIP_SYNC_MODEL_ID = 'kling'\n\nfunction getMarkup(category: MarkupCategory): number {\n  return MARKUP[category] ?? MARKUP.global\n}\n\nfunction parseModelId(model: string): string {\n  const parsed = parseModelKeyStrict(model)\n  return parsed?.modelId || model\n}\n\nfunction normalizeCapabilitySelections(\n  metadata: Record<string, unknown> | undefined,\n): Record<string, CapabilityValue> {\n  if (!metadata) return {}\n\n  const selections: Record<string, CapabilityValue> = {}\n  for (const [field, value] of Object.entries(metadata)) {\n    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n      selections[field] = value\n    }\n  }\n  return selections\n}\n\nfunction resolveModelPriceStrict(input: {\n  apiType: PricingApiType\n  model: string\n  selections?: Record<string, CapabilityValue>\n  customPricingFallback?: number | null\n}): number {\n  const result = resolveBuiltinPricing({\n    apiType: input.apiType,\n    model: input.model,\n    selections: input.selections,\n  })\n\n  if (result.status === 'resolved') return result.amount\n\n  if (result.status === 'ambiguous_model') {\n    throw new BillingOperationError(\n      'BILLING_PRICING_MODEL_AMBIGUOUS',\n      `Ambiguous ${input.apiType} pricing modelId: ${result.modelId}`,\n      {\n        apiType: input.apiType,\n        model: input.model,\n        modelId: result.modelId,\n        candidates: result.candidates.map((candidate) => `${candidate.provider}::${candidate.modelId}`),\n      },\n    )\n  }\n\n  if (result.status === 'missing_capability_match') {\n    throw new BillingOperationError(\n      'BILLING_CAPABILITY_PRICE_NOT_FOUND',\n      `No capability pricing tier matched for ${input.model}`,\n      {\n        apiType: input.apiType,\n        model: input.model,\n        selections: input.selections || {},\n      },\n    )\n  }\n\n  // Fallback to user custom pricing\n  if (typeof input.customPricingFallback === 'number') {\n    return input.customPricingFallback\n  }\n\n  const modelId = parseModelId(input.model)\n  throw new BillingOperationError(\n    'BILLING_UNKNOWN_MODEL',\n    `Unknown ${input.apiType} model pricing: ${input.model}`,\n    {\n      apiType: input.apiType,\n      model: input.model,\n      modelId,\n    },\n  )\n}\n\nfunction resolveTextUnitPrice(model: string, tokenType: 'input' | 'output', customPricingFallback?: number | null): number {\n  return resolveModelPriceStrict({\n    apiType: 'text',\n    model,\n    selections: { tokenType },\n    customPricingFallback,\n  })\n}\n\nfunction resolveVideoCapabilities(model: string): ModelCapabilities | undefined {\n  const parsed = parseModelKeyStrict(model)\n  if (parsed) {\n    return findBuiltinCapabilities('video', parsed.provider, parsed.modelId)\n  }\n\n  const candidates = listBuiltinCapabilityCatalog().filter(\n    (entry) => entry.modelType === 'video' && entry.modelId === model,\n  )\n  if (candidates.length !== 1) return undefined\n  return candidates[0].capabilities\n}\n\nfunction videoCapabilitySupportsField(\n  model: string,\n  field: 'resolution' | 'generationMode' | 'generateAudio' | 'duration',\n): boolean {\n  const capabilities = resolveVideoCapabilities(model)\n  const namespace = capabilities?.video\n  if (!namespace) return false\n\n  const options = (() => {\n    if (field === 'resolution') return namespace.resolutionOptions\n    if (field === 'generationMode') return namespace.generationModeOptions\n    if (field === 'generateAudio') return namespace.generateAudioOptions\n    return namespace.durationOptions\n  })()\n  return Array.isArray(options) && options.length > 0\n}\n\nfunction resolveVideoDurationRangeFromCapabilities(\n  model: string,\n): { min: number; max: number } | null {\n  const capabilities = resolveVideoCapabilities(model)\n\n  const options = capabilities?.video?.durationOptions\n  if (!Array.isArray(options) || options.length === 0) return null\n\n  const durations = options.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))\n  if (durations.length === 0) return null\n  return {\n    min: Math.min(...durations),\n    max: Math.max(...durations),\n  }\n}\n\nfunction resolveVideoDefaultGenerateAudioFromCapabilities(model: string): boolean | undefined {\n  const capabilities = resolveVideoCapabilities(model)\n  const options = capabilities?.video?.generateAudioOptions\n  if (!Array.isArray(options) || options.length === 0) return undefined\n  const normalized = options.filter((value): value is boolean => typeof value === 'boolean')\n  if (normalized.length === 0) return undefined\n  return normalized[0]\n}\n\nfunction validateVideoSelectionsAgainstCapabilitiesOrThrow(\n  model: string,\n  selections: Record<string, CapabilityValue>,\n) {\n  const capabilities = resolveVideoCapabilities(model)\n  if (!capabilities) return\n\n  const parsed = parseModelKeyStrict(model)\n  const modelKey = parsed ? `${parsed.provider}::${parsed.modelId}` : model\n  const issues = validateCapabilitySelectionForModel({\n    modelKey,\n    modelType: 'video',\n    capabilities,\n    selection: selections,\n    requireAllFields: false,\n  })\n  if (issues.length === 0) return\n\n  const selectedResolution = typeof selections.resolution === 'string' ? selections.resolution : undefined\n  const hasResolutionIssue = issues.some(\n    (issue) => issue.field.endsWith('.resolution') && issue.code === 'CAPABILITY_VALUE_NOT_ALLOWED',\n  )\n  if (hasResolutionIssue && selectedResolution) {\n    throw new BillingOperationError(\n      'BILLING_UNKNOWN_VIDEO_RESOLUTION',\n      `Unsupported video resolution pricing: ${selectedResolution}`,\n      {\n        apiType: 'video',\n        model,\n        resolution: selectedResolution,\n      },\n    )\n  }\n\n  const firstIssue = issues[0]\n  throw new BillingOperationError(\n    'BILLING_UNKNOWN_VIDEO_CAPABILITY_COMBINATION',\n    `Unsupported video capability pricing: ${firstIssue.field} ${firstIssue.message}`,\n    {\n      apiType: 'video',\n      model,\n      selections,\n      issue: {\n        code: firstIssue.code,\n        field: firstIssue.field,\n        message: firstIssue.message,\n      },\n    },\n  )\n}\n\nfunction applyVideoDurationScaling(input: {\n  amount: number\n  model: string\n  selections: Record<string, CapabilityValue>\n  hasDurationTier: boolean\n}): number {\n  if (input.hasDurationTier) return input.amount\n  const selectedDuration = input.selections.duration\n  if (typeof selectedDuration !== 'number' || !Number.isFinite(selectedDuration) || selectedDuration <= 0) {\n    return input.amount\n  }\n\n  const durationRange = resolveVideoDurationRangeFromCapabilities(input.model)\n  if (!durationRange) return input.amount\n\n  const baseDuration = durationRange.min <= 5 && durationRange.max >= 5\n    ? 5\n    : durationRange.min\n  if (baseDuration <= 0) return input.amount\n\n  return input.amount * (selectedDuration / baseDuration)\n}\n\nexport function calcText(\n  model: string,\n  inputTokens: number,\n  outputTokens: number,\n  customPricing?: ModelCustomPricing | null,\n): number {\n  const normalizedInput = Math.max(0, Number(inputTokens) || 0)\n  const normalizedOutput = Math.max(0, Number(outputTokens) || 0)\n\n  const inputFallback = typeof customPricing?.llm?.inputPerMillion === 'number' ? customPricing.llm.inputPerMillion : null\n  const outputFallback = typeof customPricing?.llm?.outputPerMillion === 'number' ? customPricing.llm.outputPerMillion : null\n  const inputUnitPrice = resolveTextUnitPrice(model, 'input', inputFallback)\n  const outputUnitPrice = resolveTextUnitPrice(model, 'output', outputFallback)\n  const rawCost = ((normalizedInput / 1_000_000) * inputUnitPrice) + ((normalizedOutput / 1_000_000) * outputUnitPrice)\n  return rawCost * getMarkup('text')\n}\n\nfunction resolveCustomMediaPrice(input: {\n  apiType: 'image' | 'video'\n  model: string\n  selections: Record<string, CapabilityValue>\n  pricing?: MediaCustomPricing\n}): { status: 'none' } | { status: 'resolved'; amount: number } | { status: 'invalid'; field: string } {\n  if (!input.pricing) return { status: 'none' }\n\n  let hasAnyPricing = false\n  let amount = 0\n  if (typeof input.pricing.basePrice === 'number') {\n    hasAnyPricing = true\n    amount += input.pricing.basePrice\n  }\n\n  const optionPrices = input.pricing.optionPrices\n  if (optionPrices) {\n    for (const [field, rawOptionMap] of Object.entries(optionPrices)) {\n      const optionMap = rawOptionMap || {}\n      if (Object.keys(optionMap).length === 0) continue\n      hasAnyPricing = true\n\n      const selectionValue = input.selections[field]\n      if (selectionValue === undefined) continue\n      const selectionKey = String(selectionValue)\n      const delta = optionMap[selectionKey]\n      if (typeof delta !== 'number' || !Number.isFinite(delta) || delta < 0) {\n        return { status: 'invalid', field }\n      }\n      amount += delta\n    }\n  }\n\n  if (!hasAnyPricing) return { status: 'none' }\n  return { status: 'resolved', amount }\n}\n\nexport function calcImage(\n  model: string,\n  count = 1,\n  metadata?: Record<string, unknown>,\n  customPricing?: ModelCustomPricing | null,\n): number {\n  const selections = normalizeCapabilitySelections(metadata)\n  const resolved = resolveBuiltinPricing({\n    apiType: 'image',\n    model,\n    selections,\n  })\n  let unitPrice: number | null = null\n  if (resolved.status === 'resolved') {\n    unitPrice = resolved.amount\n  } else if (resolved.status === 'ambiguous_model') {\n    throw new BillingOperationError(\n      'BILLING_PRICING_MODEL_AMBIGUOUS',\n      `Ambiguous image pricing modelId: ${resolved.modelId}`,\n      {\n        apiType: 'image',\n        model,\n        modelId: resolved.modelId,\n        candidates: resolved.candidates.map((candidate) => `${candidate.provider}::${candidate.modelId}`),\n      },\n    )\n  }\n\n  if (unitPrice === null) {\n    const customResolved = resolveCustomMediaPrice({\n      apiType: 'image',\n      model,\n      selections,\n      pricing: customPricing?.image,\n    })\n    if (customResolved.status === 'resolved') {\n      unitPrice = customResolved.amount\n    } else if (customResolved.status === 'invalid') {\n      throw new BillingOperationError(\n        'BILLING_CAPABILITY_PRICE_NOT_FOUND',\n        `No custom image price matched for field ${customResolved.field}`,\n        { apiType: 'image', model, field: customResolved.field, selections },\n      )\n    }\n  }\n\n  if (unitPrice === null) {\n    if (resolved.status === 'missing_capability_match') {\n      throw new BillingOperationError(\n        'BILLING_CAPABILITY_PRICE_NOT_FOUND',\n        `No capability pricing tier matched for ${model}`,\n        {\n          apiType: 'image',\n          model,\n          selections,\n        },\n      )\n    }\n    const modelId = parseModelId(model)\n    throw new BillingOperationError(\n      'BILLING_UNKNOWN_MODEL',\n      `Unknown image model pricing: ${model}`,\n      {\n        apiType: 'image',\n        model,\n        modelId,\n      },\n    )\n  }\n\n  const quantity = Math.max(0, Number(count) || 0)\n  return unitPrice * quantity * getMarkup('image')\n}\n\nexport function calcVideo(\n  model: string,\n  resolution = '720p',\n  count = 1,\n  metadata?: Record<string, unknown>,\n  customPricing?: ModelCustomPricing | null,\n): number {\n  const selections = normalizeCapabilitySelections(metadata)\n  if (\n    typeof selections.resolution !== 'string'\n    && videoCapabilitySupportsField(model, 'resolution')\n  ) {\n    selections.resolution = resolution\n  }\n  if (\n    typeof selections.generationMode !== 'string'\n    && videoCapabilitySupportsField(model, 'generationMode')\n  ) {\n    selections.generationMode = 'normal'\n  }\n  if (typeof selections.generateAudio !== 'boolean') {\n    const defaultGenerateAudio = resolveVideoDefaultGenerateAudioFromCapabilities(model)\n    if (typeof defaultGenerateAudio === 'boolean') {\n      selections.generateAudio = defaultGenerateAudio\n    }\n  }\n  validateVideoSelectionsAgainstCapabilitiesOrThrow(model, selections)\n\n  const resolutionResult = resolveBuiltinPricing({\n    apiType: 'video',\n    model,\n    selections,\n  })\n  if (resolutionResult.status === 'ambiguous_model') {\n    throw new BillingOperationError(\n      'BILLING_PRICING_MODEL_AMBIGUOUS',\n      `Ambiguous video pricing modelId: ${resolutionResult.modelId}`,\n      {\n        apiType: 'video',\n        model,\n        modelId: resolutionResult.modelId,\n        candidates: resolutionResult.candidates.map((candidate) => `${candidate.provider}::${candidate.modelId}`),\n      },\n    )\n  }\n  let unitPrice: number | null = null\n  if (resolutionResult.status === 'resolved') {\n    const resolvedEntry = resolutionResult.entry\n    const pricing = resolvedEntry && typeof resolvedEntry === 'object'\n      ? (resolvedEntry as { pricing?: { mode?: string; tiers?: Array<{ when?: { duration?: unknown } }> } }).pricing\n      : undefined\n    const hasDurationTier = pricing?.mode === 'capability'\n      && (pricing.tiers || []).some((tier) => typeof tier.when?.duration === 'number')\n    unitPrice = applyVideoDurationScaling({\n      amount: resolutionResult.amount,\n      model,\n      selections,\n      hasDurationTier,\n    })\n  }\n\n  if (unitPrice === null) {\n    const customResolved = resolveCustomMediaPrice({\n      apiType: 'video',\n      model,\n      selections,\n      pricing: customPricing?.video,\n    })\n    if (customResolved.status === 'resolved') {\n      unitPrice = customResolved.amount\n    } else if (customResolved.status === 'invalid') {\n      throw new BillingOperationError(\n        'BILLING_CAPABILITY_PRICE_NOT_FOUND',\n        `No custom video price matched for field ${customResolved.field}`,\n        { apiType: 'video', model, field: customResolved.field, selections },\n      )\n    }\n  }\n\n  if (unitPrice === null) {\n    if (resolutionResult.status === 'missing_capability_match') {\n      const pickedDuration = typeof selections.duration === 'number'\n        ? selections.duration\n        : null\n      const pickedResolution = selections.resolution as string\n      if (pickedDuration !== null) {\n        throw new BillingOperationError(\n          'BILLING_UNKNOWN_VIDEO_CAPABILITY_COMBINATION',\n          `Unsupported video capability pricing: resolution=${pickedResolution}, duration=${pickedDuration}`,\n          {\n            apiType: 'video',\n            model,\n            resolution: pickedResolution,\n            duration: pickedDuration,\n          },\n        )\n      }\n      throw new BillingOperationError(\n        'BILLING_UNKNOWN_VIDEO_RESOLUTION',\n        `Unsupported video resolution pricing: ${pickedResolution}`,\n        {\n          apiType: 'video',\n          model,\n          resolution: pickedResolution,\n        },\n      )\n    }\n    const modelId = parseModelId(model)\n    throw new BillingOperationError(\n      'BILLING_UNKNOWN_MODEL',\n      `Unknown video model pricing: ${model}`,\n      {\n        apiType: 'video',\n        model,\n        modelId,\n      },\n    )\n  }\n\n  const quantity = Math.max(0, Number(count) || 0)\n  return unitPrice * quantity * getMarkup('video')\n}\n\nexport function calcVoice(durationSeconds: number): number {\n  const seconds = Math.max(0, Number(durationSeconds) || 0)\n  const unitPrice = resolveModelPriceStrict({\n    apiType: 'voice',\n    model: DEFAULT_VOICE_MODEL_ID,\n  })\n  return unitPrice * seconds * getMarkup('voice')\n}\n\nexport function calcVoiceDesign(): number {\n  const unitPrice = resolveModelPriceStrict({\n    apiType: 'voice-design',\n    model: DEFAULT_VOICE_DESIGN_MODEL_ID,\n  })\n  return unitPrice * getMarkup('voiceDesign')\n}\n\nexport function calcLipSync(model = DEFAULT_LIP_SYNC_MODEL_ID): number {\n  const unitPrice = resolveModelPriceStrict({\n    apiType: 'lip-sync',\n    model,\n  })\n  return unitPrice * getMarkup('lipSync')\n}\n"
  },
  {
    "path": "src/lib/billing/currency.ts",
    "content": "export const BILLING_CURRENCY = 'CNY' as const\n\nexport type BillingCurrency = typeof BILLING_CURRENCY\n\n"
  },
  {
    "path": "src/lib/billing/errors.ts",
    "content": "export class InsufficientBalanceError extends Error {\n  public available: number\n  public required: number\n\n  constructor(required: number, available: number) {\n    super(`余额不足，需要 ¥${required.toFixed(4)}，当前可用 ¥${available.toFixed(4)}`)\n    this.name = 'InsufficientBalanceError'\n    this.required = required\n    this.available = available\n  }\n}\n\nexport type BillingOperationErrorCode =\n  | 'BILLING_CONFIRM_FAILED'\n  | 'BILLING_INVALID_FREEZE'\n  | 'BILLING_INVALID_API_TYPE'\n  | 'BILLING_FREEZE_NOT_PENDING'\n  | 'BILLING_INVALID_CHARGED_AMOUNT'\n  | 'BILLING_INVALID_DELTA'\n  | 'BILLING_FREEZE_EXPAND_FAILED'\n  | 'BILLING_UNKNOWN_MODEL'\n  | 'BILLING_UNKNOWN_VIDEO_CAPABILITY_COMBINATION'\n  | 'BILLING_UNKNOWN_VIDEO_RESOLUTION'\n  | 'BILLING_CAPABILITY_PRICE_NOT_FOUND'\n  | 'BILLING_PRICING_MODEL_AMBIGUOUS'\n  | 'BILLING_IDEMPOTENT_ALREADY_CONFIRMED'\n  | 'BILLING_IDEMPOTENT_IN_PROGRESS'\n  | 'BILLING_IDEMPOTENT_ROLLED_BACK'\n  | 'BILLING_INVALID_PROJECT'\n\nexport class BillingOperationError extends Error {\n  public readonly code: BillingOperationErrorCode\n  public readonly details?: Record<string, unknown>\n  public readonly cause?: unknown\n\n  constructor(\n    code: BillingOperationErrorCode,\n    message: string,\n    details?: Record<string, unknown>,\n    cause?: unknown,\n  ) {\n    super(message)\n    this.name = 'BillingOperationError'\n    this.code = code\n    this.details = details\n    this.cause = cause\n  }\n}\n"
  },
  {
    "path": "src/lib/billing/index.ts",
    "content": "export { getBillingMode, getBootBillingEnabled } from './mode'\nexport { BILLING_CURRENCY } from './currency'\nexport { InsufficientBalanceError } from './errors'\nexport { getProjectCostDetails, getProjectTotalCost, getUserCostDetails, getUserCostSummary } from './reporting'\nexport { addBalance, getBalance } from './ledger'\nexport {\n  handleBillingError,\n  prepareTaskBilling,\n  rollbackTaskBilling,\n  settleTaskBilling,\n  withImageBilling,\n  withLipSyncBilling,\n  withTextBilling,\n  withVideoBilling,\n  withVoiceBilling,\n  withVoiceDesignBilling,\n} from './service'\nexport { buildDefaultTaskBillingInfo, isBillableTaskType } from './task-policy'\nexport type { BillingMode, BillingRecordParams, BillingStatus, TaskBillingInfo } from './types'\n"
  },
  {
    "path": "src/lib/billing/ledger.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { Prisma } from '@prisma/client'\nimport { prisma } from '@/lib/prisma'\nimport { recordUsageCostOnly, buildBillingMeta } from './reporting'\nimport type { ApiType, UsageUnit } from './cost'\nimport { BillingOperationError } from './errors'\nimport { roundMoney, toMoneyNumber, type MoneyValue } from './money'\n\ntype LedgerRecordParams = {\n  projectId: string\n  action: string\n  apiType: ApiType\n  model: string\n  quantity: number\n  unit: UsageUnit\n  metadata?: Record<string, unknown>\n  episodeId?: string | null\n  taskType?: string | null\n}\n\nexport type FreezeSnapshot = {\n  id: string\n  userId: string\n  amount: number\n  status: string\n}\n\ntype BalanceSnapshot = {\n  id: string\n  userId: string\n  balance: number\n  frozenAmount: number\n  totalSpent: number\n  createdAt: Date\n  updatedAt: Date\n}\n\nconst MONEY_SCALE = 6\nconst MONEY_EPSILON = 1e-9\n\nfunction normalizeMoney(value: number): number {\n  return roundMoney(value, MONEY_SCALE)\n}\n\nfunction toBalanceSnapshot(balance: {\n  id: string\n  userId: string\n  balance: MoneyValue\n  frozenAmount: MoneyValue\n  totalSpent: MoneyValue\n  createdAt: Date\n  updatedAt: Date\n}): BalanceSnapshot {\n  return {\n    id: balance.id,\n    userId: balance.userId,\n    balance: toMoneyNumber(balance.balance),\n    frozenAmount: toMoneyNumber(balance.frozenAmount),\n    totalSpent: toMoneyNumber(balance.totalSpent),\n    createdAt: balance.createdAt,\n    updatedAt: balance.updatedAt,\n  }\n}\n\nexport async function getBalance(userId: string) {\n  const balance = await prisma.userBalance.findUnique({\n    where: { userId },\n  })\n\n  if (!balance) {\n    const created = await prisma.userBalance.create({\n      data: { userId, balance: 0, frozenAmount: 0, totalSpent: 0 },\n    })\n    return toBalanceSnapshot(created)\n  }\n\n  return toBalanceSnapshot(balance)\n}\n\nexport async function getFreezeByIdempotencyKey(idempotencyKey: string): Promise<FreezeSnapshot | null> {\n  if (!idempotencyKey || !idempotencyKey.trim()) return null\n  const freeze = await prisma.balanceFreeze.findUnique({\n    where: { idempotencyKey },\n    select: {\n      id: true,\n      userId: true,\n      amount: true,\n      status: true,\n    },\n  })\n  if (!freeze) return null\n  return {\n    id: freeze.id,\n    userId: freeze.userId,\n    amount: toMoneyNumber(freeze.amount),\n    status: freeze.status,\n  }\n}\n\nexport async function checkBalance(userId: string, requiredAmount: number): Promise<boolean> {\n  const balance = await getBalance(userId)\n  return balance.balance >= requiredAmount\n}\n\nexport async function freezeBalance(\n  userId: string,\n  amount: number,\n  options?: {\n    source?: string\n    taskId?: string\n    requestId?: string\n    idempotencyKey?: string\n    metadata?: Record<string, unknown>\n  },\n): Promise<string | null> {\n  const normalizedAmount = normalizeMoney(Number(amount))\n  if (!Number.isFinite(normalizedAmount) || normalizedAmount <= 0) {\n    return null\n  }\n\n  try {\n    const result = await prisma.$transaction(async (tx) => {\n      if (options?.idempotencyKey) {\n        const existing = await tx.balanceFreeze.findUnique({\n          where: { idempotencyKey: options.idempotencyKey },\n        })\n        if (existing) {\n          return existing.id\n        }\n      }\n\n      const balance = await tx.userBalance.findUnique({ where: { userId } })\n      if (!balance) {\n        await tx.userBalance.create({\n          data: { userId, balance: 0, frozenAmount: 0, totalSpent: 0 },\n        })\n      }\n\n      const updated = await tx.userBalance.updateMany({\n        where: {\n          userId,\n          balance: { gte: normalizedAmount },\n        },\n        data: {\n          balance: { decrement: normalizedAmount },\n          frozenAmount: { increment: normalizedAmount },\n        },\n      })\n      if (updated.count === 0) {\n        return null\n      }\n\n      const freezeId = `freeze_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`\n      await tx.balanceFreeze.create({\n        data: {\n          id: freezeId,\n          userId,\n          amount: normalizedAmount,\n          status: 'pending',\n          source: options?.source || 'sync',\n          taskId: options?.taskId || null,\n          requestId: options?.requestId || null,\n          idempotencyKey: options?.idempotencyKey || null,\n          metadata: options?.metadata ? JSON.stringify(options.metadata) : null,\n        },\n      })\n\n      return freezeId\n    })\n\n    return result\n  } catch (error) {\n    if (\n      options?.idempotencyKey\n      && error instanceof Prisma.PrismaClientKnownRequestError\n      && error.code === 'P2002'\n    ) {\n      const existing = await prisma.balanceFreeze.findUnique({\n        where: { idempotencyKey: options.idempotencyKey },\n        select: { id: true },\n      })\n      if (existing?.id) {\n        return existing.id\n      }\n    }\n    _ulogError('[Billing] freeze failed:', error)\n    return null\n  }\n}\n\nexport async function confirmChargeWithRecord(\n  freezeId: string,\n  recordParams: LedgerRecordParams,\n  options?: {\n    chargedAmount?: number\n  },\n): Promise<boolean> {\n  try {\n    await prisma.$transaction(async (tx) => {\n      const freeze = await tx.balanceFreeze.findUnique({ where: { id: freezeId } })\n      if (!freeze) {\n        throw new BillingOperationError('BILLING_INVALID_FREEZE', 'Invalid freeze record', { freezeId })\n      }\n      const freezeAmount = normalizeMoney(toMoneyNumber(freeze.amount))\n\n      if (freeze.status === 'confirmed') {\n        return\n      }\n\n      if (freeze.status !== 'pending') {\n        throw new BillingOperationError('BILLING_FREEZE_NOT_PENDING', 'Freeze is not pending', {\n          freezeId,\n          status: freeze.status,\n        })\n      }\n\n      const requested = Number(options?.chargedAmount)\n      const chargedAmount = normalizeMoney(Number.isFinite(requested) ? requested : freezeAmount)\n      if (chargedAmount < 0 || chargedAmount - freezeAmount > MONEY_EPSILON) {\n        throw new BillingOperationError('BILLING_INVALID_CHARGED_AMOUNT', 'Invalid chargedAmount', {\n          freezeId,\n          chargedAmount,\n          freezeAmount,\n        })\n      }\n\n      const refundAmount = normalizeMoney(Math.max(0, freezeAmount - chargedAmount))\n\n      const switched = await tx.balanceFreeze.updateMany({\n        where: {\n          id: freezeId,\n          status: 'pending',\n        },\n        data: { status: 'confirmed' },\n      })\n      if (switched.count === 0) {\n        const latest = await tx.balanceFreeze.findUnique({ where: { id: freezeId } })\n        if (latest?.status === 'confirmed') {\n          return\n        }\n        throw new BillingOperationError('BILLING_FREEZE_NOT_PENDING', 'Freeze is not pending', {\n          freezeId,\n          status: latest?.status || null,\n        })\n      }\n\n      const updatedBalance = await tx.userBalance.update({\n        where: { userId: freeze.userId },\n        data: {\n          frozenAmount: { decrement: freezeAmount },\n          totalSpent: { increment: chargedAmount },\n          ...(refundAmount > 0 ? { balance: { increment: refundAmount } } : {}),\n        },\n      })\n\n      if (chargedAmount > 0) {\n        await recordUsageCostOnly(tx, {\n          ...recordParams,\n          userId: freeze.userId,\n          cost: chargedAmount,\n          balanceAfter: toMoneyNumber(updatedBalance.balance),\n          freezeId: freeze.id,\n        })\n      }\n    }, {\n      maxWait: 10_000,\n      timeout: 10_000,\n    })\n\n    return true\n  } catch (error) {\n    _ulogError('[Billing] confirm charge failed:', error)\n    if (error instanceof BillingOperationError) {\n      throw error\n    }\n    if (error instanceof Error) {\n      throw new BillingOperationError('BILLING_CONFIRM_FAILED', error.message, { freezeId }, error)\n    }\n    throw new BillingOperationError('BILLING_CONFIRM_FAILED', `confirm charge failed: ${String(error)}`, { freezeId })\n  }\n}\n\nexport async function rollbackFreeze(freezeId: string): Promise<boolean> {\n  try {\n    await prisma.$transaction(async (tx) => {\n      const freeze = await tx.balanceFreeze.findUnique({ where: { id: freezeId } })\n      if (!freeze) {\n        throw new Error('Invalid freeze record')\n      }\n      const freezeAmount = normalizeMoney(toMoneyNumber(freeze.amount))\n      if (freeze.status === 'rolled_back') {\n        return\n      }\n      if (freeze.status !== 'pending') {\n        throw new Error('Freeze is not pending')\n      }\n\n      const switched = await tx.balanceFreeze.updateMany({\n        where: {\n          id: freezeId,\n          status: 'pending',\n        },\n        data: { status: 'rolled_back' },\n      })\n      if (switched.count === 0) {\n        const latest = await tx.balanceFreeze.findUnique({ where: { id: freezeId } })\n        if (latest?.status === 'rolled_back') {\n          return\n        }\n        throw new Error('Freeze is not pending')\n      }\n\n      await tx.userBalance.update({\n        where: { userId: freeze.userId },\n        data: {\n          balance: { increment: freezeAmount },\n          frozenAmount: { decrement: freezeAmount },\n        },\n      })\n    })\n\n    return true\n  } catch (error) {\n    _ulogError('[Billing] rollback freeze failed:', error)\n    return false\n  }\n}\n\nexport async function increasePendingFreezeAmount(freezeId: string, delta: number): Promise<boolean> {\n  const normalizedDelta = normalizeMoney(Number(delta))\n  if (!Number.isFinite(normalizedDelta) || normalizedDelta < 0) {\n    throw new BillingOperationError('BILLING_INVALID_DELTA', 'delta must be a non-negative number', {\n      freezeId,\n      delta,\n    })\n  }\n  if (normalizedDelta === 0) {\n    return true\n  }\n\n  try {\n    const result = await prisma.$transaction(async (tx) => {\n      const freeze = await tx.balanceFreeze.findUnique({ where: { id: freezeId } })\n      if (!freeze) {\n        throw new BillingOperationError('BILLING_INVALID_FREEZE', 'Invalid freeze record', { freezeId })\n      }\n      if (freeze.status === 'confirmed') {\n        return true\n      }\n      if (freeze.status !== 'pending') {\n        throw new BillingOperationError('BILLING_FREEZE_NOT_PENDING', 'Freeze is not pending', {\n          freezeId,\n          status: freeze.status,\n        })\n      }\n\n      const updated = await tx.userBalance.updateMany({\n        where: {\n          userId: freeze.userId,\n          balance: { gte: normalizedDelta },\n        },\n        data: {\n          balance: { decrement: normalizedDelta },\n          frozenAmount: { increment: normalizedDelta },\n        },\n      })\n      if (updated.count === 0) {\n        return false\n      }\n\n      const switched = await tx.balanceFreeze.updateMany({\n        where: {\n          id: freezeId,\n          status: 'pending',\n        },\n        data: {\n          amount: { increment: normalizedDelta },\n        },\n      })\n      if (switched.count === 0) {\n        throw new BillingOperationError('BILLING_FREEZE_NOT_PENDING', 'Freeze is not pending', { freezeId })\n      }\n      return true\n    })\n\n    return result\n  } catch (error) {\n    _ulogError('[Billing] increase pending freeze failed:', error)\n    if (error instanceof BillingOperationError) {\n      throw error\n    }\n    if (error instanceof Error) {\n      throw new BillingOperationError('BILLING_FREEZE_EXPAND_FAILED', error.message, { freezeId, delta: normalizedDelta }, error)\n    }\n    throw new BillingOperationError('BILLING_FREEZE_EXPAND_FAILED', `increase freeze failed: ${String(error)}`, { freezeId, delta: normalizedDelta })\n  }\n}\n\nexport async function recordShadowUsage(\n  userId: string,\n  params: {\n    projectId: string\n    episodeId?: string | null\n    taskType?: string | null\n    action: string\n    apiType: ApiType\n    model: string\n    quantity: number\n    unit: UsageUnit\n    cost: number\n    metadata?: Record<string, unknown>\n  },\n): Promise<boolean> {\n  try {\n    await prisma.$transaction(async (tx) => {\n      const balance = await tx.userBalance.upsert({\n        where: { userId },\n        create: { userId, balance: 0, frozenAmount: 0, totalSpent: 0 },\n        update: {},\n      })\n\n      const metadataSummary = params.metadata\n        ? JSON.stringify(params.metadata).slice(0, 500)\n        : ''\n\n      await tx.balanceTransaction.create({\n        data: {\n          userId,\n          type: 'shadow_consume',\n          amount: 0,\n          balanceAfter: toMoneyNumber(balance.balance),\n          description: `[SHADOW] ${params.action} - ${params.model} - ¥${params.cost.toFixed(4)}${metadataSummary ? ` | ${metadataSummary}` : ''}`,\n          relatedId: null,\n          freezeId: null,\n          projectId: params.projectId || null,\n          episodeId: params.episodeId || null,\n          taskType: params.taskType || params.action || null,\n          billingMeta: buildBillingMeta(params),\n        },\n      })\n    })\n    return true\n  } catch (error) {\n    _ulogError('[Billing] record shadow usage failed:', error)\n    return false\n  }\n}\n\ntype AddBalanceOptions = {\n  reason?: string\n  operatorId?: string\n  externalOrderId?: string\n  idempotencyKey?: string\n  type?: 'recharge' | 'adjust'\n}\n\nfunction resolveAddBalanceOptions(reasonOrOptions?: string | AddBalanceOptions): AddBalanceOptions {\n  if (typeof reasonOrOptions === 'string') {\n    return { reason: reasonOrOptions, type: 'recharge' }\n  }\n  return {\n    reason: reasonOrOptions?.reason,\n    operatorId: reasonOrOptions?.operatorId,\n    externalOrderId: reasonOrOptions?.externalOrderId,\n    idempotencyKey: reasonOrOptions?.idempotencyKey,\n    type: reasonOrOptions?.type || 'recharge',\n  }\n}\n\nexport async function addBalance(userId: string, amount: number, reasonOrOptions?: string | AddBalanceOptions): Promise<boolean> {\n  try {\n    if (!Number.isFinite(amount) || amount <= 0) {\n      throw new Error('amount must be a positive number')\n    }\n    const options = resolveAddBalanceOptions(reasonOrOptions)\n    const relatedId = options.externalOrderId || null\n\n    await prisma.$transaction(async (tx) => {\n      if (options.idempotencyKey) {\n        const existing = await tx.balanceTransaction.findFirst({\n          where: {\n            userId,\n            type: options.type || 'recharge',\n            idempotencyKey: options.idempotencyKey,\n          },\n          select: { id: true },\n        })\n        if (existing) {\n          return\n        }\n      }\n\n      const updatedBalance = await tx.userBalance.upsert({\n        where: { userId },\n        create: { userId, balance: amount, frozenAmount: 0, totalSpent: 0 },\n        update: { balance: { increment: amount } },\n      })\n\n      const auditSummary = JSON.stringify({\n        reason: options.reason || null,\n        operatorId: options.operatorId || null,\n        externalOrderId: options.externalOrderId || null,\n        idempotencyKey: options.idempotencyKey || null,\n      })\n\n      await tx.balanceTransaction.create({\n        data: {\n          userId,\n          type: options.type || 'recharge',\n          amount,\n          balanceAfter: toMoneyNumber(updatedBalance.balance),\n          description: `${options.reason || 'balance recharge'}${auditSummary ? ` | audit=${auditSummary}` : ''}`,\n          relatedId,\n          freezeId: null,\n          operatorId: options.operatorId || null,\n          externalOrderId: options.externalOrderId || null,\n          idempotencyKey: options.idempotencyKey || null,\n        },\n      })\n    })\n\n    _ulogInfo(`[Balance] add balance success: userId=${userId}, amount=¥${amount}, reason=${options.reason || 'N/A'}`)\n    return true\n  } catch (error) {\n    _ulogError('[Balance] add balance failed:', error)\n    return false\n  }\n}\n"
  },
  {
    "path": "src/lib/billing/mode.ts",
    "content": "import type { BillingMode } from './types'\n\nconst VALID_MODES: BillingMode[] = ['OFF', 'SHADOW', 'ENFORCE']\n\nfunction normalizeMode(input: unknown): BillingMode | null {\n  if (typeof input !== 'string') return null\n  const upper = input.toUpperCase()\n  if (!VALID_MODES.includes(upper as BillingMode)) return null\n  return upper as BillingMode\n}\n\nfunction getModeFromEnv(): BillingMode {\n  return normalizeMode(process.env.BILLING_MODE) || 'OFF'\n}\n\nexport async function getBillingMode(): Promise<BillingMode> {\n  return getModeFromEnv()\n}\n\nexport function getBootBillingEnabled() {\n  return getModeFromEnv() === 'ENFORCE'\n}\n"
  },
  {
    "path": "src/lib/billing/money.ts",
    "content": "import { Prisma } from '@prisma/client'\n\nexport type MoneyValue = Prisma.Decimal | number | string | null | undefined\n\nexport function toMoneyNumber(value: MoneyValue): number {\n  if (value === null || value === undefined) return 0\n  if (typeof value === 'number') {\n    return Number.isFinite(value) ? value : 0\n  }\n  if (typeof value === 'string') {\n    const parsed = Number(value)\n    return Number.isFinite(parsed) ? parsed : 0\n  }\n  if (value instanceof Prisma.Decimal) {\n    return value.toNumber()\n  }\n  const decimalLike = value as { toNumber?: () => number; toString?: () => string }\n  if (typeof decimalLike.toNumber === 'function') {\n    const parsed = decimalLike.toNumber()\n    return Number.isFinite(parsed) ? parsed : 0\n  }\n  if (typeof decimalLike.toString === 'function') {\n    const parsed = Number(decimalLike.toString())\n    return Number.isFinite(parsed) ? parsed : 0\n  }\n  return 0\n}\n\nexport function roundMoney(value: number, scale = 6): number {\n  const factor = Math.pow(10, scale)\n  return Math.round(value * factor) / factor\n}\n"
  },
  {
    "path": "src/lib/billing/reporting.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport type { Prisma } from '@prisma/client'\nimport { prisma } from '@/lib/prisma'\nimport type { ApiType, UsageUnit } from './cost'\nimport { BillingOperationError } from './errors'\nimport { toMoneyNumber } from './money'\n\ninterface RecordParams {\n  projectId: string\n  userId: string\n  action: string\n  metadata?: Record<string, unknown>\n}\n\ninterface PureRecordParams extends RecordParams {\n  apiType: ApiType\n  model: string\n  quantity: number\n  unit: UsageUnit\n  cost: number\n  balanceAfter: number\n  freezeId?: string\n  episodeId?: string | null\n  taskType?: string | null\n}\n\nconst VIRTUAL_PROJECT_IDS = new Set(['asset-hub', 'global-asset-hub', 'system'])\n\nfunction isProjectScoped(projectId: string): boolean {\n  return Boolean(projectId && !VIRTUAL_PROJECT_IDS.has(projectId))\n}\n\n/**\n * 从计费参数中提取展示用的详细信息，序列化为 JSON 存入 billingMeta\n * 前端按 unit 字段决定展示方式：\n *   image  → \"3张 · 2K\"\n *   video  → \"5秒 · 720p\"\n *   token  → \"1500 tokens\"\n *   second → \"30秒\"\n *   call   → \"1次\"\n */\nexport function buildBillingMeta(params: {\n  quantity: number\n  unit: string\n  model: string\n  apiType: string\n  metadata?: Record<string, unknown>\n}): string {\n  // 尝试从 model composite ID 提取短名 \"provider:xxx::model\" → \"model\"\n  const modelShort = params.model.includes('::')\n    ? params.model.split('::').pop() ?? params.model\n    : params.model\n\n  const meta: Record<string, unknown> = {\n    quantity: params.quantity,\n    unit: params.unit,\n    model: modelShort,\n    apiType: params.apiType,\n  }\n\n  // 从 pricingSelections 提取 capability 字段（图片分辨率、视频时长/分辨率等）\n  const selections = params.metadata?.pricingSelections\n  if (selections && typeof selections === 'object') {\n    const sel = selections as Record<string, unknown>\n    if (sel.resolution) meta.resolution = sel.resolution\n    if (sel.duration) meta.duration = sel.duration\n    if (sel.generateAudio !== undefined) meta.generateAudio = sel.generateAudio\n    if (sel.generationMode) meta.generationMode = sel.generationMode\n  }\n\n  // 文本计费的 token 信息\n  if (params.metadata?.inputTokens) meta.inputTokens = params.metadata.inputTokens\n  if (params.metadata?.outputTokens) meta.outputTokens = params.metadata.outputTokens\n\n  // 实际使用的模型列表（复合模型场景）\n  if (Array.isArray(params.metadata?.actualModels) && (params.metadata.actualModels as unknown[]).length > 0) {\n    meta.actualModels = params.metadata.actualModels\n  }\n\n  return JSON.stringify(meta)\n}\n\nexport async function recordUsageCostOnly(\n  txOrPrisma: Prisma.TransactionClient | typeof prisma,\n  params: PureRecordParams,\n): Promise<void> {\n  const hasProject = isProjectScoped(params.projectId)\n\n  if (hasProject) {\n    const project = await txOrPrisma.project.findUnique({\n      where: { id: params.projectId },\n      select: { id: true },\n    })\n    if (!project) {\n      throw new BillingOperationError('BILLING_INVALID_PROJECT', `project not found for billing: ${params.projectId}`, {\n        projectId: params.projectId,\n        action: params.action,\n        apiType: params.apiType,\n      })\n    }\n\n    await txOrPrisma.usageCost.create({\n      data: {\n        projectId: params.projectId,\n        userId: params.userId,\n        apiType: params.apiType,\n        model: params.model,\n        action: params.action,\n        quantity: params.quantity,\n        unit: params.unit,\n        cost: params.cost,\n        metadata: params.metadata ? JSON.stringify(params.metadata) : null,\n      },\n    })\n  } else {\n    _ulogInfo(`[计费] 跳过 UsageCost 记录 (projectId=${params.projectId})，仅记录流水`)\n  }\n\n  await txOrPrisma.balanceTransaction.create({\n    data: {\n      userId: params.userId,\n      type: 'consume',\n      amount: -params.cost,\n      balanceAfter: params.balanceAfter,\n      description: `${params.action} - ${params.model}${hasProject ? '' : ' (Asset Hub)'}`,\n      relatedId: params.freezeId || null,\n      freezeId: params.freezeId || null,\n      projectId: hasProject ? params.projectId : null,\n      episodeId: params.episodeId || null,\n      taskType: params.taskType || params.action || null,\n      billingMeta: buildBillingMeta(params),\n    },\n  })\n\n  _ulogInfo(`[计费] ${params.action} - ${params.model} - ¥${params.cost.toFixed(4)} (已记录${hasProject ? '' : '，无项目归属'})`)\n}\n\nexport async function getProjectTotalCost(projectId: string): Promise<number> {\n  try {\n    const result = await prisma.usageCost.aggregate({\n      where: { projectId },\n      _sum: { cost: true },\n    })\n    return toMoneyNumber(result._sum.cost)\n  } catch (error) {\n    _ulogError('[计费] 查询项目总费用失败:', error)\n    return 0\n  }\n}\n\nexport async function getProjectCostDetails(projectId: string) {\n  const byTypeRaw = await prisma.usageCost.groupBy({\n    by: ['apiType'],\n    where: { projectId },\n    _sum: { cost: true },\n    _count: true,\n  })\n\n  const byActionRaw = await prisma.usageCost.groupBy({\n    by: ['action'],\n    where: { projectId },\n    _sum: { cost: true },\n    _count: true,\n  })\n\n  const recentRecordsRaw = await prisma.usageCost.findMany({\n    where: { projectId },\n    orderBy: { createdAt: 'desc' },\n    take: 50,\n  })\n\n  const byType = byTypeRaw.map((item) => ({\n    ...item,\n    _sum: {\n      ...item._sum,\n      cost: toMoneyNumber(item._sum.cost),\n    },\n  }))\n  const byAction = byActionRaw.map((item) => ({\n    ...item,\n    _sum: {\n      ...item._sum,\n      cost: toMoneyNumber(item._sum.cost),\n    },\n  }))\n  const recentRecords = recentRecordsRaw.map((item) => ({\n    ...item,\n    cost: toMoneyNumber(item.cost),\n  }))\n\n  return {\n    total: await getProjectTotalCost(projectId),\n    byType,\n    byAction,\n    recentRecords,\n  }\n}\n\nexport async function getUserCostSummary(userId: string) {\n  try {\n    const byProjectRaw = await prisma.usageCost.groupBy({\n      by: ['projectId'],\n      where: { userId },\n      _sum: { cost: true },\n      _count: true,\n    })\n\n    const totalResult = await prisma.usageCost.aggregate({\n      where: { userId },\n      _sum: { cost: true },\n    })\n\n    return {\n      total: toMoneyNumber(totalResult._sum.cost),\n      byProject: byProjectRaw.map((item) => ({\n        ...item,\n        _sum: {\n          ...item._sum,\n          cost: toMoneyNumber(item._sum.cost),\n        },\n      })),\n    }\n  } catch (error) {\n    _ulogError('[计费] 查询用户费用汇总失败:', error)\n    return {\n      total: 0,\n      byProject: [],\n    }\n  }\n}\n\nexport async function getUserCostDetails(userId: string, page = 1, pageSize = 20) {\n  const skip = (page - 1) * pageSize\n\n  const [recordsRaw, total] = await Promise.all([\n    prisma.usageCost.findMany({\n      where: { userId },\n      orderBy: { createdAt: 'desc' },\n      skip,\n      take: pageSize,\n    }),\n    prisma.usageCost.count({ where: { userId } }),\n  ])\n\n  const records = recordsRaw.map((item) => ({\n    ...item,\n    cost: toMoneyNumber(item.cost),\n  }))\n\n  return {\n    records,\n    total,\n    page,\n    pageSize,\n    totalPages: Math.ceil(total / pageSize),\n  }\n}\n"
  },
  {
    "path": "src/lib/billing/runtime-usage.ts",
    "content": "import { AsyncLocalStorage } from 'node:async_hooks'\n\nexport interface TextUsageEntry {\n  model: string\n  inputTokens: number\n  outputTokens: number\n}\n\ntype TextUsageStore = {\n  textUsage: TextUsageEntry[]\n}\n\nconst usageStore = new AsyncLocalStorage<TextUsageStore>()\n\nexport async function withTextUsageCollection<T>(\n  fn: () => Promise<T>,\n): Promise<{ result: T; textUsage: TextUsageEntry[] }> {\n  return await usageStore.run({ textUsage: [] }, async () => {\n    const result = await fn()\n    const store = usageStore.getStore()\n    return {\n      result,\n      textUsage: store?.textUsage ? [...store.textUsage] : [],\n    }\n  })\n}\n\nexport function recordTextUsage(entry: TextUsageEntry) {\n  const store = usageStore.getStore()\n  if (!store) return\n  store.textUsage.push({\n    model: entry.model,\n    inputTokens: Math.max(0, Math.floor(entry.inputTokens || 0)),\n    outputTokens: Math.max(0, Math.floor(entry.outputTokens || 0)),\n  })\n}\n"
  },
  {
    "path": "src/lib/billing/service.ts",
    "content": "import { createHash, randomUUID } from 'node:crypto'\nimport { NextResponse } from 'next/server'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { getLogContext } from '@/lib/logging/context'\nimport { prisma } from '@/lib/prisma'\nimport { parseModelKeyStrict } from '@/lib/model-config-contract'\nimport {\n  calcImage,\n  calcLipSync,\n  calcText,\n  calcVideo,\n  calcVoice,\n  calcVoiceDesign,\n  type ModelCustomPricing,\n} from './cost'\nimport {\n  confirmChargeWithRecord,\n  freezeBalance,\n  getBalance,\n  getFreezeByIdempotencyKey,\n  increasePendingFreezeAmount,\n  recordShadowUsage,\n  rollbackFreeze,\n} from './ledger'\nimport type { ApiType, UsageUnit } from './cost'\nimport { getBillingMode } from './mode'\nimport { BillingOperationError, InsufficientBalanceError } from './errors'\nimport { roundMoney } from './money'\nimport { withTextUsageCollection, type TextUsageEntry } from './runtime-usage'\nimport type {\n  BillingRecordParams,\n  TaskBillingInfo,\n} from './types'\nimport { BUILTIN_PRICING_VERSION } from '@/lib/model-pricing/version'\n\ntype CostInput = {\n  apiType: ApiType\n  model: string\n  quantity: number\n  unit: UsageUnit\n  metadata?: Record<string, unknown>\n  quotedCost?: number\n  maxCost?: number\n  customPricing?: ModelCustomPricing | null\n}\n\ntype SyncBillingParams<T> = {\n  userId: string\n  projectId: string\n  action: string\n  apiType: ApiType\n  model: string\n  quantity: number\n  unit: UsageUnit\n  metadata?: Record<string, unknown>\n  quotedCost?: number\n  maxCost?: number\n  customPricing?: ModelCustomPricing | null\n  extractActualQuantity?: (result: T) => number | null | undefined\n}\n\ntype ResolvedActual = {\n  actualCost: number\n  actualQuantity: number\n  metadata?: Record<string, unknown>\n}\n\ntype UsageByModel = Record<string, { inputTokens: number; outputTokens: number; cost: number }>\n\nconst MONEY_SCALE = 6\nconst MONEY_EPSILON = 1e-9\n\nfunction normalizeMoney(value: number): number {\n  const numeric = Number(value)\n  if (!Number.isFinite(numeric)) return 0\n  return roundMoney(Math.max(0, numeric), MONEY_SCALE)\n}\n\nfunction asNumber(value: unknown): number | null {\n  const n = Number(value)\n  if (!Number.isFinite(n)) return null\n  return n\n}\n\nfunction resolveCost(input: CostInput) {\n  const asMoney = (value: number) => normalizeMoney(value)\n\n  if (typeof input.maxCost === 'number' && input.maxCost >= 0) {\n    return asMoney(input.maxCost)\n  }\n\n  if (typeof input.quotedCost === 'number' && input.quotedCost >= 0) {\n    return asMoney(input.quotedCost)\n  }\n\n  switch (input.apiType) {\n    case 'text': {\n      const inputTokens = Number(input.metadata?.inputTokens ?? Math.floor(input.quantity * 0.7))\n      const outputTokens = Number(input.metadata?.outputTokens ?? Math.max(input.quantity - inputTokens, 0))\n      return asMoney(calcText(input.model, Math.max(inputTokens, 0), Math.max(outputTokens, 0), input.customPricing))\n    }\n    case 'image':\n      return asMoney(calcImage(input.model, input.quantity, input.metadata, input.customPricing))\n    case 'video': {\n      const resolution = typeof input.metadata?.resolution === 'string' ? input.metadata.resolution : '720p'\n      return asMoney(calcVideo(input.model, resolution, input.quantity, input.metadata, input.customPricing))\n    }\n    case 'voice':\n      return asMoney(calcVoice(input.quantity))\n    case 'voice-design':\n      return asMoney(calcVoiceDesign())\n    case 'lip-sync':\n      return asMoney(calcLipSync(input.model))\n    default:\n      throw new BillingOperationError('BILLING_INVALID_API_TYPE', `Unsupported billing apiType: ${String(input.apiType)}`, {\n        apiType: input.apiType,\n        model: input.model,\n      })\n  }\n}\n\nfunction resolveTextCostFromUsage(\n  usage: TextUsageEntry[],\n  customPricing?: ModelCustomPricing | null,\n): ResolvedActual | null {\n  if (!Array.isArray(usage) || usage.length === 0) return null\n\n  let inputTokens = 0\n  let outputTokens = 0\n  let cost = 0\n  const byModel: UsageByModel = {}\n\n  for (const item of usage) {\n    const inTokens = Math.max(0, Math.floor(Number(item.inputTokens || 0)))\n    const outTokens = Math.max(0, Math.floor(Number(item.outputTokens || 0)))\n    const model = item.model || 'unknown'\n    const hasBillableTokens = inTokens > 0 || outTokens > 0\n    const itemCost = hasBillableTokens ? normalizeMoney(calcText(model, inTokens, outTokens, customPricing)) : 0\n\n    inputTokens += inTokens\n    outputTokens += outTokens\n    cost += itemCost\n\n    if (!byModel[model]) {\n      byModel[model] = { inputTokens: 0, outputTokens: 0, cost: 0 }\n    }\n    byModel[model].inputTokens += inTokens\n    byModel[model].outputTokens += outTokens\n    byModel[model].cost += itemCost\n  }\n\n  return {\n    actualCost: normalizeMoney(cost),\n    actualQuantity: inputTokens + outputTokens,\n    metadata: {\n      actualInputTokens: inputTokens,\n      actualOutputTokens: outputTokens,\n      usageByModel: byModel,\n    },\n  }\n}\n\nfunction resolveRecordModel(defaultModel: string, metadata?: Record<string, unknown>) {\n  const usageByModelValue = metadata?.usageByModel\n  if (!usageByModelValue || typeof usageByModelValue !== 'object' || Array.isArray(usageByModelValue)) {\n    return {\n      model: defaultModel,\n      actualModels: [] as string[],\n    }\n  }\n  const actualModels = Object.keys(usageByModelValue as UsageByModel).filter((item) => typeof item === 'string' && item.trim())\n  if (actualModels.length === 0) {\n    return {\n      model: defaultModel,\n      actualModels,\n    }\n  }\n  if (actualModels.length === 1) {\n    return {\n      model: actualModels[0],\n      actualModels,\n    }\n  }\n  return {\n    model: 'multi-model',\n    actualModels,\n  }\n}\n\nasync function executeWithUsage<T>(\n  apiType: ApiType,\n  execute: () => Promise<T>,\n): Promise<{ result: T; textUsage: TextUsageEntry[] }> {\n  if (apiType !== 'text') {\n    return {\n      result: await execute(),\n      textUsage: [],\n    }\n  }\n  return await withTextUsageCollection(execute)\n}\n\nfunction clampChargedCost(actualCost: number, freezeCost: number) {\n  const normalizedActual = normalizeMoney(actualCost)\n  const normalizedFreeze = normalizeMoney(freezeCost)\n  if (normalizedActual <= normalizedFreeze + MONEY_EPSILON) {\n    return normalizedActual\n  }\n  _ulogError('[Billing] actual cost exceeds frozen max, overage freeze required', {\n    actualCost: normalizedActual,\n    frozenCost: normalizedFreeze,\n    requiredOverage: normalizeMoney(normalizedActual - normalizedFreeze),\n  })\n  return normalizedActual\n}\n\nasync function ensureFreezeCoverage(params: {\n  freezeId: string\n  userId: string\n  actualCost: number\n  quotedCost: number\n}): Promise<number> {\n  const normalizedQuoted = normalizeMoney(params.quotedCost)\n  const chargedCost = clampChargedCost(params.actualCost, normalizedQuoted)\n  if (chargedCost <= normalizedQuoted + MONEY_EPSILON) {\n    return chargedCost\n  }\n\n  const overage = normalizeMoney(chargedCost - normalizedQuoted)\n  if (overage <= MONEY_EPSILON) {\n    return chargedCost\n  }\n  const expanded = await increasePendingFreezeAmount(params.freezeId, overage)\n  if (expanded) {\n    return chargedCost\n  }\n\n  await rollbackFreeze(params.freezeId)\n  const balance = await getBalance(params.userId)\n  throw new InsufficientBalanceError(chargedCost, balance.balance)\n}\n\nfunction resolveActualForSync<T>(\n  params: SyncBillingParams<T>,\n  result: T,\n  textUsage: TextUsageEntry[],\n  quotedCost: number,\n): ResolvedActual {\n  const textResolved = resolveTextCostFromUsage(textUsage, params.customPricing)\n  if (params.apiType === 'text' && textResolved) {\n    if (textResolved.actualQuantity > 0) {\n      return textResolved\n    }\n    return {\n      actualCost: quotedCost,\n      actualQuantity: params.quantity,\n      metadata: {\n        ...(textResolved.metadata || {}),\n      },\n    }\n  }\n\n  if (params.extractActualQuantity) {\n    const actualQuantity = asNumber(params.extractActualQuantity(result))\n    if (actualQuantity !== null && actualQuantity >= 0) {\n      return {\n        actualCost: resolveCost({\n          apiType: params.apiType,\n          model: params.model,\n          quantity: actualQuantity,\n          unit: params.unit,\n          metadata: params.metadata,\n          customPricing: params.customPricing,\n        }),\n        actualQuantity,\n      }\n    }\n  }\n\n  return {\n    actualCost: quotedCost,\n    actualQuantity: params.quantity,\n  }\n}\n\nfunction resolveTaskActual(\n  info: Extract<TaskBillingInfo, { billable: true }>,\n  quotedCost: number,\n  options?: {\n    result?: Record<string, unknown> | void\n    textUsage?: TextUsageEntry[]\n  },\n): ResolvedActual {\n  const textResolved = resolveTextCostFromUsage(options?.textUsage || [])\n  if (info.apiType === 'text' && textResolved) {\n    if (textResolved.actualQuantity > 0) {\n      return textResolved\n    }\n    return {\n      actualCost: quotedCost,\n      actualQuantity: info.quantity,\n      metadata: {\n        ...(textResolved.metadata || {}),\n      },\n    }\n  }\n\n  const payload = options?.result && typeof options.result === 'object' ? options.result : null\n  const actualQuantity = payload\n    ? asNumber(\n      (payload as Record<string, unknown>).actualQuantity\n      ?? (payload as Record<string, unknown>).actualSeconds\n      ?? (payload as Record<string, unknown>).actualDurationSeconds\n      ?? (payload as Record<string, unknown>).actualCharacters\n    )\n    : null\n\n  if (actualQuantity !== null && actualQuantity >= 0) {\n    return {\n      actualCost: resolveCost({\n        apiType: info.apiType,\n        model: info.model,\n        quantity: actualQuantity,\n        unit: info.unit,\n        metadata: info.metadata,\n      }),\n      actualQuantity,\n    }\n  }\n\n  return {\n    actualCost: resolveCost({\n      apiType: info.apiType,\n      model: info.model,\n      quantity: info.quantity,\n      unit: info.unit,\n      metadata: info.metadata,\n      quotedCost: info.maxFrozenCost,\n    }),\n    actualQuantity: info.quantity,\n  }\n}\n\nfunction buildSyncBillingKey<T>(params: SyncBillingParams<T>, recordParams: BillingRecordParams) {\n  if (recordParams.billingKey) return recordParams.billingKey\n\n  const metadataFingerprint = JSON.stringify({\n    ...(recordParams.metadata || {}),\n    ...(params.metadata || {}),\n  })\n  const requestId =\n    recordParams.requestId\n    || (typeof recordParams.metadata?.requestId === 'string' ? recordParams.metadata.requestId : null)\n    || getLogContext().requestId\n\n  if (requestId) {\n    const digest = createHash('sha1')\n      .update(`${params.userId}:${params.projectId}:${params.action}:${params.apiType}:${params.model}:${params.quantity}:${metadataFingerprint}:${requestId}`)\n      .digest('hex')\n      .slice(0, 16)\n    return `sync_${requestId}_${digest}`\n  }\n\n  return `sync_${randomUUID()}`\n}\n\nasync function withSyncBillingCore<T>(\n  params: SyncBillingParams<T>,\n  recordParams: BillingRecordParams,\n  execute: () => Promise<T>,\n): Promise<T> {\n  const pricingVersion = BUILTIN_PRICING_VERSION\n  const pricingSelections = params.metadata || {}\n  const mode = await getBillingMode()\n  if (mode === 'OFF') {\n    return await execute()\n  }\n\n  const quotedCost = resolveCost({\n    apiType: params.apiType,\n    model: params.model,\n    quantity: params.quantity,\n    unit: params.unit,\n    metadata: params.metadata,\n    quotedCost: params.quotedCost,\n    maxCost: params.maxCost,\n    customPricing: params.customPricing,\n  })\n\n  if (quotedCost <= 0) {\n    return await execute()\n  }\n\n  if (mode === 'SHADOW') {\n    const { result, textUsage } = await executeWithUsage(params.apiType, execute)\n    const actual = resolveActualForSync(params, result, textUsage, quotedCost)\n    await recordShadowUsage(params.userId, {\n      projectId: params.projectId,\n      taskType: params.action || null,\n      action: params.action,\n      apiType: params.apiType,\n      model: params.model,\n      quantity: actual.actualQuantity,\n      unit: params.unit,\n      cost: actual.actualCost,\n      metadata: {\n        ...(recordParams.metadata || {}),\n        ...(params.metadata || {}),\n        ...(actual.metadata || {}),\n        mode: 'SHADOW',\n        quotedCost,\n        pricingVersion,\n        pricingSelections,\n      },\n    })\n    return result\n  }\n\n  const billingKey = buildSyncBillingKey(params, recordParams)\n  const requestId = recordParams.requestId || getLogContext().requestId || undefined\n  const existingFreeze = await getFreezeByIdempotencyKey(billingKey)\n  if (existingFreeze) {\n    if (existingFreeze.status === 'confirmed') {\n      throw new BillingOperationError(\n        'BILLING_IDEMPOTENT_ALREADY_CONFIRMED',\n        'duplicate billing request already confirmed',\n        { billingKey, freezeId: existingFreeze.id },\n      )\n    }\n    if (existingFreeze.status === 'pending') {\n      throw new BillingOperationError(\n        'BILLING_IDEMPOTENT_IN_PROGRESS',\n        'duplicate billing request is already in progress',\n        { billingKey, freezeId: existingFreeze.id },\n      )\n    }\n    if (existingFreeze.status === 'rolled_back') {\n      throw new BillingOperationError(\n        'BILLING_IDEMPOTENT_ROLLED_BACK',\n        'duplicate billing request was already rolled back',\n        { billingKey, freezeId: existingFreeze.id },\n      )\n    }\n  }\n\n  const freezeId = await freezeBalance(params.userId, quotedCost, {\n    source: 'sync',\n    requestId,\n    idempotencyKey: billingKey,\n    metadata: {\n      projectId: params.projectId,\n      action: params.action,\n      apiType: params.apiType,\n      model: params.model,\n      unit: params.unit,\n      quantity: params.quantity,\n      billingKey,\n      requestId,\n      ...(recordParams.metadata || {}),\n      ...(params.metadata || {}),\n      pricingVersion,\n      pricingSelections,\n    },\n  })\n  if (!freezeId) {\n    const balance = await getBalance(params.userId)\n    throw new InsufficientBalanceError(quotedCost, balance.balance)\n  }\n\n  try {\n    const { result, textUsage } = await executeWithUsage(params.apiType, execute)\n    const actual = resolveActualForSync(params, result, textUsage, quotedCost)\n    const recordModel = resolveRecordModel(params.model, actual.metadata)\n    const chargedCost = await ensureFreezeCoverage({\n      freezeId,\n      userId: params.userId,\n      actualCost: actual.actualCost,\n      quotedCost,\n    })\n    await confirmChargeWithRecord(\n      freezeId,\n      {\n        projectId: params.projectId,\n        action: params.action,\n        apiType: params.apiType,\n        model: recordModel.model,\n        quantity: actual.actualQuantity,\n        unit: params.unit,\n        metadata: {\n          ...(recordParams.metadata || {}),\n          ...(params.metadata || {}),\n          ...(actual.metadata || {}),\n          mode: 'ENFORCE',\n          quotedCost,\n          actualCost: actual.actualCost,\n          chargedCost,\n          pricingVersion,\n          pricingSelections,\n          billingKey,\n          requestId,\n          ...(recordModel.actualModels.length > 0 ? { actualModels: recordModel.actualModels } : {}),\n        },\n      },\n      { chargedAmount: chargedCost },\n    )\n    return result\n  } catch (error) {\n    await rollbackFreeze(freezeId)\n    if (error instanceof BillingOperationError) {\n      throw new BillingOperationError(error.code, error.message, {\n        ...(error.details || {}),\n        billingKey,\n        pricingVersion,\n      }, error)\n    }\n    throw error\n  }\n}\n\n/**\n * Load user custom pricing for a specific model from their stored config.\n */\nasync function loadUserCustomPricing(\n  userId: string,\n  model: string,\n): Promise<ModelCustomPricing | null> {\n  const parsed = parseModelKeyStrict(model)\n  if (!parsed) return null\n\n  const pref = await prisma.userPreference.findUnique({\n    where: { userId },\n    select: { customModels: true },\n  })\n  if (!pref?.customModels) return null\n\n  let models: Array<{ modelKey: string; customPricing?: unknown }>\n  try {\n    models = JSON.parse(pref.customModels) as typeof models\n  } catch {\n    return null\n  }\n  if (!Array.isArray(models)) return null\n\n  const target = models.find((m) => m.modelKey === parsed.modelKey)\n  const raw = target?.customPricing\n  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null\n  const pricing = raw as Record<string, unknown>\n\n  const llmRaw = (pricing.llm && typeof pricing.llm === 'object' && !Array.isArray(pricing.llm))\n    ? (pricing.llm as Record<string, unknown>)\n    : pricing\n\n  const inputPerMillion = typeof llmRaw.inputPerMillion === 'number'\n    ? llmRaw.inputPerMillion\n    : typeof pricing.input === 'number'\n      ? pricing.input\n      : undefined\n  const outputPerMillion = typeof llmRaw.outputPerMillion === 'number'\n    ? llmRaw.outputPerMillion\n    : typeof pricing.output === 'number'\n      ? pricing.output\n      : undefined\n\n  const normalizeMedia = (value: unknown): ModelCustomPricing['image'] | undefined => {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined\n    const record = value as Record<string, unknown>\n    const basePrice = typeof record.basePrice === 'number' ? record.basePrice : undefined\n    const rawOptions = record.optionPrices\n    let optionPrices: Record<string, Record<string, number>> | undefined\n    if (rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)) {\n      optionPrices = {}\n      for (const [field, rawFieldOptions] of Object.entries(rawOptions as Record<string, unknown>)) {\n        if (!rawFieldOptions || typeof rawFieldOptions !== 'object' || Array.isArray(rawFieldOptions)) continue\n        const normalizedField: Record<string, number> = {}\n        for (const [optionKey, rawAmount] of Object.entries(rawFieldOptions as Record<string, unknown>)) {\n          if (typeof rawAmount !== 'number' || !Number.isFinite(rawAmount) || rawAmount < 0) continue\n          normalizedField[optionKey] = rawAmount\n        }\n        if (Object.keys(normalizedField).length > 0) {\n          optionPrices[field] = normalizedField\n        }\n      }\n      if (Object.keys(optionPrices).length === 0) {\n        optionPrices = undefined\n      }\n    }\n    if (basePrice === undefined && optionPrices === undefined) return undefined\n    return {\n      ...(basePrice !== undefined ? { basePrice } : {}),\n      ...(optionPrices ? { optionPrices } : {}),\n    }\n  }\n\n  const image = normalizeMedia(pricing.image)\n  const video = normalizeMedia(pricing.video)\n  const llm = (typeof inputPerMillion === 'number' || typeof outputPerMillion === 'number')\n    ? {\n      ...(typeof inputPerMillion === 'number' ? { inputPerMillion } : {}),\n      ...(typeof outputPerMillion === 'number' ? { outputPerMillion } : {}),\n    }\n    : undefined\n\n  if (!llm && !image && !video) return null\n  return {\n    ...(llm ? { llm } : {}),\n    ...(image ? { image } : {}),\n    ...(video ? { video } : {}),\n  }\n}\n\nexport async function withTextBilling<T>(\n  userId: string,\n  model: string,\n  maxInputTokens: number,\n  maxOutputTokens: number,\n  recordParams: BillingRecordParams,\n  generateFn: () => Promise<T>,\n): Promise<T> {\n  const customPricing = await loadUserCustomPricing(userId, model)\n  const quotedCost = calcText(model, maxInputTokens, maxOutputTokens, customPricing)\n  return await withSyncBillingCore(\n    {\n      userId,\n      projectId: recordParams.projectId,\n      action: recordParams.action,\n      apiType: 'text',\n      model,\n      quantity: maxInputTokens + maxOutputTokens,\n      unit: 'token',\n      metadata: {\n        ...recordParams.metadata,\n        maxInputTokens,\n        maxOutputTokens,\n      },\n      maxCost: quotedCost,\n      customPricing,\n    },\n    recordParams,\n    generateFn,\n  )\n}\n\nexport async function withImageBilling<T>(\n  userId: string,\n  model: string,\n  count: number,\n  recordParams: BillingRecordParams,\n  generateFn: () => Promise<T>,\n): Promise<T> {\n  const customPricing = await loadUserCustomPricing(userId, model)\n  return await withSyncBillingCore(\n    {\n      userId,\n      projectId: recordParams.projectId,\n      action: recordParams.action,\n      apiType: 'image',\n      model,\n      quantity: count,\n      unit: 'image',\n      metadata: recordParams.metadata,\n      customPricing,\n    },\n    recordParams,\n    generateFn,\n  )\n}\n\nexport async function withVideoBilling<T>(\n  userId: string,\n  model: string,\n  resolution: string,\n  maxCount: number,\n  recordParams: BillingRecordParams,\n  generateFn: () => Promise<T>,\n): Promise<T> {\n  const customPricing = await loadUserCustomPricing(userId, model)\n  return await withSyncBillingCore(\n    {\n      userId,\n      projectId: recordParams.projectId,\n      action: recordParams.action,\n      apiType: 'video',\n      model,\n      quantity: maxCount,\n      unit: 'video',\n      metadata: { ...recordParams.metadata, resolution },\n      customPricing,\n    },\n    recordParams,\n    generateFn,\n  )\n}\n\nexport async function withVoiceBilling<T>(\n  userId: string,\n  maxFreezeSeconds: number,\n  recordParams: BillingRecordParams,\n  generateFn: () => Promise<T>,\n): Promise<T> {\n  return await withSyncBillingCore(\n    {\n      userId,\n      projectId: recordParams.projectId,\n      action: recordParams.action,\n      apiType: 'voice',\n      model: 'index-tts2',\n      quantity: maxFreezeSeconds,\n      unit: 'second',\n      metadata: recordParams.metadata,\n      maxCost: calcVoice(maxFreezeSeconds),\n      extractActualQuantity: (result) => {\n        if (!result || typeof result !== 'object') return null\n        const value =\n          (result as Record<string, unknown>).actualDurationSeconds\n          ?? (result as Record<string, unknown>).actualSeconds\n        return asNumber(value)\n      },\n    },\n    recordParams,\n    generateFn,\n  )\n}\n\nexport async function withVoiceDesignBilling<T>(\n  userId: string,\n  recordParams: BillingRecordParams,\n  generateFn: () => Promise<T>,\n): Promise<T> {\n  return await withSyncBillingCore(\n    {\n      userId,\n      projectId: recordParams.projectId,\n      action: recordParams.action,\n      apiType: 'voice-design',\n      model: 'bailian',\n      quantity: 1,\n      unit: 'call',\n      metadata: recordParams.metadata,\n    },\n    recordParams,\n    generateFn,\n  )\n}\n\nexport async function withLipSyncBilling<T>(\n  userId: string,\n  recordParams: BillingRecordParams,\n  model = 'kling',\n  generateFn: () => Promise<T>,\n): Promise<T> {\n  return await withSyncBillingCore(\n    {\n      userId,\n      projectId: recordParams.projectId,\n      action: recordParams.action,\n      apiType: 'lip-sync',\n      model,\n      quantity: 1,\n      unit: 'call',\n      metadata: recordParams.metadata,\n    },\n    recordParams,\n    generateFn,\n  )\n}\n\nexport function handleBillingError(error: unknown): NextResponse | null {\n  if (error instanceof InsufficientBalanceError) {\n    return NextResponse.json(\n      {\n        error: error.message,\n        code: 'INSUFFICIENT_BALANCE',\n        required: error.required,\n        available: error.available,\n      },\n      { status: 402 },\n    )\n  }\n  return null\n}\n\nexport async function prepareTaskBilling(task: {\n  id: string\n  userId: string\n  projectId: string\n  billingInfo: TaskBillingInfo | { billable: false } | null\n}) {\n  const info = task.billingInfo\n  if (!info || !info.billable) return info\n\n  const mode = await getBillingMode()\n  const next: TaskBillingInfo = {\n    ...info,\n    modeSnapshot: mode,\n    billingKey: info.billingKey || task.id,\n    pricingVersion: info.pricingVersion || BUILTIN_PRICING_VERSION,\n  }\n\n  if (mode === 'OFF') {\n    next.status = 'skipped'\n    return next\n  }\n\n  const customPricing = await loadUserCustomPricing(task.userId, info.model)\n  let quotedCost: number\n  try {\n    quotedCost = resolveCost({\n      apiType: info.apiType,\n      model: info.model,\n      quantity: info.quantity,\n      unit: info.unit,\n      metadata: info.metadata,\n      quotedCost: info.maxFrozenCost,\n      customPricing,\n    })\n  } catch (error) {\n    if (mode !== 'ENFORCE' && error instanceof BillingOperationError && error.code === 'BILLING_UNKNOWN_MODEL') {\n      next.status = mode === 'SHADOW' ? 'quoted' : 'skipped'\n      next.maxFrozenCost = 0\n      return next\n    }\n    throw error\n  }\n\n  if (quotedCost <= 0) {\n    next.status = 'skipped'\n    return next\n  }\n\n  if (mode === 'SHADOW') {\n    next.status = 'quoted'\n    next.maxFrozenCost = quotedCost\n    return next\n  }\n\n  const freezeId = await freezeBalance(task.userId, quotedCost, {\n    source: 'task',\n    taskId: task.id,\n    idempotencyKey: info.billingKey || task.id,\n    metadata: {\n      taskType: info.taskType,\n      action: info.action,\n      apiType: info.apiType,\n      model: info.model,\n      quantity: info.quantity,\n      unit: info.unit,\n      billingKey: info.billingKey || task.id,\n      pricingVersion: info.pricingVersion || BUILTIN_PRICING_VERSION,\n      pricingSelections: info.metadata || {},\n      ...(info.metadata || {}),\n    },\n  })\n  if (!freezeId) {\n    const balance = await getBalance(task.userId)\n    throw new InsufficientBalanceError(quotedCost, balance.balance)\n  }\n\n  next.status = 'frozen'\n  next.freezeId = freezeId\n  next.maxFrozenCost = quotedCost\n  return next\n}\n\nexport async function settleTaskBilling(task: {\n  id: string\n  projectId: string\n  episodeId?: string | null\n  userId: string\n  billingInfo: TaskBillingInfo | { billable: false } | null\n}, options?: {\n  result?: Record<string, unknown> | void\n  textUsage?: TextUsageEntry[]\n}) {\n  const info = task.billingInfo\n  if (!info || !info.billable) return info\n\n  const mode = info.modeSnapshot || await getBillingMode()\n  const noChargeStatus = info.status === 'skipped' ? 'skipped' : 'settled'\n  if (mode === 'OFF') {\n    return {\n      ...info,\n      modeSnapshot: mode,\n      status: noChargeStatus,\n      chargedCost: 0,\n    } satisfies TaskBillingInfo\n  }\n\n  const customPricing = await loadUserCustomPricing(task.userId, info.model)\n  let quotedCost: number\n  try {\n    quotedCost = resolveCost({\n      apiType: info.apiType,\n      model: info.model,\n      quantity: info.quantity,\n      unit: info.unit,\n      metadata: info.metadata,\n      quotedCost: info.maxFrozenCost,\n      customPricing,\n    })\n  } catch (error) {\n    if (mode === 'SHADOW' && error instanceof BillingOperationError && error.code === 'BILLING_UNKNOWN_MODEL') {\n      return {\n        ...info,\n        modeSnapshot: mode,\n        status: noChargeStatus,\n        chargedCost: 0,\n      } satisfies TaskBillingInfo\n    }\n    throw error\n  }\n\n  if (mode === 'SHADOW' && quotedCost <= 0) {\n    return {\n      ...info,\n      modeSnapshot: mode,\n      status: noChargeStatus,\n      chargedCost: 0,\n    } satisfies TaskBillingInfo\n  }\n\n  let actual: ResolvedActual\n  try {\n    actual = resolveTaskActual(info, quotedCost, options)\n  } catch (error) {\n    if (mode === 'SHADOW' && error instanceof BillingOperationError && error.code === 'BILLING_UNKNOWN_MODEL') {\n      return {\n        ...info,\n        modeSnapshot: mode,\n        status: noChargeStatus,\n        chargedCost: 0,\n      } satisfies TaskBillingInfo\n    }\n    throw error\n  }\n\n  if (mode === 'SHADOW') {\n    await recordShadowUsage(task.userId, {\n      projectId: task.projectId,\n      episodeId: typeof task.episodeId === 'string' ? task.episodeId : null,\n      taskType: info.taskType || null,\n      action: info.action,\n      apiType: info.apiType,\n      model: info.model,\n      quantity: actual.actualQuantity,\n      unit: info.unit,\n      cost: actual.actualCost,\n      metadata: {\n        ...(info.metadata || {}),\n        ...(actual.metadata || {}),\n        mode: 'SHADOW',\n        taskId: task.id,\n        taskType: info.taskType,\n        quotedCost,\n        pricingVersion: info.pricingVersion || BUILTIN_PRICING_VERSION,\n        pricingSelections: info.metadata || {},\n      },\n    })\n    return {\n      ...info,\n      modeSnapshot: mode,\n      status: info.status === 'skipped' ? 'skipped' : 'settled',\n      chargedCost: 0,\n    } satisfies TaskBillingInfo\n  }\n\n  if (mode !== 'ENFORCE') {\n    return {\n      ...info,\n      modeSnapshot: mode,\n      status: info.status === 'skipped' ? 'skipped' : 'settled',\n      chargedCost: 0,\n    } satisfies TaskBillingInfo\n  }\n\n  if (!info.freezeId) {\n    return {\n      ...info,\n      status: 'failed',\n    } satisfies TaskBillingInfo\n  }\n\n  const chargedCost = await ensureFreezeCoverage({\n    freezeId: info.freezeId,\n    userId: task.userId,\n    actualCost: actual.actualCost,\n    quotedCost,\n  })\n  const recordModel = resolveRecordModel(info.model, actual.metadata)\n  try {\n    await confirmChargeWithRecord(\n      info.freezeId,\n      {\n        projectId: task.projectId,\n        action: info.action,\n        apiType: info.apiType,\n        model: recordModel.model,\n        quantity: actual.actualQuantity,\n        unit: info.unit,\n        metadata: {\n          ...(info.metadata || {}),\n          ...(actual.metadata || {}),\n          billingKey: info.billingKey || task.id,\n          source: 'task',\n          taskType: info.taskType,\n          taskId: task.id,\n          mode: 'ENFORCE',\n          quotedCost,\n          actualCost: actual.actualCost,\n          chargedCost,\n          pricingVersion: info.pricingVersion || BUILTIN_PRICING_VERSION,\n          pricingSelections: info.metadata || {},\n          ...(recordModel.actualModels.length > 0 ? { actualModels: recordModel.actualModels } : {}),\n        },\n      },\n      { chargedAmount: chargedCost },\n    )\n  } catch (error) {\n    const rolledBack = (await rollbackTaskBilling({\n      id: task.id,\n      billingInfo: info,\n    })) as TaskBillingInfo\n    if (rolledBack.billable && rolledBack.status !== 'rolled_back') {\n      throw new BillingOperationError('BILLING_CONFIRM_FAILED', 'confirm task charge failed; billing rollback failed', {\n        taskId: task.id,\n        freezeId: info.freezeId,\n      }, error)\n    }\n    if (error instanceof BillingOperationError) {\n      throw new BillingOperationError(error.code, error.message, {\n        ...(error.details || {}),\n        taskId: task.id,\n        freezeId: info.freezeId,\n      }, error)\n    }\n    throw error\n  }\n\n  return {\n    ...info,\n    status: 'settled',\n    chargedCost,\n  } satisfies TaskBillingInfo\n}\n\nexport async function rollbackTaskBilling(task: {\n  id: string\n  billingInfo: TaskBillingInfo | { billable: false } | null\n}) {\n  const info = task.billingInfo\n  if (!info || !info.billable) return info\n  if (!info.freezeId) return info\n  if (info.modeSnapshot !== 'ENFORCE') return info\n\n  try {\n    await rollbackFreeze(info.freezeId)\n    return {\n      ...info,\n      status: 'rolled_back',\n    } satisfies TaskBillingInfo\n  } catch (error) {\n    _ulogError('[Billing] rollback task freeze failed:', error)\n    return {\n      ...info,\n      status: 'failed',\n    } satisfies TaskBillingInfo\n  }\n}\n"
  },
  {
    "path": "src/lib/billing/task-policy.ts",
    "content": "import {\n  calcImage,\n  calcLipSync,\n  calcText,\n  calcVideo,\n  calcVoice,\n  calcVoiceDesign,\n} from './cost'\nimport { BillingOperationError } from './errors'\nimport { BUILTIN_PRICING_VERSION } from '@/lib/model-pricing/version'\nimport { TASK_TYPE, type TaskType } from '@/lib/task/types'\nimport type { TaskBillingInfo } from './types'\n\ntype AnyPayload = Record<string, unknown> | null | undefined\n\nconst BILLABLE_TASK_TYPES = new Set<TaskType>([\n  TASK_TYPE.IMAGE_PANEL,\n  TASK_TYPE.IMAGE_CHARACTER,\n  TASK_TYPE.IMAGE_LOCATION,\n  TASK_TYPE.VIDEO_PANEL,\n  TASK_TYPE.LIP_SYNC,\n  TASK_TYPE.VOICE_LINE,\n  TASK_TYPE.VOICE_DESIGN,\n  TASK_TYPE.ASSET_HUB_VOICE_DESIGN,\n  TASK_TYPE.REGENERATE_STORYBOARD_TEXT,\n  TASK_TYPE.INSERT_PANEL,\n  TASK_TYPE.PANEL_VARIANT,\n  TASK_TYPE.MODIFY_ASSET_IMAGE,\n  TASK_TYPE.REGENERATE_GROUP,\n  TASK_TYPE.ASSET_HUB_IMAGE,\n  TASK_TYPE.ASSET_HUB_MODIFY,\n  TASK_TYPE.ANALYZE_NOVEL,\n  TASK_TYPE.STORY_TO_SCRIPT_RUN,\n  TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n  TASK_TYPE.CLIPS_BUILD,\n  TASK_TYPE.SCREENPLAY_CONVERT,\n  TASK_TYPE.VOICE_ANALYZE,\n  TASK_TYPE.ANALYZE_GLOBAL,\n  TASK_TYPE.AI_MODIFY_APPEARANCE,\n  TASK_TYPE.AI_MODIFY_LOCATION,\n  TASK_TYPE.AI_MODIFY_SHOT_PROMPT,\n  TASK_TYPE.ANALYZE_SHOT_VARIANTS,\n  TASK_TYPE.AI_CREATE_CHARACTER,\n  TASK_TYPE.AI_CREATE_LOCATION,\n  TASK_TYPE.REFERENCE_TO_CHARACTER,\n  TASK_TYPE.CHARACTER_PROFILE_CONFIRM,\n  TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM,\n  TASK_TYPE.EPISODE_SPLIT_LLM,\n  TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER,\n  TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,\n  TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER,\n  TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION,\n  TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,\n])\n\nfunction toNumber(value: unknown, fallback: number) {\n  const n = Number(value)\n  if (!Number.isFinite(n)) return fallback\n  return n\n}\n\nfunction readString(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  return trimmed || null\n}\n\nfunction readNumber(value: unknown): number | null {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return null\n  return value\n}\n\nfunction toRecord(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction pickFirstString(values: unknown[]): string | null {\n  for (const value of values) {\n    const next = readString(value)\n    if (next) return next\n  }\n  return null\n}\n\nfunction buildTextTaskInfo(taskType: TaskType, payload: AnyPayload): TaskBillingInfo | null {\n  const inputTokens = Math.max(0, Math.floor(toNumber(payload?.maxInputTokens, 3000)))\n  const outputTokens = Math.max(0, Math.floor(toNumber(payload?.maxOutputTokens, 1200)))\n  const model = pickFirstString([payload?.analysisModel, payload?.model])\n  if (!model) return null\n\n  // calcText may throw if model has no built-in pricing (user custom pricing resolved later)\n  let maxFrozenCost = 0\n  try {\n    maxFrozenCost = calcText(model, inputTokens, outputTokens)\n  } catch {\n    // Custom-priced or uncatalogued model: actual cost resolved in prepareTaskBilling with user context\n  }\n\n  return {\n    billable: true,\n    source: 'task',\n    taskType,\n    apiType: 'text',\n    model,\n    quantity: inputTokens + outputTokens,\n    unit: 'token',\n    maxFrozenCost,\n    pricingVersion: BUILTIN_PRICING_VERSION,\n    action: String(taskType),\n    metadata: { inputTokens, outputTokens },\n    status: 'quoted',\n  }\n}\n\nfunction buildImageTaskInfo(taskType: TaskType, payload: AnyPayload): TaskBillingInfo | null {\n  const model = pickFirstString([payload?.imageModel, payload?.modelId, payload?.model])\n  if (!model) return null\n  const quantity = Math.max(1, Math.floor(toNumber(payload?.candidateCount ?? payload?.count, 1)))\n  const generationOptions = toRecord(payload?.generationOptions)\n  const resolution = readString(generationOptions.resolution) || readString(payload?.resolution)\n  const metadata = resolution ? { resolution } : undefined\n  let maxFrozenCost = 0\n  try {\n    maxFrozenCost = calcImage(model, quantity, metadata)\n  } catch (error) {\n    if (error instanceof BillingOperationError && error.code === 'BILLING_UNKNOWN_MODEL') {\n      // Uncatalogued model: allow task to proceed without billing estimate\n    } else {\n      throw error\n    }\n  }\n  return {\n    billable: true,\n    source: 'task',\n    taskType,\n    apiType: 'image',\n    model,\n    quantity,\n    unit: 'image',\n    maxFrozenCost,\n    pricingVersion: BUILTIN_PRICING_VERSION,\n    action: String(taskType),\n    ...(metadata ? { metadata } : {}),\n    status: 'quoted',\n  }\n}\n\nfunction buildVideoTaskInfo(taskType: TaskType, payload: AnyPayload): TaskBillingInfo | null {\n  const firstLastFramePayload = toRecord(payload?.firstLastFrame)\n  const generationMode = Object.keys(firstLastFramePayload).length > 0 ? 'firstlastframe' : 'normal'\n  const model = pickFirstString([\n    payload?.videoModel,\n    payload?.modelId,\n    payload?.model,\n    firstLastFramePayload.flModel,\n  ])\n  if (!model) return null\n  const generationOptions = toRecord(payload?.generationOptions)\n  const resolution = readString(generationOptions.resolution) || readString(payload?.resolution)\n  const duration = readNumber(generationOptions.duration) ?? readNumber(payload?.duration)\n  const generateAudio = typeof generationOptions.generateAudio === 'boolean'\n    ? generationOptions.generateAudio\n    : undefined\n  const quantity = Math.max(1, Math.floor(toNumber(payload?.count, 1)))\n  const metadata = {\n    ...(resolution ? { resolution } : {}),\n    ...(typeof duration === 'number' ? { duration } : {}),\n    generationMode,\n    ...(typeof generateAudio === 'boolean' ? { generateAudio } : {}),\n  }\n  let maxFrozenCost = 0\n  try {\n    maxFrozenCost = calcVideo(model, resolution || '720p', quantity, metadata)\n  } catch (error) {\n    if (error instanceof BillingOperationError && error.code === 'BILLING_UNKNOWN_MODEL') {\n      // Uncatalogued model: allow task to proceed without billing estimate\n    } else {\n      throw error\n    }\n  }\n  return {\n    billable: true,\n    source: 'task',\n    taskType,\n    apiType: 'video',\n    model,\n    quantity,\n    unit: 'video',\n    maxFrozenCost,\n    pricingVersion: BUILTIN_PRICING_VERSION,\n    action: String(taskType),\n    metadata,\n    status: 'quoted',\n  }\n}\n\nfunction buildVoiceTaskInfo(taskType: TaskType, payload: AnyPayload): TaskBillingInfo {\n  const maxSeconds = Math.max(1, Math.floor(toNumber(payload?.maxSeconds, 5)))\n  return {\n    billable: true,\n    source: 'task',\n    taskType,\n    apiType: 'voice',\n    model: 'index-tts2',\n    quantity: maxSeconds,\n    unit: 'second',\n    maxFrozenCost: calcVoice(maxSeconds),\n    pricingVersion: BUILTIN_PRICING_VERSION,\n    action: String(taskType),\n    metadata: { maxSeconds },\n    status: 'quoted',\n  }\n}\n\nfunction buildVoiceDesignTaskInfo(taskType: TaskType): TaskBillingInfo {\n  return {\n    billable: true,\n    source: 'task',\n    taskType,\n    apiType: 'voice-design',\n    model: 'bailian-voice-design',\n    quantity: 1,\n    unit: 'call',\n    maxFrozenCost: calcVoiceDesign(),\n    pricingVersion: BUILTIN_PRICING_VERSION,\n    action: String(taskType),\n    status: 'quoted',\n  }\n}\n\nexport function isBillableTaskType(taskType: TaskType) {\n  return BILLABLE_TASK_TYPES.has(taskType)\n}\n\nexport function buildDefaultTaskBillingInfo(taskType: TaskType, payload: AnyPayload): TaskBillingInfo | null {\n  if (!isBillableTaskType(taskType)) return null\n\n  switch (taskType) {\n    case TASK_TYPE.IMAGE_PANEL:\n    case TASK_TYPE.IMAGE_CHARACTER:\n    case TASK_TYPE.IMAGE_LOCATION:\n    case TASK_TYPE.MODIFY_ASSET_IMAGE:\n    case TASK_TYPE.REGENERATE_GROUP:\n    case TASK_TYPE.ASSET_HUB_IMAGE:\n    case TASK_TYPE.ASSET_HUB_MODIFY:\n      return buildImageTaskInfo(taskType, payload)\n    case TASK_TYPE.VIDEO_PANEL:\n      return buildVideoTaskInfo(taskType, payload)\n    case TASK_TYPE.LIP_SYNC: {\n      const lipSyncModel = pickFirstString([payload?.lipSyncModel]) || 'kling'\n      return {\n        billable: true,\n        source: 'task',\n        taskType,\n        apiType: 'lip-sync',\n        model: lipSyncModel,\n        quantity: 1,\n        unit: 'call',\n        maxFrozenCost: calcLipSync(lipSyncModel),\n        pricingVersion: BUILTIN_PRICING_VERSION,\n        action: String(taskType),\n        status: 'quoted',\n      }\n    }\n    case TASK_TYPE.VOICE_LINE:\n      return buildVoiceTaskInfo(taskType, payload)\n    case TASK_TYPE.VOICE_DESIGN:\n    case TASK_TYPE.ASSET_HUB_VOICE_DESIGN:\n      return buildVoiceDesignTaskInfo(taskType)\n    case TASK_TYPE.REGENERATE_STORYBOARD_TEXT:\n    case TASK_TYPE.INSERT_PANEL:\n    case TASK_TYPE.ANALYZE_NOVEL:\n    case TASK_TYPE.STORY_TO_SCRIPT_RUN:\n    case TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN:\n    case TASK_TYPE.CLIPS_BUILD:\n    case TASK_TYPE.SCREENPLAY_CONVERT:\n    case TASK_TYPE.VOICE_ANALYZE:\n    case TASK_TYPE.ANALYZE_GLOBAL:\n    case TASK_TYPE.AI_MODIFY_APPEARANCE:\n    case TASK_TYPE.AI_MODIFY_LOCATION:\n    case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:\n    case TASK_TYPE.ANALYZE_SHOT_VARIANTS:\n    case TASK_TYPE.AI_CREATE_CHARACTER:\n    case TASK_TYPE.AI_CREATE_LOCATION:\n    case TASK_TYPE.REFERENCE_TO_CHARACTER:\n    case TASK_TYPE.CHARACTER_PROFILE_CONFIRM:\n    case TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM:\n    case TASK_TYPE.EPISODE_SPLIT_LLM:\n    case TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER:\n    case TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION:\n    case TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER:\n    case TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION:\n    case TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER:\n      return buildTextTaskInfo(taskType, payload)\n    case TASK_TYPE.PANEL_VARIANT:\n      return buildImageTaskInfo(taskType, payload)\n    default:\n      return null\n  }\n}\n"
  },
  {
    "path": "src/lib/billing/types.ts",
    "content": "import type { ApiType, UsageUnit } from './cost'\nimport type { TaskBillingInfo } from '@/lib/task/types'\n\nexport type BillingMode = 'OFF' | 'SHADOW' | 'ENFORCE'\n\nexport type BillingStatus =\n  | 'skipped'\n  | 'quoted'\n  | 'frozen'\n  | 'settled'\n  | 'rolled_back'\n  | 'failed'\n\nexport interface BillingRecordParams {\n  projectId: string\n  action: string\n  billingKey?: string\n  requestId?: string\n  metadata?: Record<string, unknown>\n}\n\nexport type AnyTaskBillingInfo = TaskBillingInfo | { billable: false; source?: 'task' }\n\nexport interface BillingQuote {\n  cost: number\n  apiType: ApiType\n  model: string\n  quantity: number\n  unit: UsageUnit\n}\n\nexport type { TaskBillingInfo, TaskType } from '@/lib/task/types'\n"
  },
  {
    "path": "src/lib/config-service.ts",
    "content": "/**\n * 统一配置服务\n *\n * 所有 API 通过此服务获取模型配置，确保数据源一致性。\n *\n * 优先级：项目配置 > 用户偏好 > null\n */\n\nimport { prisma } from '@/lib/prisma'\nimport {\n  type CapabilitySelections,\n  type CapabilityValue,\n  composeModelKey as composeStrictModelKey,\n  parseModelKeyStrict,\n} from '@/lib/model-config-contract'\nimport { findBuiltinCapabilities } from '@/lib/model-capabilities/catalog'\nimport { resolveGenerationOptionsForModel } from '@/lib/model-capabilities/lookup'\nimport {\n  type WorkflowConcurrencyConfig,\n  normalizeWorkflowConcurrencyConfig,\n} from '@/lib/workflow-concurrency'\n\nexport type ParsedModelKey = { provider: string, modelId: string }\n\n/**\n * 解析模型复合 Key（严格模式，仅接受 provider::modelId）\n */\nexport function parseModelKey(key: string | null | undefined): ParsedModelKey | null {\n  const parsed = parseModelKeyStrict(key)\n  if (!parsed) return null\n  return {\n    provider: parsed.provider,\n    modelId: parsed.modelId,\n  }\n}\n\n/**\n * 组合 provider 与 modelId 为标准复合主键。\n */\nexport function composeModelKey(provider: string, modelId: string): string {\n  return composeStrictModelKey(provider, modelId)\n}\n\n/**\n * 从复合 Key 中提取真正的 modelId（用于 API 调用）\n */\nexport function extractModelId(key: string | null | undefined): string | null {\n  const parsed = parseModelKey(key)\n  return parsed?.modelId || null\n}\n\n/**\n * 从模型字段中提取标准 modelKey（provider::modelId）\n */\nexport function extractModelKey(key: string | null | undefined): string | null {\n  const parsed = parseModelKey(key)\n  if (!parsed?.provider || !parsed?.modelId) return null\n  return composeModelKey(parsed.provider, parsed.modelId)\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isCapabilityValue(value: unknown): value is CapabilityValue {\n  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction normalizeCapabilitySelections(raw: unknown): CapabilitySelections {\n  if (!isRecord(raw)) return {}\n\n  const normalized: CapabilitySelections = {}\n  for (const [modelKey, rawSelection] of Object.entries(raw)) {\n    if (!isRecord(rawSelection)) continue\n\n    const selection: Record<string, CapabilityValue> = {}\n    for (const [field, value] of Object.entries(rawSelection)) {\n      if (field === 'aspectRatio') continue\n      if (!isCapabilityValue(value)) continue\n      selection[field] = value\n    }\n\n    if (Object.keys(selection).length > 0) {\n      normalized[modelKey] = selection\n    }\n  }\n\n  return normalized\n}\n\nfunction parseCapabilitySelections(raw: string | null | undefined): CapabilitySelections {\n  if (!raw) return {}\n  try {\n    return normalizeCapabilitySelections(JSON.parse(raw) as unknown)\n  } catch {\n    return {}\n  }\n}\n\nexport interface ProjectModelConfig {\n  analysisModel: string | null\n  characterModel: string | null\n  locationModel: string | null\n  storyboardModel: string | null\n  editModel: string | null\n  videoModel: string | null\n  audioModel: string | null\n  videoRatio: string | null\n  artStyle: string | null\n  capabilityDefaults: CapabilitySelections\n  capabilityOverrides: CapabilitySelections\n}\n\nexport interface UserModelConfig {\n  analysisModel: string | null\n  characterModel: string | null\n  locationModel: string | null\n  storyboardModel: string | null\n  editModel: string | null\n  videoModel: string | null\n  audioModel: string | null\n  capabilityDefaults: CapabilitySelections\n}\n\nexport async function getUserWorkflowConcurrencyConfig(\n  userId: string,\n): Promise<WorkflowConcurrencyConfig> {\n  const userPref = await prisma.userPreference.findUnique({\n    where: { userId },\n    select: {\n      analysisConcurrency: true,\n      imageConcurrency: true,\n      videoConcurrency: true,\n    },\n  })\n\n  return normalizeWorkflowConcurrencyConfig({\n    analysis: userPref?.analysisConcurrency,\n    image: userPref?.imageConcurrency,\n    video: userPref?.videoConcurrency,\n  })\n}\n\n/**\n * 获取项目级模型配置\n */\nexport async function getProjectModelConfig(\n  projectId: string,\n  userId: string,\n): Promise<ProjectModelConfig> {\n  const [projectData, userPref] = await Promise.all([\n    prisma.novelPromotionProject.findUnique({ where: { projectId } }),\n    prisma.userPreference.findUnique({ where: { userId } }),\n  ])\n\n  return {\n    analysisModel: extractModelKey(projectData?.analysisModel) || extractModelKey(userPref?.analysisModel) || null,\n    characterModel: extractModelKey(projectData?.characterModel) || null,\n    locationModel: extractModelKey(projectData?.locationModel) || null,\n    storyboardModel: extractModelKey(projectData?.storyboardModel) || null,\n    editModel: extractModelKey(projectData?.editModel) || null,\n    videoModel: extractModelKey(projectData?.videoModel) || null,\n    audioModel: extractModelKey(projectData?.audioModel) || extractModelKey(userPref?.audioModel) || null,\n    videoRatio: projectData?.videoRatio || '16:9',\n    artStyle: projectData?.artStyle || null,\n    capabilityDefaults: parseCapabilitySelections(userPref?.capabilityDefaults),\n    capabilityOverrides: parseCapabilitySelections(projectData?.capabilityOverrides),\n  }\n}\n\n/**\n * 获取用户级模型配置（无项目时使用）\n */\nexport async function getUserModelConfig(userId: string): Promise<UserModelConfig> {\n  const userPref = await prisma.userPreference.findUnique({\n    where: { userId },\n  })\n\n  return {\n    analysisModel: extractModelKey(userPref?.analysisModel) || null,\n    characterModel: extractModelKey(userPref?.characterModel) || null,\n    locationModel: extractModelKey(userPref?.locationModel) || null,\n    storyboardModel: extractModelKey(userPref?.storyboardModel) || null,\n    editModel: extractModelKey(userPref?.editModel) || null,\n    videoModel: extractModelKey(userPref?.videoModel) || null,\n    audioModel: extractModelKey(userPref?.audioModel) || null,\n    capabilityDefaults: parseCapabilitySelections(userPref?.capabilityDefaults),\n  }\n}\n\nexport function resolveModelCapabilityGenerationOptions(input: {\n  modelType: 'llm' | 'image' | 'video'\n  modelKey: string\n  capabilityDefaults?: CapabilitySelections\n  capabilityOverrides?: CapabilitySelections\n  runtimeSelections?: Record<string, CapabilityValue>\n}): Record<string, CapabilityValue> {\n  const parsed = parseModelKeyStrict(input.modelKey)\n  if (!parsed) {\n    throw new Error(`MODEL_KEY_INVALID: ${input.modelKey}`)\n  }\n\n  const capabilities = findBuiltinCapabilities(input.modelType, parsed.provider, parsed.modelId)\n  const resolved = resolveGenerationOptionsForModel({\n    modelType: input.modelType,\n    modelKey: input.modelKey,\n    capabilities,\n    capabilityDefaults: input.capabilityDefaults,\n    capabilityOverrides: input.capabilityOverrides,\n    runtimeSelections: input.runtimeSelections,\n    requireAllFields: input.modelType !== 'llm',\n  })\n\n  if (resolved.issues.length > 0) {\n    const first = resolved.issues[0]\n    throw new Error(`${first.code}: ${first.field} ${first.message}`)\n  }\n\n  return resolved.options\n}\n\nexport async function resolveProjectModelCapabilityGenerationOptions(input: {\n  projectId: string\n  userId: string\n  modelType: 'llm' | 'image' | 'video'\n  modelKey: string\n  runtimeSelections?: Record<string, CapabilityValue>\n}): Promise<Record<string, CapabilityValue>> {\n  const config = await getProjectModelConfig(input.projectId, input.userId)\n  return resolveModelCapabilityGenerationOptions({\n    modelType: input.modelType,\n    modelKey: input.modelKey,\n    capabilityDefaults: config.capabilityDefaults,\n    capabilityOverrides: config.capabilityOverrides,\n    runtimeSelections: input.runtimeSelections,\n  })\n}\n\n/**\n * 检查必需的模型配置是否存在\n */\nexport function checkRequiredModels(\n  config: Partial<ProjectModelConfig | UserModelConfig>,\n  requiredFields: (keyof ProjectModelConfig | keyof UserModelConfig)[],\n): string[] {\n  const missing: string[] = []\n  const configValues = config as Record<string, unknown>\n\n  const fieldNames: Record<string, string> = {\n    analysisModel: 'AI分析模型',\n    characterModel: '角色图像模型',\n    locationModel: '场景图像模型',\n    storyboardModel: '分镜图像模型',\n    editModel: '修图/编辑模型',\n    videoModel: '视频模型',\n    audioModel: '语音合成模型',\n  }\n\n  for (const field of requiredFields) {\n    if (!configValues[field]) {\n      missing.push(fieldNames[field] || field)\n    }\n  }\n\n  return missing\n}\n\n/**\n * 生成缺失配置的错误消息\n */\nexport function getMissingConfigError(missingFields: string[]): string {\n  if (missingFields.length === 0) return ''\n  if (missingFields.length === 1) {\n    return `请先在项目设置中配置\"${missingFields[0]}\"`\n  }\n  return `请先在项目设置中配置以下模型：${missingFields.join('、')}`\n}\n\n/**\n * 为图片类任务统一构建 billingPayload（项目级，async）\n *\n * 生图和修图统一使用严格模式：用户必须已在项目设置中配置好 resolution。\n * resolution 会同时注入到 billingPayload.generationOptions（计费用）\n * 和 task payload（worker 读取后传给 API 的 imageSize 参数）。\n */\nexport async function buildImageBillingPayload(input: {\n  projectId: string\n  userId: string\n  imageModel: string | null\n  basePayload: Record<string, unknown>\n}): Promise<Record<string, unknown>> {\n  const { projectId, userId, imageModel, basePayload } = input\n  if (!imageModel) return basePayload\n\n  let capabilityOptions: Record<string, CapabilityValue> = {}\n  try {\n    capabilityOptions = await resolveProjectModelCapabilityGenerationOptions({\n      projectId,\n      userId,\n      modelType: 'image',\n      modelKey: imageModel,\n    })\n  } catch (err) {\n    const message = err instanceof Error ? err.message : 'Image model capability not configured'\n    throw Object.assign(new Error(message), { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })\n  }\n\n  return {\n    ...basePayload,\n    imageModel,\n    ...(Object.keys(capabilityOptions).length > 0 ? { generationOptions: capabilityOptions } : {}),\n  }\n}\n\n/**\n * 为图片类任务统一构建 billingPayload（用户级，sync）\n *\n * 适用于 asset-hub 等无 projectId 场景，使用已取出的 userModelConfig。\n */\nexport function buildImageBillingPayloadFromUserConfig(input: {\n  userModelConfig: UserModelConfig\n  imageModel: string | null\n  basePayload: Record<string, unknown>\n}): Record<string, unknown> {\n  const { userModelConfig, imageModel, basePayload } = input\n  if (!imageModel) return basePayload\n\n  let capabilityOptions: Record<string, CapabilityValue> = {}\n  try {\n    capabilityOptions = resolveModelCapabilityGenerationOptions({\n      modelType: 'image',\n      modelKey: imageModel,\n      capabilityDefaults: userModelConfig.capabilityDefaults,\n    })\n  } catch (err) {\n    const message = err instanceof Error ? err.message : 'Image model capability not configured'\n    throw Object.assign(new Error(message), { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message })\n  }\n\n  return {\n    ...basePayload,\n    imageModel,\n    ...(Object.keys(capabilityOptions).length > 0 ? { generationOptions: capabilityOptions } : {}),\n  }\n}\n"
  },
  {
    "path": "src/lib/constants.ts",
    "content": "/**\n * 主形象的 appearanceIndex 值。\n * 所有判断主/子形象的逻辑必须引用此常量，禁止硬编码数字。\n * 子形象的 appearanceIndex 从 PRIMARY_APPEARANCE_INDEX + 1 开始递增。\n */\nexport const PRIMARY_APPEARANCE_INDEX = 0\n\n// 比例配置（nanobanana 支持的所有比例，按常用程度排序）\nexport const ASPECT_RATIO_CONFIGS: Record<string, { label: string; isVertical: boolean }> = {\n  '16:9': { label: '16:9', isVertical: false },\n  '9:16': { label: '9:16', isVertical: true },\n  '1:1': { label: '1:1', isVertical: false },\n  '3:2': { label: '3:2', isVertical: false },\n  '2:3': { label: '2:3', isVertical: true },\n  '4:3': { label: '4:3', isVertical: false },\n  '3:4': { label: '3:4', isVertical: true },\n  '5:4': { label: '5:4', isVertical: false },\n  '4:5': { label: '4:5', isVertical: true },\n  '21:9': { label: '21:9', isVertical: false },\n}\n\n// 配置页面使用的选项列表（从 ASPECT_RATIO_CONFIGS 派生）\nexport const VIDEO_RATIOS = Object.entries(ASPECT_RATIO_CONFIGS).map(([value, config]) => ({\n  value,\n  label: config.label\n}))\n\n// 获取比例配置\nexport function getAspectRatioConfig(ratio: string) {\n  return ASPECT_RATIO_CONFIGS[ratio] || ASPECT_RATIO_CONFIGS['16:9']\n}\n\nexport const ANALYSIS_MODELS = [\n  { value: 'google/gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro' },\n  { value: 'google/gemini-3-flash-preview', label: 'Gemini 3 Flash' },\n  { value: 'google/gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash-Lite' },\n  { value: 'anthropic/claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },\n  { value: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4' }\n]\n\nexport const IMAGE_MODELS = [\n  { value: 'doubao-seedream-4-5-251128', label: 'Seedream 4.5' },\n  { value: 'doubao-seedream-4-0-250828', label: 'Seedream 4.0' }\n]\n\n// 图像模型选项（ 生成完整图片）\nexport const IMAGE_MODEL_OPTIONS = [\n  { value: 'banana', label: 'Banana Pro (FAL)' },\n  { value: 'banana-2', label: 'Banana 2 (FAL)' },\n  { value: 'gemini-3-pro-image-preview', label: 'Banana (Google)' },\n  { value: 'gemini-3-pro-image-preview-batch', label: 'Banana (Google Batch) 省50%' },\n  { value: 'doubao-seedream-4-0-250828', label: 'Seedream 4.0' },\n  { value: 'doubao-seedream-4-5-251128', label: 'Seedream 4.5' },\n  { value: 'imagen-4.0-generate-001', label: 'Imagen 4.0 (Google)' },\n  { value: 'imagen-4.0-ultra-generate-001', label: 'Imagen 4.0 Ultra' },\n  { value: 'imagen-4.0-fast-generate-001', label: 'Imagen 4.0 Fast' }\n]\n\n// Banana 模型分辨率选项（仅用于九宫格分镜图，单张生成固定2K）\nexport const BANANA_RESOLUTION_OPTIONS = [\n  { value: '2K', label: '2K (推荐，快速)' },\n  { value: '4K', label: '4K (高清，较慢)' }\n]\n\n// 支持分辨率选择的 Banana 模型\nexport const BANANA_MODELS = ['banana', 'banana-2', 'gemini-3-pro-image-preview', 'gemini-3-pro-image-preview-batch']\n\nexport const VIDEO_MODELS = [\n  { value: 'doubao-seedance-1-0-pro-fast-251015', label: 'Seedance 1.0 Pro Fast' },\n  { value: 'doubao-seedance-1-0-pro-fast-251015-batch', label: 'Seedance 1.0 Pro Fast (批量) 省50%' },\n  { value: 'doubao-seedance-1-0-lite-i2v-250428', label: 'Seedance 1.0 Lite' },\n  { value: 'doubao-seedance-1-0-lite-i2v-250428-batch', label: 'Seedance 1.0 Lite (批量) 省50%' },\n  { value: 'doubao-seedance-1-5-pro-251215', label: 'Seedance 1.5 Pro' },\n  { value: 'doubao-seedance-1-5-pro-251215-batch', label: 'Seedance 1.5 Pro (批量) 省50%' },\n  { value: 'doubao-seedance-1-0-pro-250528', label: 'Seedance 1.0 Pro' },\n  { value: 'doubao-seedance-1-0-pro-250528-batch', label: 'Seedance 1.0 Pro (批量) 省50%' },\n  { value: 'fal-wan25', label: 'Wan 2.6' },\n  { value: 'fal-veo31', label: 'Veo 3.1 Fast' },\n  { value: 'fal-sora2', label: 'Sora 2' },\n  { value: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video', label: 'Kling 2.5 Turbo Pro' },\n  { value: 'fal-ai/kling-video/v3/standard/image-to-video', label: 'Kling 3 Standard' },\n  { value: 'fal-ai/kling-video/v3/pro/image-to-video', label: 'Kling 3 Pro' }\n]\n\n// SeeDream 批量模型列表（使用 GPU 空闲时间，成本降低50%）\nexport const SEEDANCE_BATCH_MODELS = [\n  'doubao-seedance-1-5-pro-251215-batch',\n  'doubao-seedance-1-0-pro-250528-batch',\n  'doubao-seedance-1-0-pro-fast-251015-batch',\n  'doubao-seedance-1-0-lite-i2v-250428-batch',\n]\n\n// 支持生成音频的模型（仅 Seedance 1.5 Pro 支持，包含批量版本）\nexport const AUDIO_SUPPORTED_MODELS = ['doubao-seedance-1-5-pro-251215', 'doubao-seedance-1-5-pro-251215-batch']\n\n// 首尾帧视频模型（能力权威来源是 standards/capabilities；此常量仅作静态兜底展示）\nexport const FIRST_LAST_FRAME_MODELS = [\n  { value: 'doubao-seedance-1-5-pro-251215', label: 'Seedance 1.5 Pro (首尾帧)' },\n  { value: 'doubao-seedance-1-5-pro-251215-batch', label: 'Seedance 1.5 Pro (首尾帧/批量) 省50%' },\n  { value: 'doubao-seedance-1-0-pro-250528', label: 'Seedance 1.0 Pro (首尾帧)' },\n  { value: 'doubao-seedance-1-0-pro-250528-batch', label: 'Seedance 1.0 Pro (首尾帧/批量) 省50%' },\n  { value: 'doubao-seedance-1-0-lite-i2v-250428', label: 'Seedance 1.0 Lite (首尾帧)' },\n  { value: 'doubao-seedance-1-0-lite-i2v-250428-batch', label: 'Seedance 1.0 Lite (首尾帧/批量) 省50%' },\n  { value: 'veo-3.1-generate-preview', label: 'Veo 3.1 (首尾帧)' },\n  { value: 'veo-3.1-fast-generate-preview', label: 'Veo 3.1 Fast (首尾帧)' }\n]\n\nexport const VIDEO_RESOLUTIONS = [\n  { value: '720p', label: '720p' },\n  { value: '1080p', label: '1080p' }\n]\n\nexport const TTS_RATES = [\n  { value: '+0%', label: '正常速度 (1.0x)' },\n  { value: '+20%', label: '轻微加速 (1.2x)' },\n  { value: '+50%', label: '加速 (1.5x)' },\n  { value: '+100%', label: '快速 (2.0x)' }\n]\n\nexport const TTS_VOICES = [\n  { value: 'zh-CN-YunxiNeural', label: '云希 (男声)', preview: '男' },\n  { value: 'zh-CN-XiaoxiaoNeural', label: '晓晓 (女声)', preview: '女' },\n  { value: 'zh-CN-YunyangNeural', label: '云扬 (男声)', preview: '男' },\n  { value: 'zh-CN-XiaoyiNeural', label: '晓伊 (女声)', preview: '女' }\n]\n\nexport const ART_STYLES = [\n  {\n    value: 'american-comic',\n    label: '漫画风',\n    preview: '漫',\n    promptZh: '日式动漫风格',\n    promptEn: 'Japanese anime style'\n  },\n  {\n    value: 'chinese-comic',\n    label: '精致国漫',\n    preview: '国',\n    promptZh: '现代高质量漫画风格，动漫风格，细节丰富精致，线条锐利干净，质感饱满，超清，干净的画面风格，2D风格，动漫风格。',\n    promptEn: 'Modern premium Chinese comic style, rich details, clean sharp line art, full texture, ultra-clear 2D anime aesthetics.'\n  },\n  {\n    value: 'japanese-anime',\n    label: '日系动漫风',\n    preview: '日',\n    promptZh: '现代日系动漫风格，赛璐璐上色，清晰干净的线条，视觉小说CG感。高质量2D风格',\n    promptEn: 'Modern Japanese anime style, cel shading, clean line art, visual-novel CG look, high-quality 2D style.'\n  },\n  {\n    value: 'realistic',\n    label: '真人风格',\n    preview: '实',\n    promptZh: '真实电影级画面质感，真实现实场景，色彩饱满通透，画面干净精致，真实感',\n    promptEn: 'Realistic cinematic look, real-world scene fidelity, rich transparent colors, clean and refined image quality.'\n  }\n]\n\nexport type ArtStyleValue = (typeof ART_STYLES)[number]['value']\n\nexport function isArtStyleValue(value: unknown): value is ArtStyleValue {\n  return typeof value === 'string' && ART_STYLES.some((style) => style.value === value)\n}\n\n/**\n * 🔥 实时从 ART_STYLES 常量获取风格 prompt\n * 这是获取风格 prompt 的唯一正确方式，确保始终使用最新的常量定义\n * \n * @param artStyle - 风格标识符，如 'realistic', 'american-comic' 等\n * @returns 对应的风格 prompt，如果找不到则返回空字符串\n */\nexport function getArtStylePrompt(\n  artStyle: string | null | undefined,\n  locale: 'zh' | 'en',\n): string {\n  if (!artStyle) return ''\n  const style = ART_STYLES.find(s => s.value === artStyle)\n  if (!style) return ''\n  return locale === 'en' ? style.promptEn : style.promptZh\n}\n\n// 角色形象生成的系统后缀（始终添加到提示词末尾，不显示给用户）- 左侧面部特写+右侧三视图\nexport const CHARACTER_PROMPT_SUFFIX = '角色设定图，画面分为左右两个区域：【左侧区域】占约1/3宽度，是角色的正面特写（如果是人类则展示完整正脸，如果是动物/生物则展示最具辨识度的正面形态）；【右侧区域】占约2/3宽度，是角色三视图横向排列（从左到右依次为：正面全身、侧面全身、背面全身），三视图高度一致。纯白色背景，无其他元素。'\n\n// 场景图片生成的系统后缀（已禁用四视图，直接生成单张场景图）\nexport const LOCATION_PROMPT_SUFFIX = ''\n\n// 角色图片生成比例（16:9横版，左侧面部特写+右侧全身）\nexport const CHARACTER_IMAGE_RATIO = '16:9'\n// 角色图片尺寸（用于Seedream API）\nexport const CHARACTER_IMAGE_SIZE = '3840x2160'  // 16:9 横版\n// 角色图片尺寸（用于Banana API）\nexport const CHARACTER_IMAGE_BANANA_RATIO = '3:2'\n\n// 场景图片生成比例（1:1 正方形单张场景）\nexport const LOCATION_IMAGE_RATIO = '1:1'\n// 场景图片尺寸（用于Seedream API）- 4K\nexport const LOCATION_IMAGE_SIZE = '4096x4096'  // 1:1 正方形 4K\n// 场景图片尺寸（用于Banana API）\nexport const LOCATION_IMAGE_BANANA_RATIO = '1:1'\n\n// 从提示词中移除角色系统后缀（用于显示给用户）\nexport function removeCharacterPromptSuffix(prompt: string): string {\n  if (!prompt) return ''\n  return prompt.replace(CHARACTER_PROMPT_SUFFIX, '').trim()\n}\n\n// 添加角色系统后缀到提示词（用于生成图片）\nexport function addCharacterPromptSuffix(prompt: string): string {\n  if (!prompt) return CHARACTER_PROMPT_SUFFIX\n  const cleanPrompt = removeCharacterPromptSuffix(prompt)\n  return `${cleanPrompt}${cleanPrompt ? '，' : ''}${CHARACTER_PROMPT_SUFFIX}`\n}\n\n// 从提示词中移除场景系统后缀（用于显示给用户）\nexport function removeLocationPromptSuffix(prompt: string): string {\n  if (!prompt) return ''\n  return prompt.replace(LOCATION_PROMPT_SUFFIX, '').replace(/，$/, '').trim()\n}\n\n// 添加场景系统后缀到提示词（用于生成图片）\nexport function addLocationPromptSuffix(prompt: string): string {\n  // 后缀为空时直接返回原提示词\n  if (!LOCATION_PROMPT_SUFFIX) return prompt || ''\n  if (!prompt) return LOCATION_PROMPT_SUFFIX\n  const cleanPrompt = removeLocationPromptSuffix(prompt)\n  return `${cleanPrompt}${cleanPrompt ? '，' : ''}${LOCATION_PROMPT_SUFFIX}`\n}\n\n/**\n * 构建角色介绍字符串（用于发送给 AI，帮助理解\"我\"和称呼对应的角色）\n * @param characters - 角色列表，需要包含 name 和 introduction 字段\n * @returns 格式化的角色介绍字符串\n */\nexport function buildCharactersIntroduction(characters: Array<{ name: string; introduction?: string | null }>): string {\n  if (!characters || characters.length === 0) return '暂无角色介绍'\n\n  const introductions = characters\n    .filter(c => c.introduction && c.introduction.trim())\n    .map(c => `- ${c.name}：${c.introduction}`)\n\n  if (introductions.length === 0) return '暂无角色介绍'\n\n  return introductions.join('\\n')\n}\n"
  },
  {
    "path": "src/lib/contracts/image-urls-contract.test.ts",
    "content": "import assert from 'node:assert/strict'\nimport test from 'node:test'\nimport {\n  ImageUrlsContractError,\n  decodeImageUrlsFromDb,\n  decodeImageUrlsStrict,\n  encodeImageUrls,\n} from './image-urls-contract'\n\ntest('encodeImageUrls returns JSON array string', () => {\n  const encoded = encodeImageUrls(['a', 'b'])\n  assert.equal(encoded, '[\"a\",\"b\"]')\n})\n\ntest('decodeImageUrlsStrict parses valid JSON array', () => {\n  const decoded = decodeImageUrlsStrict('[\"a\",\"b\"]')\n  assert.deepEqual(decoded, ['a', 'b'])\n})\n\ntest('decodeImageUrlsStrict throws on invalid JSON', () => {\n  assert.throws(() => decodeImageUrlsStrict('not-json'), ImageUrlsContractError)\n})\n\ntest('decodeImageUrlsStrict throws on non-array JSON', () => {\n  assert.throws(() => decodeImageUrlsStrict('{\"a\":1}'), ImageUrlsContractError)\n})\n\ntest('decodeImageUrlsStrict throws on non-string array entry', () => {\n  assert.throws(() => decodeImageUrlsStrict('[\"a\",1]'), ImageUrlsContractError)\n})\n\ntest('decodeImageUrlsFromDb throws on null', () => {\n  assert.throws(() => decodeImageUrlsFromDb(null), ImageUrlsContractError)\n})\n"
  },
  {
    "path": "src/lib/contracts/image-urls-contract.ts",
    "content": "export class ImageUrlsContractError extends Error {\n  constructor(message: string) {\n    super(message)\n    this.name = 'ImageUrlsContractError'\n  }\n}\n\nfunction assertStringArray(value: unknown, fieldName: string): asserts value is string[] {\n  if (!Array.isArray(value)) {\n    throw new ImageUrlsContractError(`${fieldName} must be a JSON array`)\n  }\n  const invalidIndex = value.findIndex((item) => typeof item !== 'string')\n  if (invalidIndex !== -1) {\n    throw new ImageUrlsContractError(`${fieldName}[${invalidIndex}] must be a string`)\n  }\n}\n\nexport function decodeImageUrlsStrict(raw: string, fieldName = 'imageUrls'): string[] {\n  let parsed: unknown\n  try {\n    parsed = JSON.parse(raw)\n  } catch {\n    throw new ImageUrlsContractError(`${fieldName} must be valid JSON`)\n  }\n\n  assertStringArray(parsed, fieldName)\n  return parsed\n}\n\nexport function decodeImageUrlsFromDb(raw: string | null | undefined, fieldName = 'imageUrls'): string[] {\n  if (typeof raw !== 'string') {\n    throw new ImageUrlsContractError(`${fieldName} must be a JSON string in DB`)\n  }\n  return decodeImageUrlsStrict(raw, fieldName)\n}\n\nexport function encodeImageUrls(value: string[], fieldName = 'imageUrls'): string {\n  assertStringArray(value, fieldName)\n  return JSON.stringify(value)\n}\n"
  },
  {
    "path": "src/lib/crypto-utils.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\n/**\n * API Key 加密/解密工具\n * \n * 使用 AES-256-GCM 算法，密钥从 NEXTAUTH_SECRET 派生\n * 确保用户在网页上输入的 API Key 在数据库中加密存储\n */\n\nimport crypto from 'crypto'\n\nconst ALGORITHM = 'aes-256-gcm'\nconst IV_LENGTH = 16\nconst KEY_LENGTH = 32\nconst SALT = 'waoowaoo-api-key-salt-v1' // 固定盐值\n\ntype ApiKeyObject = Record<string, unknown>\n\nfunction isApiKeyObject(value: unknown): value is ApiKeyObject {\n    return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\n/**\n * 从环境变量派生加密密钥\n * 优先使用 API_ENCRYPTION_KEY（开源版本固定值）\n * 后备使用 NEXTAUTH_SECRET\n */\nfunction deriveEncryptionKey(): Buffer {\n    // 优先使用专用的加密密钥（开源版本建议使用固定值）\n    const secret = process.env.API_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET\n\n    if (!secret) {\n        throw new Error('API_ENCRYPTION_KEY 或 NEXTAUTH_SECRET 未配置，无法加密 API Key')\n    }\n\n    // 使用 PBKDF2 派生 32 字节密钥\n    // 10万次迭代，足够安全且性能可接受\n    return crypto.pbkdf2Sync(secret, SALT, 100000, KEY_LENGTH, 'sha256')\n}\n\n/**\n * 加密 API Key\n * \n * @param plaintext 明文 API Key（用户输入）\n * @returns 加密后的字符串（格式：iv:authTag:encrypted，全部 hex 编码）\n * \n * @example\n * const encrypted = encryptApiKey('sk-or-v1-abc123...')\n * // 返回: \"a1b2c3d4e5f6....:d7e8f9a0b1c2....:1234567890ab....\"\n */\nexport function encryptApiKey(plaintext: string): string {\n    if (!plaintext || plaintext.trim() === '') {\n        throw new Error('API Key 不能为空')\n    }\n\n    const key = deriveEncryptionKey()\n    const iv = crypto.randomBytes(IV_LENGTH)\n\n    const cipher = crypto.createCipheriv(ALGORITHM, key, iv)\n\n    const encrypted = Buffer.concat([\n        cipher.update(plaintext, 'utf8'),\n        cipher.final()\n    ])\n\n    const authTag = cipher.getAuthTag()\n\n    // 格式: iv:authTag:encrypted (hex 编码)\n    return [\n        iv.toString('hex'),\n        authTag.toString('hex'),\n        encrypted.toString('hex')\n    ].join(':')\n}\n\n/**\n * 解密 API Key\n * \n * @param ciphertext 加密后的字符串（encryptApiKey 的返回值）\n * @returns 明文 API Key\n * \n * @example\n * const decrypted = decryptApiKey('a1b2c3d4e5f6....:d7e8f9a0b1c2....:1234567890ab....')\n * // 返回: \"sk-or-v1-abc123...\"\n */\nexport function decryptApiKey(ciphertext: string): string {\n    if (!ciphertext || ciphertext.trim() === '') {\n        throw new Error('加密数据不能为空')\n    }\n\n    const parts = ciphertext.split(':')\n    if (parts.length !== 3) {\n        throw new Error('加密数据格式错误')\n    }\n\n    const [ivHex, authTagHex, encryptedHex] = parts\n\n    const key = deriveEncryptionKey()\n    const iv = Buffer.from(ivHex, 'hex')\n    const authTag = Buffer.from(authTagHex, 'hex')\n    const encrypted = Buffer.from(encryptedHex, 'hex')\n\n    const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)\n    decipher.setAuthTag(authTag)\n\n    const decrypted = Buffer.concat([\n        decipher.update(encrypted),\n        decipher.final()\n    ])\n\n    return decrypted.toString('utf8')\n}\n\n/**\n * 批量加密 API Key 对象\n * \n * @param apiKeys 对象，key 为服务名，value 为对象（包含 apiKey 等字段）\n * @returns 加密后的字符串（JSON 格式）\n * \n * @example\n * const encrypted = encryptApiKeyObject({\n *   google: { apiKey: 'abc123' },\n *   fal: { apiKey: 'xyz789' }\n * })\n */\nexport function encryptApiKeyObject(apiKeys: ApiKeyObject): string {\n    const encrypted: ApiKeyObject = {}\n\n    for (const [provider, config] of Object.entries(apiKeys)) {\n        if (isApiKeyObject(config)) {\n            const encryptedConfig: ApiKeyObject = { ...config }\n\n            // 加密所有包含 'key' 或 'secret' 的字段\n            for (const [key, value] of Object.entries(config)) {\n                if (typeof value === 'string' && value.trim() !== '') {\n                    const lowerKey = key.toLowerCase()\n                    if (lowerKey.includes('key') || lowerKey.includes('secret')) {\n                        encryptedConfig[key] = encryptApiKey(value)\n                    }\n                }\n            }\n            encrypted[provider] = encryptedConfig\n        }\n    }\n\n    return JSON.stringify(encrypted)\n}\n\n/**\n * 批量解密 API Key 对象\n * \n * @param encryptedJson 加密后的 JSON 字符串\n * @returns 解密后的对象\n */\nexport function decryptApiKeyObject(encryptedJson: string): ApiKeyObject {\n    if (!encryptedJson || encryptedJson.trim() === '') {\n        return {}\n    }\n\n    try {\n        const encrypted = JSON.parse(encryptedJson) as unknown\n        if (!isApiKeyObject(encrypted)) {\n            return {}\n        }\n        const decrypted: ApiKeyObject = {}\n\n        for (const [provider, config] of Object.entries(encrypted)) {\n            if (isApiKeyObject(config)) {\n                const decryptedConfig: ApiKeyObject = { ...config }\n\n                // 解密所有包含 'key' 或 'secret' 的字段\n                for (const [key, value] of Object.entries(config)) {\n                    if (typeof value === 'string' && value.trim() !== '') {\n                        const lowerKey = key.toLowerCase()\n                        if (lowerKey.includes('key') || lowerKey.includes('secret')) {\n                            try {\n                                decryptedConfig[key] = decryptApiKey(value)\n                            } catch (error) {\n                                _ulogError(`解密 ${provider}.${key} 失败:`, error)\n                                // 如果解密失败，保持原值（可能是明文）\n                                decryptedConfig[key] = value\n                            }\n                        }\n                    }\n                }\n                decrypted[provider] = decryptedConfig\n            }\n        }\n\n        return decrypted\n    } catch (error) {\n        _ulogError('解密 API Key 对象失败:', error)\n        return {}\n    }\n}\n"
  },
  {
    "path": "src/lib/env.ts",
    "content": "/**\n * 🔧 环境配置工具\n * 集中管理环境变量的获取，避免到处重复\n */\n\nexport function getPublicBaseUrl(): string {\n    return process.env.NEXTAUTH_URL || 'http://localhost:3000'\n}\n\n/**\n * 获取应用内部 baseUrl。\n * 用于容器内自调用、服务端 fetch 本应用 API、拉取本地 /api/files 资源等场景。\n */\nexport function getInternalBaseUrl(): string {\n    return process.env.INTERNAL_APP_URL\n        || process.env.INTERNAL_TASK_API_BASE_URL\n        || process.env.NEXTAUTH_URL\n        || 'http://localhost:3000'\n}\n\n/**\n * 向后兼容：当前仓库中 getBaseUrl 主要用于服务端内部调用，因此默认返回内部地址。\n */\nexport function getBaseUrl(): string {\n    return getInternalBaseUrl()\n}\n\n/**\n * 获取完整的 API URL\n * @param path API 路径，如 '/api/user/balance'\n */\nexport function getApiUrl(path: string): string {\n    const baseUrl = getInternalBaseUrl()\n    // 确保 path 以 / 开头\n    const normalizedPath = path.startsWith('/') ? path : `/${path}`\n    return `${baseUrl}${normalizedPath}`\n}\n"
  },
  {
    "path": "src/lib/episode-marker-detector.ts",
    "content": "/**\n * 分集标记检测器\n * 用于检测文本中是否存在明确的分集标记，支持预分割\n */\n\nimport { countWords } from './word-count'\n\nexport interface EpisodeMarkerMatch {\n    index: number          // 在原文中的位置\n    text: string           // 匹配到的标记文本\n    episodeNumber: number  // 推断的集数\n}\n\nexport interface PreviewSplit {\n    number: number\n    title: string\n    wordCount: number\n    startIndex: number\n    endIndex: number\n    preview: string        // 前20字预览\n}\n\nexport interface EpisodeMarkerResult {\n    hasMarkers: boolean\n    markerType: string\n    markerTypeKey: string  // i18n key\n    confidence: 'high' | 'medium' | 'low'\n    matches: EpisodeMarkerMatch[]\n    previewSplits: PreviewSplit[]\n}\n\n// 中文数字映射\nconst CHINESE_NUMBERS: Record<string, number> = {\n    '零': 0, '〇': 0,\n    '一': 1, '壹': 1,\n    '二': 2, '贰': 2, '两': 2,\n    '三': 3, '叁': 3,\n    '四': 4, '肆': 4,\n    '五': 5, '伍': 5,\n    '六': 6, '陆': 6,\n    '七': 7, '柒': 7,\n    '八': 8, '捌': 8,\n    '九': 9, '玖': 9,\n    '十': 10, '拾': 10,\n    '百': 100, '佰': 100,\n    '千': 1000, '仟': 1000,\n}\n\n/**\n * 将中文数字转换为阿拉伯数字\n */\nfunction chineseToNumber(chinese: string): number {\n    // 如果是纯数字，直接返回\n    if (/^\\d+$/.test(chinese)) {\n        return parseInt(chinese, 10)\n    }\n\n    let result = 0\n    let temp = 0\n    let lastUnit = 1\n\n    for (const char of chinese) {\n        const num = CHINESE_NUMBERS[char]\n        if (num === undefined) continue\n\n        if (num >= 10) {\n            // 单位（十、百、千）\n            if (temp === 0) temp = 1\n            temp *= num\n            if (num >= lastUnit) {\n                result += temp\n                temp = 0\n            }\n            lastUnit = num\n        } else {\n            // 数字\n            temp = num\n        }\n    }\n\n    return result + temp\n}\n\n// 检测模式定义\ninterface DetectionPattern {\n    regex: RegExp\n    typeKey: string\n    typeName: string\n    extractNumber: (match: RegExpMatchArray) => number\n    extractTitle: (match: RegExpMatchArray, content: string, nextIndex?: number) => string\n}\n\nconst DETECTION_PATTERNS: DetectionPattern[] = [\n    // 1. 中文\"第X集\"\n    {\n        regex: /^第([一二三四五六七八九十百千\\d]+)集[：:\\s]*(.*)?/gm,\n        typeKey: 'episode',\n        typeName: '第X集',\n        extractNumber: (match) => chineseToNumber(match[1]),\n        extractTitle: (match) => match[2]?.trim() || ''\n    },\n    // 2. 中文\"第X章\"\n    {\n        regex: /^第([一二三四五六七八九十百千\\d]+)章[：:\\s]*(.*)?/gm,\n        typeKey: 'chapter',\n        typeName: '第X章',\n        extractNumber: (match) => chineseToNumber(match[1]),\n        extractTitle: (match) => match[2]?.trim() || ''\n    },\n    // 3. 中文\"第X幕\"\n    {\n        regex: /^第([一二三四五六七八九十百千\\d]+)幕[：:\\s]*(.*)?/gm,\n        typeKey: 'act',\n        typeName: '第X幕',\n        extractNumber: (match) => chineseToNumber(match[1]),\n        extractTitle: (match) => match[2]?.trim() || ''\n    },\n    // 4. 场景编号 X-Y【场景】 - 只取第一个数字作为集数\n    {\n        regex: /^(\\d+)-\\d+[【\\[](.*?)[】\\]]/gm,\n        typeKey: 'scene',\n        typeName: 'X-Y【场景】',\n        extractNumber: (match) => parseInt(match[1], 10),\n        extractTitle: (match) => match[2]?.trim() || ''\n    },\n    // 5. 数字前缀 \"1. 标题\" 或 \"1、标题\"\n    {\n        regex: /^(\\d+)[\\.、：:]\\s*(.+)/gm,\n        typeKey: 'numbered',\n        typeName: '数字编号',\n        extractNumber: (match) => parseInt(match[1], 10),\n        extractTitle: (match) => match[2]?.trim().slice(0, 20) || ''\n    },\n    // 5.5 数字+转义点 \"1\\.\" 或 \"3\\.\"（Markdown格式）\n    {\n        regex: /^(\\d+)\\\\\\.\\s*(.+)/gm,\n        typeKey: 'numberedEscaped',\n        typeName: '数字编号(转义)',\n        extractNumber: (match) => parseInt(match[1], 10),\n        extractTitle: (match) => match[2]?.trim().slice(0, 20) || ''\n    },\n    // 5.6 纯数字后直接跟中文（无分隔符）如 \"1太子带回\" - 需要数字在行首或段首\n    {\n        regex: /(?:^|\\n\\n)(\\d+)([\\u4e00-\\u9fa5])/gm,\n        typeKey: 'numberedDirect',\n        typeName: '数字+中文',\n        extractNumber: (match) => parseInt(match[1], 10),\n        extractTitle: (match) => match[2]?.trim().slice(0, 20) || ''\n    },\n    // 6. 英文 Episode\n    {\n        regex: /^Episode\\s*(\\d+)[：:\\s]*(.*)?/gim,\n        typeKey: 'episodeEn',\n        typeName: 'Episode X',\n        extractNumber: (match) => parseInt(match[1], 10),\n        extractTitle: (match) => match[2]?.trim() || ''\n    },\n    // 7. 英文 Chapter\n    {\n        regex: /^Chapter\\s*(\\d+)[：:\\s]*(.*)?/gim,\n        typeKey: 'chapterEn',\n        typeName: 'Chapter X',\n        extractNumber: (match) => parseInt(match[1], 10),\n        extractTitle: (match) => match[2]?.trim() || ''\n    },\n    // 8. Markdown加粗数字标记 (如 \"...内容**1**内容...\" 或 \"...内容**3**内容...\")\n    // 支持行内出现，不要求单独一行\n    {\n        regex: /\\*\\*(\\d+)\\*\\*/g,\n        typeKey: 'boldNumber',\n        typeName: '**数字**',\n        extractNumber: (match) => parseInt(match[1], 10),\n        extractTitle: () => ''\n    },\n    // 9. 纯数字单独一行 (如 \"1\\n内容\")\n    {\n        regex: /^(\\d+)\\s*$/gm,\n        typeKey: 'pureNumber',\n        typeName: '纯数字',\n        extractNumber: (match) => parseInt(match[1], 10),\n        extractTitle: () => ''\n    },\n]\n\n/**\n * 检测文本中的分集标记\n */\nexport function detectEpisodeMarkers(content: string): EpisodeMarkerResult {\n    const result: EpisodeMarkerResult = {\n        hasMarkers: false,\n        markerType: '',\n        markerTypeKey: '',\n        confidence: 'low',\n        matches: [],\n        previewSplits: []\n    }\n\n    if (!content || content.length < 100) {\n        return result\n    }\n\n    // 尝试每种模式\n    for (const pattern of DETECTION_PATTERNS) {\n        const regex = new RegExp(pattern.regex.source, pattern.regex.flags)\n        const matches: EpisodeMarkerMatch[] = []\n        let match: RegExpExecArray | null\n\n        while ((match = regex.exec(content)) !== null) {\n            const episodeNumber = pattern.extractNumber(match)\n\n            // 场景编号特殊处理：同一集只记录第一次出现\n            if (pattern.typeKey === 'scene') {\n                const existingMatch = matches.find(m => m.episodeNumber === episodeNumber)\n                if (existingMatch) {\n                    continue // 跳过同一集的后续场景\n                }\n            }\n\n            matches.push({\n                index: match.index,\n                text: match[0],\n                episodeNumber\n            })\n        }\n\n        // 如果这种模式匹配数量更多，使用它\n        if (matches.length >= 2 && matches.length > result.matches.length) {\n            result.matches = matches\n            result.markerType = pattern.typeName\n            result.markerTypeKey = pattern.typeKey\n            result.hasMarkers = true\n        }\n    }\n\n    if (!result.hasMarkers) {\n        return result\n    }\n\n    // 按位置排序\n    result.matches.sort((a, b) => a.index - b.index)\n\n    // 计算置信度\n    const matchCount = result.matches.length\n    const avgDistance = result.matches.length > 1\n        ? (result.matches[result.matches.length - 1].index - result.matches[0].index) / (result.matches.length - 1)\n        : 0\n\n    if (matchCount >= 3 && avgDistance >= 500 && avgDistance <= 8000) {\n        result.confidence = 'high'\n    } else if (matchCount >= 2) {\n        result.confidence = 'medium'\n    } else {\n        result.confidence = 'low'\n    }\n\n    // 生成预览分割\n    const previewSplits: PreviewSplit[] = []\n\n    // 🔥 检查第一个标记是否不是第1集，如果是且前面有内容，自动补充缺失的集\n    const firstMatch = result.matches[0]\n    if (firstMatch && firstMatch.episodeNumber > 1 && firstMatch.index > 100) {\n        // 补充从第1集到第一个标记前的所有集\n        for (let i = 1; i < firstMatch.episodeNumber; i++) {\n            // 只有第1集使用所有前面的内容\n            if (i === 1) {\n                const episodeContent = content.slice(0, firstMatch.index)\n                const preview = episodeContent.slice(0, 50).trim().slice(0, 20)\n                previewSplits.push({\n                    number: i,\n                    title: `第 ${i} 集`,\n                    wordCount: countWords(episodeContent),\n                    startIndex: 0,\n                    endIndex: firstMatch.index,\n                    preview: preview + (preview.length >= 20 ? '...' : '')\n                })\n                break // 只补充第1集，后续的1和2可能只是格式不同\n            }\n        }\n    }\n\n    // 处理正常检测到的标记\n    result.matches.forEach((match, idx) => {\n        const startIndex = idx === 0 && previewSplits.length === 0 ? 0 : match.index\n        const endIndex = idx < result.matches.length - 1\n            ? result.matches[idx + 1].index\n            : content.length\n\n        const episodeContent = content.slice(startIndex, endIndex)\n        const wordCount = countWords(episodeContent)\n\n        // 标题固定使用\"第 X 集\"格式\n        const title = `第 ${match.episodeNumber} 集`\n\n        // 生成预览：从数字前缀后开始取内容（只跳过如 \"1.\" 这样的前缀，不跳过整行）\n        const markerPositionInContent = match.index - startIndex\n        // 计算数字前缀的长度\n        const markerPrefix = match.text.match(/^(?:第[一二三四五六七八九十百千\\d]+[集章幕]|Episode\\s*\\d+|Chapter\\s*\\d+|\\*\\*\\d+\\*\\*|\\d+)[\\.、：:\\s]*/i)?.[0] || ''\n        const prefixLength = markerPrefix.length || match.text.length\n        const previewStart = markerPositionInContent + prefixLength\n        const preview = episodeContent.slice(previewStart, previewStart + 50).trim().slice(0, 20)\n\n        previewSplits.push({\n            number: match.episodeNumber,\n            title,\n            wordCount,\n            startIndex,\n            endIndex,\n            preview: preview + (preview.length >= 20 ? '...' : '')\n        })\n    })\n\n    result.previewSplits = previewSplits\n\n    return result\n}\n\n/**\n * 根据检测结果分割内容\n */\nexport function splitByMarkers(content: string, markerResult: EpisodeMarkerResult): Array<{\n    number: number\n    title: string\n    summary: string\n    content: string\n    wordCount: number\n}> {\n    if (!markerResult.hasMarkers || markerResult.previewSplits.length === 0) {\n        return []\n    }\n\n    return markerResult.previewSplits.map(split => {\n        const episodeContent = content.slice(split.startIndex, split.endIndex).trim()\n\n        return {\n            number: split.number,\n            title: split.title || `第 ${split.number} 集`,\n            summary: '', // 标识符分集不生成摘要\n            content: episodeContent,\n            wordCount: countWords(episodeContent)\n        }\n    })\n}\n"
  },
  {
    "path": "src/lib/error-handler.ts",
    "content": "import { getErrorSpec, resolveUnifiedErrorCode, type UnifiedErrorCode } from './errors/codes'\nimport { normalizeAnyError } from './errors/normalize'\n\nexport const ERROR_CODES = {\n  INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',\n  OPERATION_FAILED: 'INTERNAL_ERROR',\n  NETWORK_ERROR: 'NETWORK_ERROR',\n} as const\n\nexport type ErrorCode = typeof ERROR_CODES[keyof typeof ERROR_CODES]\n\ntype ApiErrorPayload = {\n  success?: boolean\n  code?: string\n  message?: string\n  error?:\n    | string\n    | {\n        code?: string\n        message?: string\n        retryable?: boolean\n        category?: string\n        userMessageKey?: string\n        details?: Record<string, unknown>\n      }\n}\n\ntype ParsedApiErrorPayload = {\n  code?: string\n  message?: string\n  details?: Record<string, unknown>\n}\n\nfunction toUnifiedErrorCode(code: ErrorCode | UnifiedErrorCode): UnifiedErrorCode {\n  const resolved = resolveUnifiedErrorCode(code)\n  return resolved || ERROR_CODES.OPERATION_FAILED\n}\n\nfunction parseApiErrorPayload(payload: ApiErrorPayload | null): ParsedApiErrorPayload {\n  if (!payload) return {}\n\n  const objectError = typeof payload.error === 'object' && payload.error ? payload.error : null\n  const stringError = typeof payload.error === 'string' ? payload.error : null\n\n  return {\n    code: objectError?.code || payload.code || undefined,\n    message: objectError?.message || payload.message || stringError || undefined,\n    details: objectError?.details || undefined,\n  }\n}\n\nasync function readApiErrorPayload(res: Response): Promise<ApiErrorPayload | null> {\n  try {\n    return (await res.json()) as ApiErrorPayload\n  } catch {\n    return null\n  }\n}\n\nexport async function handleApiError(\n  res: Response,\n  fallbackCode: ErrorCode = ERROR_CODES.OPERATION_FAILED,\n): Promise<never> {\n  const payload = await readApiErrorPayload(res)\n  const parsed = parseApiErrorPayload(payload)\n\n  if (res.status === getErrorSpec('INSUFFICIENT_BALANCE').httpStatus) {\n    throw new Error(ERROR_CODES.INSUFFICIENT_BALANCE)\n  }\n\n  const normalized = normalizeAnyError(\n    {\n      code: parsed.code,\n      message: parsed.message,\n      details: parsed.details,\n      status: res.status,\n    },\n    {\n      context: 'api',\n      fallbackCode: toUnifiedErrorCode(fallbackCode),\n    },\n  )\n\n  throw new Error(normalized.code)\n}\n\nexport async function checkApiResponse(\n  res: Response,\n  fallbackCode: ErrorCode = ERROR_CODES.OPERATION_FAILED,\n): Promise<void> {\n  if (res.ok) return\n  await handleApiError(res, fallbackCode)\n}\n\nexport function isInsufficientBalanceError(error: Error): boolean {\n  return error.message === ERROR_CODES.INSUFFICIENT_BALANCE\n}\n"
  },
  {
    "path": "src/lib/error-utils.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport type { UnifiedErrorCode } from '@/lib/errors/codes'\nimport { getUserMessageByCode } from '@/lib/errors/user-messages'\nimport { normalizeAnyError } from '@/lib/errors/normalize'\n\n/**\n * 检查错误是否是由于页面卸载/刷新导致的 fetch 中止\n * 用于避免在页面刷新时显示无意义的错误提示\n */\nexport function isAbortError(error: unknown): boolean {\n    if (!error) return false\n\n    // 检查 AbortError\n    if (error instanceof DOMException && error.name === 'AbortError') {\n        return true\n    }\n\n    // 检查 fetch 失败相关的错误信息\n    if (error instanceof Error) {\n        const message = error.message.toLowerCase()\n        if (\n            message.includes('abort') ||\n            message.includes('cancelled') ||\n            message.includes('canceled') ||\n            message.includes('failed to fetch') ||\n            message.includes('network request failed') ||\n            message.includes('load failed') ||\n            message.includes('the operation was aborted')\n        ) {\n            return true\n        }\n    }\n\n    // 检查 TypeError (通常是网络错误)\n    if (error instanceof TypeError && error.message.includes('fetch')) {\n        return true\n    }\n\n    return false\n}\n\nexport function resolveClientError(error: unknown, fallbackCode: UnifiedErrorCode = 'INTERNAL_ERROR'): {\n    code: UnifiedErrorCode\n    message: string\n    rawMessage: string\n} {\n    const normalized = normalizeAnyError(error, {\n        context: 'api',\n        fallbackCode,\n    })\n\n    return {\n        code: normalized.code,\n        message: getUserMessageByCode(normalized.code),\n        rawMessage: normalized.message,\n    }\n}\n\n/**\n * 安全的错误提示函数\n * 如果错误是由于页面刷新导致的，则不显示提示\n */\nexport function safeAlert(message: string, error?: unknown): void {\n    // 如果是页面刷新导致的错误，静默处理\n    if (error && isAbortError(error)) {\n        _ulogInfo('[Info] 请求被中止（可能是页面刷新）:', message)\n        return\n    }\n\n    if (error) {\n        const resolved = resolveClientError(error)\n        alert(message || resolved.message)\n        return\n    }\n\n    alert(message)\n}\n\n/**\n * 安全的错误处理函数\n * 返回是否应该显示错误（如果是页面刷新导致的错误则返回 false）\n */\nexport function shouldShowError(error: unknown): boolean {\n    return !isAbortError(error)\n}\n"
  },
  {
    "path": "src/lib/errors/codes.ts",
    "content": "export const ERROR_CATEGORY = {\n  AUTH: 'AUTH',\n  BILLING: 'BILLING',\n  CONTENT: 'CONTENT',\n  PROVIDER: 'PROVIDER',\n  SYSTEM: 'SYSTEM',\n  VALIDATION: 'VALIDATION',\n} as const\n\nexport type ErrorCategory = (typeof ERROR_CATEGORY)[keyof typeof ERROR_CATEGORY]\n\nexport const ERROR_CATALOG = {\n  UNAUTHORIZED: {\n    httpStatus: 401,\n    retryable: false,\n    category: ERROR_CATEGORY.AUTH,\n    userMessageKey: 'errors.UNAUTHORIZED',\n    defaultMessage: 'Unauthorized',\n  },\n  FORBIDDEN: {\n    httpStatus: 403,\n    retryable: false,\n    category: ERROR_CATEGORY.AUTH,\n    userMessageKey: 'errors.FORBIDDEN',\n    defaultMessage: 'Forbidden',\n  },\n  NOT_FOUND: {\n    httpStatus: 404,\n    retryable: false,\n    category: ERROR_CATEGORY.VALIDATION,\n    userMessageKey: 'errors.NOT_FOUND',\n    defaultMessage: 'Resource not found',\n  },\n  INVALID_PARAMS: {\n    httpStatus: 400,\n    retryable: false,\n    category: ERROR_CATEGORY.VALIDATION,\n    userMessageKey: 'errors.INVALID_PARAMS',\n    defaultMessage: 'Invalid parameters',\n  },\n  MISSING_CONFIG: {\n    httpStatus: 400,\n    retryable: false,\n    category: ERROR_CATEGORY.VALIDATION,\n    userMessageKey: 'errors.MISSING_CONFIG',\n    defaultMessage: 'Missing required configuration',\n  },\n  CONFLICT: {\n    httpStatus: 409,\n    retryable: false,\n    category: ERROR_CATEGORY.VALIDATION,\n    userMessageKey: 'errors.CONFLICT',\n    defaultMessage: 'Conflict',\n  },\n  TASK_NOT_READY: {\n    httpStatus: 202,\n    retryable: true,\n    category: ERROR_CATEGORY.SYSTEM,\n    userMessageKey: 'errors.TASK_NOT_READY',\n    defaultMessage: 'Task is not ready',\n  },\n  NO_RESULT: {\n    httpStatus: 404,\n    retryable: false,\n    category: ERROR_CATEGORY.SYSTEM,\n    userMessageKey: 'errors.NO_RESULT',\n    defaultMessage: 'No task result',\n  },\n  RATE_LIMIT: {\n    httpStatus: 429,\n    retryable: true,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.RATE_LIMIT',\n    defaultMessage: 'Rate limit exceeded',\n  },\n  MODEL_NOT_OPEN: {\n    httpStatus: 403,\n    retryable: false,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.MODEL_NOT_OPEN',\n    defaultMessage: 'Model is not activated for this account',\n  },\n  MODEL_NOT_REGISTERED: {\n    httpStatus: 400,\n    retryable: false,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.MODEL_NOT_REGISTERED',\n    defaultMessage: 'Model is not registered',\n  },\n  MODEL_NOT_CONFIGURED: {\n    httpStatus: 400,\n    retryable: false,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.MODEL_NOT_CONFIGURED',\n    defaultMessage: 'Model is not configured. Please add a model in the settings first.',\n  },\n  QUOTA_EXCEEDED: {\n    httpStatus: 429,\n    retryable: true,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.QUOTA_EXCEEDED',\n    defaultMessage: 'Quota exceeded',\n  },\n  EXTERNAL_ERROR: {\n    httpStatus: 502,\n    retryable: true,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.EXTERNAL_ERROR',\n    defaultMessage: 'External service failed',\n  },\n  NETWORK_ERROR: {\n    httpStatus: 502,\n    retryable: true,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.NETWORK_ERROR',\n    defaultMessage: 'Network request failed',\n  },\n  EMPTY_RESPONSE: {\n    httpStatus: 502,\n    retryable: true,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.EMPTY_RESPONSE',\n    defaultMessage: 'Model returned empty response',\n  },\n  INSUFFICIENT_BALANCE: {\n    httpStatus: 402,\n    retryable: false,\n    category: ERROR_CATEGORY.BILLING,\n    userMessageKey: 'errors.INSUFFICIENT_BALANCE',\n    defaultMessage: 'Insufficient balance',\n  },\n  SENSITIVE_CONTENT: {\n    httpStatus: 422,\n    retryable: false,\n    category: ERROR_CATEGORY.CONTENT,\n    userMessageKey: 'errors.SENSITIVE_CONTENT',\n    defaultMessage: 'Sensitive content detected',\n  },\n  GENERATION_TIMEOUT: {\n    httpStatus: 504,\n    retryable: true,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.GENERATION_TIMEOUT',\n    defaultMessage: 'Generation timed out',\n  },\n  VIDEO_API_FORMAT_UNSUPPORTED: {\n    httpStatus: 400,\n    retryable: false,\n    category: ERROR_CATEGORY.VALIDATION,\n    userMessageKey: 'errors.VIDEO_API_FORMAT_UNSUPPORTED',\n    defaultMessage: 'Video API format is unsupported',\n  },\n  GENERATION_FAILED: {\n    httpStatus: 500,\n    retryable: true,\n    category: ERROR_CATEGORY.PROVIDER,\n    userMessageKey: 'errors.GENERATION_FAILED',\n    defaultMessage: 'Generation failed',\n  },\n  WATCHDOG_TIMEOUT: {\n    httpStatus: 500,\n    retryable: true,\n    category: ERROR_CATEGORY.SYSTEM,\n    userMessageKey: 'errors.WATCHDOG_TIMEOUT',\n    defaultMessage: 'Task heartbeat timeout',\n  },\n  WORKER_EXECUTION_ERROR: {\n    httpStatus: 500,\n    retryable: true,\n    category: ERROR_CATEGORY.SYSTEM,\n    userMessageKey: 'errors.WORKER_EXECUTION_ERROR',\n    defaultMessage: 'Worker execution failed',\n  },\n  INTERNAL_ERROR: {\n    httpStatus: 500,\n    retryable: false,\n    category: ERROR_CATEGORY.SYSTEM,\n    userMessageKey: 'errors.INTERNAL_ERROR',\n    defaultMessage: 'Internal server error',\n  },\n} as const\n\nexport type UnifiedErrorCode = keyof typeof ERROR_CATALOG\n\nexport const DEFAULT_ERROR_CODE: UnifiedErrorCode = 'INTERNAL_ERROR'\n\nexport const LEGACY_ERROR_CODE_ALIASES: Record<string, UnifiedErrorCode> = {\n  OPERATION_FAILED: 'INTERNAL_ERROR',\n}\n\nexport function isKnownErrorCode(code: unknown): code is UnifiedErrorCode {\n  return typeof code === 'string' && code in ERROR_CATALOG\n}\n\nexport function resolveUnifiedErrorCode(code: unknown): UnifiedErrorCode | null {\n  if (isKnownErrorCode(code)) return code\n  if (typeof code !== 'string') return null\n  const normalized = code.trim().toUpperCase()\n  return LEGACY_ERROR_CODE_ALIASES[normalized] || null\n}\n\nexport function getErrorSpec(code: UnifiedErrorCode) {\n  return ERROR_CATALOG[code]\n}\n"
  },
  {
    "path": "src/lib/errors/display.ts",
    "content": "import { resolveUnifiedErrorCode } from './codes'\nimport { getUserMessageByCode } from './user-messages'\nimport { normalizeAnyError } from './normalize'\n\n/** 从原始错误消息中提取面向用户的关键细节 */\nfunction extractProviderDetail(raw: string | null | undefined): string | null {\n  if (!raw || typeof raw !== 'string') return null\n  // 优先从 JSON 体中提取 \"message\" 字段（ARK / OpenRouter / 多数 OpenAI 兼容 API）\n  const jsonMatch = raw.match(/\\{.*\"message\"\\s*:\\s*\"([^\"]+)\"/)\n  if (jsonMatch?.[1]) return jsonMatch[1]\n  // 兜底：移除内部前缀如 \"[ARK Image] 图片生成失败: \" ，保留核心描述\n  const cleaned = raw\n    .replace(/^\\[[\\w\\s]+\\]\\s*/g, '')           // [ARK Image]\n    .replace(/^[\\w\\s]+失败:\\s*/g, '')           // 图片生成失败:\n    .replace(/^\\d{3}\\s*-\\s*/g, '')              // 400 -\n    .trim()\n  return cleaned || null\n}\n\nexport function resolveErrorDisplay(input?: {\n  code?: string | null\n  message?: string | null\n} | null) {\n  if (!input) return null\n  // code 和 message 都为空时，表示没有错误，直接返回 null\n  // 如果不做这个判断，normalizeAnyError 会对空输入兜底返回 INTERNAL_ERROR，导致所有面板误报\n  if (!input.code && !input.message) return null\n\n  const code = resolveUnifiedErrorCode(input.code)\n  if (code && code !== 'INTERNAL_ERROR') {\n    const userMessage = getUserMessageByCode(code)\n    if (code === 'VIDEO_API_FORMAT_UNSUPPORTED') {\n      return {\n        code,\n        message: userMessage,\n      }\n    }\n    // 尝试从原始 message 中提取 API 返回的具体细节\n    const detail = extractProviderDetail(input.message)\n    return {\n      code,\n      message: detail ? `${userMessage}\\n${detail}` : userMessage,\n    }\n  }\n\n  // 当 code 是兜底的 INTERNAL_ERROR 或 code 缺失时，尝试从 message 推断更具体的错误码\n  // 这样像\"敏感内容\"、\"余额不足\"、\"网络错误\"等具体错误能正确显示\n  const normalized = normalizeAnyError(\n    { code: input.code || undefined, message: input.message || undefined },\n    { context: 'api' },\n  )\n  if (normalized?.code) {\n    const userMessage = getUserMessageByCode(normalized.code)\n    const detail = extractProviderDetail(input.message)\n    return {\n      code: normalized.code,\n      message: detail ? `${userMessage}\\n${detail}` : userMessage,\n    }\n  }\n\n  return null\n}\n"
  },
  {
    "path": "src/lib/errors/extract.ts",
    "content": "export function extractErrorMessage(error: unknown, fallback = 'Unknown error'): string {\n  if (error instanceof Error && typeof error.message === 'string' && error.message.trim()) {\n    return error.message\n  }\n  if (typeof error === 'object' && error !== null) {\n    const message = (error as { message?: unknown }).message\n    if (typeof message === 'string' && message.trim()) return message\n  }\n  return fallback\n}\n\nexport function extractErrorStatus(error: unknown): number | null {\n  if (typeof error === 'object' && error !== null) {\n    const status = (error as { status?: unknown }).status\n    if (typeof status === 'number') return status\n  }\n  return null\n}\n"
  },
  {
    "path": "src/lib/errors/normalize.ts",
    "content": "import { InsufficientBalanceError } from '@/lib/billing/errors'\nimport { getPrismaErrorCode, isLikelyPrismaDisconnectError, isPrismaRetryableCode } from '@/lib/prisma-error'\nimport { DEFAULT_ERROR_CODE, getErrorSpec, isKnownErrorCode, resolveUnifiedErrorCode, type UnifiedErrorCode } from './codes'\nimport type { ErrorContext, NormalizedError, NormalizedErrorDetails } from './types'\n\ntype NormalizeOptions = {\n  context?: ErrorContext\n  fallbackCode?: UnifiedErrorCode\n  details?: Record<string, unknown> | null\n}\n\ntype ErrorLike = {\n  code?: unknown\n  status?: unknown\n  message?: unknown\n  details?: unknown\n  provider?: unknown\n}\n\nfunction toMessage(value: unknown): string {\n  if (typeof value === 'string' && value.trim()) return value.trim()\n  if (value instanceof Error && value.message.trim()) return value.message.trim()\n  try {\n    return JSON.stringify(value)\n  } catch {\n    return ''\n  }\n}\n\nfunction toLowerMessage(value: unknown): string {\n  return toMessage(value).toLowerCase()\n}\n\nfunction containsAny(haystack: string, needles: string[]) {\n  for (const needle of needles) {\n    if (haystack.includes(needle)) return true\n  }\n  return false\n}\n\nfunction isModelNotOpenCode(code: unknown): boolean {\n  if (typeof code !== 'string') return false\n  const normalized = code.trim().toUpperCase()\n  return normalized === 'MODELNOTOPEN' || normalized === 'MODEL_NOT_OPEN'\n}\n\nfunction isModelNotOpenMessage(message: string): boolean {\n  return containsAny(message, [\n    'modelnotopen',\n    'has not activated the model',\n    'not activated the model',\n    'activate the model service in the ark console',\n  ])\n}\n\nfunction isModelNotRegisteredMessage(message: string): boolean {\n  return containsAny(message, [\n    'model_not_registered',\n    'model not registered',\n  ])\n}\n\n/**\n * MODEL_NOT_CONFIGURED: 用户未配置对应类型的模型\n * 覆盖形式：model_not_found / model_not_configured / no xxx model is enabled\n */\nfunction isModelNotConfiguredMessage(message: string): boolean {\n  return containsAny(message, [\n    'model_not_found',\n    'model_not_configured',\n    'is not enabled for image',\n    'is not enabled for video',\n    'is not enabled for audio',\n    'is not enabled for lipsync',\n    'is not enabled for llm',\n    'no image model is enabled',\n    'no video model is enabled',\n    'no audio model is enabled',\n    'no lipsync model is enabled',\n    'no llm model is enabled',\n    'multiple image models are enabled',\n    'multiple video models are enabled',\n    'multiple audio models are enabled',\n    'multiple lipsync models are enabled',\n    'multiple llm models are enabled',\n  ])\n}\n\nfunction isEmptyResponseMessage(message: string): boolean {\n  return containsAny(message, [\n    'channel:empty_response',\n    'empty response',\n    'no meaningful content in candidates',\n    'stream_empty',\n  ])\n}\n\nfunction isVideoApiFormatUnsupportedMessage(message: string): boolean {\n  if (containsAny(message, [\n    'video_api_format_unsupported',\n    'openai_compat_video_template_required',\n    'openai_compat_video_template_media_type_invalid',\n    'openai_compat_video_template_create_body_required',\n    'openai_compat_video_template_output_not_found',\n    'openai_compat_video_template_task_id_not_found',\n    'openai_compat_template_variable_missing',\n    'openai_compat_template_multipart_body_invalid',\n    'openai_compat_template_multipart_file_invalid',\n  ])) {\n    return true\n  }\n\n  const templateStatusMatch = message.match(/template request failed with status (\\d{3})/i)\n  if (!templateStatusMatch) return false\n\n  const parsedStatus = Number.parseInt(templateStatusMatch[1] || '', 10)\n  return parsedStatus === 404 || parsedStatus === 405 || parsedStatus === 415\n}\n\nfunction buildNormalizedError(\n  code: UnifiedErrorCode,\n  message?: string,\n  details: NormalizedErrorDetails = null,\n  provider?: string | null,\n): NormalizedError {\n  const spec = getErrorSpec(code)\n  return {\n    code,\n    message: message?.trim() || spec.defaultMessage,\n    httpStatus: spec.httpStatus,\n    retryable: spec.retryable,\n    category: spec.category,\n    userMessageKey: spec.userMessageKey,\n    details,\n    provider: provider || null,\n  }\n}\n\nfunction inferCodeFromMessage(message: string): UnifiedErrorCode | null {\n  const upper = message.toUpperCase()\n  const explicitMatch = upper.match(/\\b([A-Z_]{3,})\\b/)\n  if (explicitMatch && isKnownErrorCode(explicitMatch[1])) {\n    return explicitMatch[1]\n  }\n\n  const statusMatch = message.match(/\\bstatus\\s+(\\d{3})\\b/)\n  if (statusMatch) {\n    const parsedStatus = Number.parseInt(statusMatch[1] || '', 10)\n    if (Number.isFinite(parsedStatus)) {\n      if (parsedStatus === 404 || parsedStatus === 405 || parsedStatus === 415) {\n        return 'VIDEO_API_FORMAT_UNSUPPORTED'\n      }\n      if (parsedStatus === 401) return 'UNAUTHORIZED'\n      if (parsedStatus === 403) return 'FORBIDDEN'\n      if (parsedStatus === 404) return 'NOT_FOUND'\n      if (parsedStatus === 409) return 'CONFLICT'\n      if (parsedStatus === 422) return 'SENSITIVE_CONTENT'\n      if (parsedStatus === 429) return 'RATE_LIMIT'\n      if (parsedStatus === 502 || parsedStatus === 503) return 'EXTERNAL_ERROR'\n      if (parsedStatus === 504) return 'GENERATION_TIMEOUT'\n      if (parsedStatus >= 500) return 'EXTERNAL_ERROR'\n      if (parsedStatus >= 400) return 'INVALID_PARAMS'\n    }\n  }\n\n  if (isModelNotOpenMessage(message)) return 'MODEL_NOT_OPEN'\n  if (isModelNotRegisteredMessage(message)) return 'MODEL_NOT_REGISTERED'\n  if (isModelNotConfiguredMessage(message)) return 'MODEL_NOT_CONFIGURED'\n  if (isEmptyResponseMessage(message)) return 'EMPTY_RESPONSE'\n  if (isVideoApiFormatUnsupportedMessage(message)) return 'VIDEO_API_FORMAT_UNSUPPORTED'\n  if (containsAny(message, ['task cancelled', 'canceled by user', 'cancelled by user', '任务已取消'])) return 'CONFLICT'\n  if (containsAny(message, ['unauthorized', 'not authenticated', 'need login', '401'])) return 'UNAUTHORIZED'\n  // AccountOverdueError（ARK 欠费 403）必须在 FORBIDDEN 之前检查\n  if (containsAny(message, ['accountoverdueerror', 'overdue balance', 'overdue', 'account has an overdue'])) return 'INSUFFICIENT_BALANCE'\n  if (containsAny(message, ['forbidden', 'permission denied', '403'])) return 'FORBIDDEN'\n  if (containsAny(message, ['not found', '不存在', 'missing record'])) return 'NOT_FOUND'\n  if (containsAny(message, ['invalid', 'missing', 'required', 'bad request', 'fieldinvalid'])) return 'INVALID_PARAMS'\n  if (containsAny(message, ['quota', 'rate limit', 'resource_exhausted', 'throttle', '429'])) return 'RATE_LIMIT'\n  if (containsAny(message, ['insufficient balance', 'creditinsufficient', 'balance is not enough', '402', 'insufficient credits', '余额不足', '余额不够', '请充值'])) return 'INSUFFICIENT_BALANCE'\n  if (containsAny(message, ['sensitive', 'unsafe', 'safety', 'blocked', 'prohibited', 'policy_violation', 'moderation', 'harm', '敏感', '违规', '不当', '安全策略', '被过滤']) && !containsAny(message, ['case-sensitive', 'case sensitive'])) return 'SENSITIVE_CONTENT'\n  if (containsAny(message, ['timeout', 'timed out', 'deadline exceeded'])) return 'GENERATION_TIMEOUT'\n  if (containsAny(message, ['503', 'unavailable', 'overloaded', 'upstream error'])) return 'EXTERNAL_ERROR'\n  if (containsAny(message, ['network', 'fetch failed', 'econnreset', 'enotfound', 'econnrefused', 'eai_again', 'terminated', 'aborted', 'socket hang up'])) return 'NETWORK_ERROR'\n  if (containsAny(message, ['conflict', 'already exists', 'duplicate'])) return 'CONFLICT'\n  return null\n}\n\nfunction inferCodeFromPrismaCode(prismaCode: string): UnifiedErrorCode {\n  if (prismaCode === 'P2002') return 'CONFLICT'\n  if (prismaCode === 'P2001' || prismaCode === 'P2025') return 'NOT_FOUND'\n  if (isPrismaRetryableCode(prismaCode)) return 'EXTERNAL_ERROR'\n  return 'INTERNAL_ERROR'\n}\n\nexport function normalizeAnyError(input: unknown, options: NormalizeOptions = {}): NormalizedError {\n  const fallbackCode = options.fallbackCode || DEFAULT_ERROR_CODE\n  const errorLike = (input || {}) as ErrorLike\n  const message = toMessage(errorLike.message ?? input)\n  const lowerMessage = toLowerMessage(message)\n  const provider = typeof errorLike.provider === 'string' ? errorLike.provider : null\n\n  if (input instanceof TypeError) {\n    if (lowerMessage === 'terminated' || containsAny(lowerMessage, ['aborted', 'socket hang up'])) {\n      return buildNormalizedError(\n        'NETWORK_ERROR',\n        message || 'Network request terminated',\n        options.details,\n        provider,\n      )\n    }\n  }\n\n  const prismaCode = getPrismaErrorCode(input)\n  if (prismaCode) {\n    return buildNormalizedError(\n      inferCodeFromPrismaCode(prismaCode),\n      message || `Database request failed (${prismaCode})`,\n      {\n        prismaCode,\n        ...(options.details || {}),\n      },\n      provider,\n    )\n  }\n\n  if (isLikelyPrismaDisconnectError(input)) {\n    return buildNormalizedError(\n      'EXTERNAL_ERROR',\n      message || 'Database connection unavailable',\n      options.details,\n      provider,\n    )\n  }\n\n  if (input instanceof InsufficientBalanceError) {\n    return buildNormalizedError('INSUFFICIENT_BALANCE', message || input.message, {\n      required: input.required,\n      available: input.available,\n      ...(options.details || {}),\n    })\n  }\n\n  const resolvedCode = resolveUnifiedErrorCode(errorLike.code)\n  if (resolvedCode) {\n    return buildNormalizedError(resolvedCode, message, {\n      ...(typeof errorLike.details === 'object' && errorLike.details ? (errorLike.details as Record<string, unknown>) : {}),\n      ...(options.details || {}),\n    }, provider)\n  }\n\n  if (isModelNotOpenCode(errorLike.code) || isModelNotOpenMessage(lowerMessage)) {\n    return buildNormalizedError('MODEL_NOT_OPEN', message, options.details, provider)\n  }\n  if (isModelNotRegisteredMessage(lowerMessage)) {\n    return buildNormalizedError('MODEL_NOT_REGISTERED', message, options.details, provider)\n  }\n  if (isModelNotConfiguredMessage(lowerMessage)) {\n    return buildNormalizedError('MODEL_NOT_CONFIGURED', message, options.details, provider)\n  }\n  if (isEmptyResponseMessage(lowerMessage)) {\n    return buildNormalizedError('EMPTY_RESPONSE', message, options.details, provider)\n  }\n\n  if (typeof errorLike.status === 'number') {\n    if (errorLike.status === 401) return buildNormalizedError('UNAUTHORIZED', message, options.details, provider)\n    // 403 可能是欠费（AccountOverdueError），需优先检查消息内容再决定错误码\n    if (errorLike.status === 403) {\n      if (containsAny(lowerMessage, ['accountoverdueerror', 'overdue balance', 'overdue', 'account has an overdue'])) {\n        return buildNormalizedError('INSUFFICIENT_BALANCE', message, options.details, provider)\n      }\n      return buildNormalizedError('FORBIDDEN', message, options.details, provider)\n    }\n    if (errorLike.status === 404) return buildNormalizedError('NOT_FOUND', message, options.details, provider)\n    if (errorLike.status === 409) return buildNormalizedError('CONFLICT', message, options.details, provider)\n    if (errorLike.status === 422) return buildNormalizedError('SENSITIVE_CONTENT', message, options.details, provider)\n    if (errorLike.status === 429) return buildNormalizedError('RATE_LIMIT', message, options.details, provider)\n    if (errorLike.status === 502 || errorLike.status === 503) return buildNormalizedError('EXTERNAL_ERROR', message, options.details, provider)\n    if (errorLike.status === 504) return buildNormalizedError('GENERATION_TIMEOUT', message, options.details, provider)\n  }\n\n  const inferredCode = inferCodeFromMessage(lowerMessage)\n  if (inferredCode) {\n    return buildNormalizedError(inferredCode, message, options.details, provider)\n  }\n\n  if (options.context === 'worker' && containsAny(lowerMessage, ['provider', 'generation failed'])) {\n    return buildNormalizedError('GENERATION_FAILED', message, options.details, provider)\n  }\n\n  return buildNormalizedError(fallbackCode, message || getErrorSpec(fallbackCode).defaultMessage, options.details, provider)\n}\n\nexport function normalizeTaskError(\n  code: string | null | undefined,\n  message: string | null | undefined,\n  details: Record<string, unknown> | null = null,\n): NormalizedError | null {\n  if (!code && !message) return null\n\n  if (code === 'TASK_CANCELLED') {\n    return buildNormalizedError(\n      'CONFLICT',\n      message || 'Task cancelled by user',\n      {\n        ...(details || {}),\n        cancelled: true,\n        originalCode: code,\n      },\n    )\n  }\n\n  const resolvedTaskCode = resolveUnifiedErrorCode(code)\n  if (resolvedTaskCode) {\n    return buildNormalizedError(resolvedTaskCode, message || undefined, details)\n  }\n\n  const inferred = normalizeAnyError(\n    {\n      code,\n      message,\n      details,\n    },\n    {\n      fallbackCode: DEFAULT_ERROR_CODE,\n    },\n  )\n\n  if (code && !resolveUnifiedErrorCode(code)) {\n    return {\n      ...inferred,\n      details: {\n        ...(inferred.details || {}),\n        originalCode: code,\n      },\n    }\n  }\n\n  return inferred\n}\n"
  },
  {
    "path": "src/lib/errors/types.ts",
    "content": "import type { ErrorCategory, UnifiedErrorCode } from './codes'\n\nexport type ErrorContext = 'api' | 'worker'\n\nexport type NormalizedErrorDetails = Record<string, unknown> | null\n\nexport type NormalizedError = {\n  code: UnifiedErrorCode\n  message: string\n  httpStatus: number\n  retryable: boolean\n  category: ErrorCategory\n  userMessageKey: string\n  details: NormalizedErrorDetails\n  provider?: string | null\n}\n"
  },
  {
    "path": "src/lib/errors/user-messages.ts",
    "content": "import type { UnifiedErrorCode } from './codes'\n\nexport const USER_ERROR_MESSAGES_ZH: Record<UnifiedErrorCode, string> = {\n  UNAUTHORIZED: '请先登录后再试。',\n  FORBIDDEN: '你没有权限执行此操作。',\n  NOT_FOUND: '没有找到对应的数据。',\n  INVALID_PARAMS: '请求参数不正确，请检查后重试。',\n  MISSING_CONFIG: '系统配置不完整，请联系管理员。',\n  CONFLICT: '当前状态冲突，请刷新后重试。',\n  TASK_NOT_READY: '任务还在处理中，请稍后。',\n  NO_RESULT: '任务已完成，但没有可用结果。',\n  RATE_LIMIT: '请求过于频繁，请稍后重试。',\n  MODEL_NOT_OPEN: '模型权限未开通。请前往 https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model ，在模型管理页面点击右上角「一键开通所有模型」。',\n  MODEL_NOT_REGISTERED: '模型尚未注册，请先完成模型配置后再试。',\n  MODEL_NOT_CONFIGURED: '未配置可用模型，请先前往设置页面添加对应类型的模型后再试。',\n  QUOTA_EXCEEDED: '额度已用尽，请稍后再试。',\n  EXTERNAL_ERROR: '外部服务暂时不可用，请稍后重试。',\n  NETWORK_ERROR: '网络异常，请稍后重试。',\n  EMPTY_RESPONSE: '模型返回空响应（无有效内容），请稍后重试。',\n  INSUFFICIENT_BALANCE: '余额不足，请先充值。',\n  SENSITIVE_CONTENT: '内容可能涉及敏感信息，请修改后重试。',\n  GENERATION_TIMEOUT: '生成超时，请重试。',\n  VIDEO_API_FORMAT_UNSUPPORTED: '当前视频接口格式暂不支持。',\n  GENERATION_FAILED: '生成失败，请稍后重试。',\n  WATCHDOG_TIMEOUT: '任务执行超时，系统已终止该任务。',\n  WORKER_EXECUTION_ERROR: '任务执行失败，请稍后重试。',\n  INTERNAL_ERROR: '系统内部错误，请稍后重试。',\n}\n\nexport function getUserMessageByCode(code: UnifiedErrorCode) {\n  return USER_ERROR_MESSAGES_ZH[code]\n}\n"
  },
  {
    "path": "src/lib/fonts.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport fs from 'fs'\nimport path from 'path'\nimport { ImageResponse } from '@vercel/og'\nimport type { ReactElement } from 'react'\n\n// 字体文件可能的路径（按优先级尝试）\nconst POSSIBLE_FONT_PATHS = [\n    path.join(process.cwd(), 'src/assets/fonts/NotoSansSC-Regular.ttf'),\n    path.join(process.cwd(), '.next/server/src/assets/fonts/NotoSansSC-Regular.ttf'),\n]\n\n// 缓存字体数据（只加载一次）\nlet fontDataCache: Buffer | null = null\nlet fontInitialized = false\n\n/**\n * 加载字体文件\n */\nfunction loadFontData(): Buffer | null {\n    if (fontDataCache) {\n        return fontDataCache\n    }\n\n    _ulogInfo('[Fonts] Searching for font file...')\n\n    for (const fontPath of POSSIBLE_FONT_PATHS) {\n        _ulogInfo('[Fonts] Trying:', fontPath)\n        if (fs.existsSync(fontPath)) {\n            fontDataCache = fs.readFileSync(fontPath)\n            _ulogInfo('[Fonts] ✅ Font loaded:', fontPath, `(${(fontDataCache.length / 1024 / 1024).toFixed(2)} MB)`)\n            return fontDataCache\n        }\n    }\n\n    _ulogError('[Fonts] ❌ Font file not found')\n    return null\n}\n\n/**\n * 初始化字体配置（预加载字体到内存）\n */\nexport async function initializeFonts(): Promise<void> {\n    if (fontInitialized) {\n        return\n    }\n\n    loadFontData()\n    fontInitialized = true\n}\n\n/**\n * 获取字体名称\n */\nexport function getFontFamily(): string {\n    return 'NotoSansSC'\n}\n\n/**\n * 使用 @vercel/og 生成文字标签图片（PNG Buffer）\n * 这个方案使用纯 WebAssembly，不依赖任何原生模块或系统库\n * 在本地和 Vercel 环境都能正常工作\n */\nexport async function createLabelSVG(\n    width: number,\n    barHeight: number,\n    fontSize: number,\n    padding: number,\n    labelText: string\n): Promise<Buffer> {\n    const fontData = loadFontData()\n\n    if (!fontData) {\n        _ulogError('[Fonts] Cannot create label image without font')\n        // 返回一个空的黑色图片\n        return createFallbackImage(width, barHeight)\n    }\n\n    try {\n        // 使用 @vercel/og 的 ImageResponse 生成图片\n        const response = new ImageResponse(\n            {\n                type: 'div',\n                props: {\n                    style: {\n                        width: '100%',\n                        height: '100%',\n                        display: 'flex',\n                        alignItems: 'center',\n                        backgroundColor: 'black',\n                        paddingLeft: padding,\n                        paddingRight: padding,\n                    },\n                    children: {\n                        type: 'span',\n                        props: {\n                            style: {\n                                color: 'white',\n                                fontSize: fontSize,\n                                fontWeight: 'bold',\n                                fontFamily: 'NotoSansSC',\n                            },\n                            children: labelText,\n                        },\n                    },\n                },\n            } as unknown as ReactElement,\n            {\n                width: width,\n                height: barHeight,\n                fonts: [\n                    {\n                        name: 'NotoSansSC',\n                        data: fontData,\n                        weight: 400,\n                        style: 'normal',\n                    },\n                ],\n            }\n        )\n\n        // 从 Response 获取 Buffer\n        const arrayBuffer = await response.arrayBuffer()\n        return Buffer.from(arrayBuffer)\n    } catch (error) {\n        _ulogError('[Fonts] Error creating label image:', error)\n        return createFallbackImage(width, barHeight)\n    }\n}\n\n/**\n * 创建备用的黑色图片（当字体加载失败时）\n */\nasync function createFallbackImage(width: number, height: number): Promise<Buffer> {\n    // 使用 sharp 创建一个黑色矩形\n    const sharp = (await import('sharp')).default\n    return sharp({\n        create: {\n            width: width,\n            height: height,\n            channels: 4,\n            background: { r: 0, g: 0, b: 0, alpha: 1 },\n        },\n    })\n        .png()\n        .toBuffer()\n}\n"
  },
  {
    "path": "src/lib/gemini-batch-utils.ts",
    "content": "/**\n * Gemini Batch 工具函数\n * \n * 用于提交和查询 Google Gemini Batch API 的任务\n * 参考: https://ai.google.dev/gemini-api/docs/batch-api\n * \n * 特点：\n * - 价格是标准 API 的 50%\n * - 处理时间 24 小时内\n */\n\nimport { GoogleGenAI } from '@google/genai'\nimport { getInternalBaseUrl } from '@/lib/env'\nimport { getImageBase64Cached } from './image-cache'\nimport { logInternal } from './logging/semantic'\n\ntype UnknownRecord = Record<string, unknown>\n\nfunction asRecord(value: unknown): UnknownRecord | null {\n  return value && typeof value === 'object' ? (value as UnknownRecord) : null\n}\n\nfunction getErrorMessage(error: unknown): string {\n  if (error instanceof Error) return error.message\n  const record = asRecord(error)\n  if (record && typeof record.message === 'string') return record.message\n  return String(error)\n}\n\ninterface GeminiBatchClient {\n  batches: {\n    create(args: {\n      model: string\n      src: unknown[]\n      config: { displayName: string }\n    }): Promise<unknown>\n    get(args: { name: string }): Promise<unknown>\n  }\n}\n\n/**\n * 提交 Gemini Batch 图片生成任务\n * \n * 使用 ai.batches.create() 方法提交批量任务\n * \n * @param apiKey Google AI API Key\n * @param prompt 图片生成提示词\n * @param options 生成选项\n * @returns 返回 batchName（如 batches/xxx）用于后续查询\n */\nexport async function submitGeminiBatch(\n  apiKey: string,\n  prompt: string,\n  options?: {\n    referenceImages?: string[]\n    aspectRatio?: string\n    resolution?: string\n  }\n): Promise<{\n  success: boolean\n  batchName?: string\n  error?: string\n}> {\n  if (!apiKey) {\n    return { success: false, error: '请配置 Google AI API Key' }\n  }\n\n  try {\n    const ai = new GoogleGenAI({ apiKey })\n\n    // 构建 content parts\n    const contentParts: UnknownRecord[] = []\n\n    // 添加参考图片（最多 14 张）\n    const referenceImages = options?.referenceImages || []\n    for (let i = 0; i < Math.min(referenceImages.length, 14); i++) {\n      const imageData = referenceImages[i]\n\n      if (imageData.startsWith('data:')) {\n        // Base64 格式\n        const base64Start = imageData.indexOf(';base64,')\n        if (base64Start !== -1) {\n          const mimeType = imageData.substring(5, base64Start)\n          const data = imageData.substring(base64Start + 8)\n          contentParts.push({ inlineData: { mimeType, data } })\n        }\n      } else if (imageData.startsWith('http') || imageData.startsWith('/')) {\n        // URL 格式（包括本地相对路径 /api/files/...）：下载转 base64\n        try {\n          // 🔧 本地模式修复：相对路径需要补全完整 URL\n          let fullUrl = imageData\n          if (imageData.startsWith('/')) {\n            const baseUrl = getInternalBaseUrl()\n            fullUrl = `${baseUrl}${imageData}`\n          }\n          const base64DataUrl = await getImageBase64Cached(fullUrl)\n          const base64Start = base64DataUrl.indexOf(';base64,')\n          if (base64Start !== -1) {\n            const mimeType = base64DataUrl.substring(5, base64Start)\n            const data = base64DataUrl.substring(base64Start + 8)\n            contentParts.push({ inlineData: { mimeType, data } })\n          }\n        } catch (e: unknown) {\n          logInternal('GeminiBatch', 'WARN', `下载参考图片 ${i + 1} 失败`, { error: getErrorMessage(e) })\n        }\n      } else {\n        // 纯 base64\n        contentParts.push({\n          inlineData: { mimeType: 'image/png', data: imageData }\n        })\n      }\n    }\n\n    // 添加文本提示\n    contentParts.push({ text: prompt })\n\n    // 构建内嵌请求（Inline Requests）\n    // 🔥 添加 imageConfig 以控制输出图片的比例和尺寸\n    const imageConfig: UnknownRecord = {}\n    if (options?.aspectRatio) {\n      imageConfig.aspectRatio = options.aspectRatio\n    }\n    if (options?.resolution) {\n      imageConfig.imageSize = options.resolution  // 'HD', '4K' 等\n    }\n\n    const inlinedRequests = [\n      {\n        contents: [{ parts: contentParts }],\n        config: {\n          responseModalities: ['TEXT', 'IMAGE'],  // 🔥 必须指定包含 IMAGE\n          ...(Object.keys(imageConfig).length > 0 && { imageConfig })  // 🔥 添加图片配置\n        }\n      }\n    ]\n\n    // 🔥 使用 ai.batches.create 创建批量任务\n    const batchClient = ai as unknown as GeminiBatchClient\n    const batchJob = await batchClient.batches.create({\n      model: 'gemini-3-pro-image-preview',\n      src: inlinedRequests,\n      config: {\n        displayName: `image-gen-${Date.now()}`\n      }\n    })\n\n    const batchName = asRecord(batchJob)?.name  // 格式: batches/xxx\n\n    if (typeof batchName !== 'string' || !batchName) {\n      return { success: false, error: '未返回 batch name' }\n    }\n\n    logInternal('GeminiBatch', 'INFO', `✅ 任务已提交: ${batchName}`)\n    return { success: true, batchName }\n\n  } catch (error: unknown) {\n    const message = getErrorMessage(error)\n    logInternal('GeminiBatch', 'ERROR', '提交异常', { error: message })\n    return { success: false, error: `提交异常: ${message}` }\n  }\n}\n\n/**\n * 查询 Gemini Batch 任务状态\n * \n * 使用 ai.batches.get() 方法查询任务状态\n * \n * @param batchName 批量任务名称（如 batches/xxx）\n * @param apiKey Google AI API Key\n */\nexport async function queryGeminiBatchStatus(batchName: string, apiKey: string): Promise<{\n  status: string\n  completed: boolean\n  failed: boolean\n  imageBase64?: string\n  imageUrl?: string\n  error?: string\n}> {\n  if (!apiKey) {\n    return { status: 'error', completed: false, failed: true, error: '请配置 Google AI API Key' }\n  }\n\n  try {\n    const ai = new GoogleGenAI({ apiKey })\n\n    // 🔥 使用 ai.batches.get 查询任务状态\n    const batchClient = ai as unknown as GeminiBatchClient\n    const batchJob = await batchClient.batches.get({ name: batchName })\n    const batchRecord = asRecord(batchJob) || {}\n\n    const state = typeof batchRecord.state === 'string' ? batchRecord.state : 'UNKNOWN'\n    logInternal('GeminiBatch', 'INFO', `查询状态: ${batchName} -> ${state}`)\n\n    // 检查完成状态\n    const completedStates = new Set([\n      'JOB_STATE_SUCCEEDED'\n    ])\n    const failedStates = new Set([\n      'JOB_STATE_FAILED',\n      'JOB_STATE_CANCELLED',\n      'JOB_STATE_EXPIRED'\n    ])\n\n    if (completedStates.has(state)) {\n      // 从 inlinedResponses 中提取图片\n      const dest = asRecord(batchRecord.dest)\n      const responses = Array.isArray(dest?.inlinedResponses) ? dest.inlinedResponses : []\n\n      if (responses.length > 0) {\n        const firstResponse = asRecord(responses[0])\n        const response = asRecord(firstResponse?.response)\n        const candidates = Array.isArray(response?.candidates) ? response.candidates : []\n        const firstCandidate = asRecord(candidates[0])\n        const content = asRecord(firstCandidate?.content)\n        const parts = Array.isArray(content?.parts) ? content.parts : []\n\n        for (const part of parts) {\n          const partRecord = asRecord(part)\n          const inlineData = asRecord(partRecord?.inlineData)\n          if (typeof inlineData?.data === 'string') {\n            const imageBase64 = inlineData.data\n            const mimeType = typeof inlineData.mimeType === 'string' ? inlineData.mimeType : 'image/png'\n\n            logInternal('GeminiBatch', 'INFO', `✅ 获取到图片，MIME 类型: ${mimeType}`, { batchName })\n            return {\n              status: 'completed',\n              completed: true,\n              failed: false,\n              imageBase64,\n              imageUrl: `data:${mimeType};base64,${imageBase64}`\n            }\n          }\n        }\n      }\n\n      // 任务完成但没有图片\n      return {\n        status: 'completed_no_image',\n        completed: false,\n        failed: true,\n        error: '任务完成但未找到图片（可能被内容安全策略过滤）'\n      }\n    }\n\n    if (failedStates.has(state)) {\n      return {\n        status: state,\n        completed: false,\n        failed: true,\n        error: `任务失败: ${state}`\n      }\n    }\n\n    // 仍在处理中 (PENDING, RUNNING 等)\n    return { status: state, completed: false, failed: false }\n\n  } catch (error: unknown) {\n    const message = getErrorMessage(error)\n    logInternal('GeminiBatch', 'ERROR', '查询异常', { batchName, error: message })\n    return { status: 'error', completed: false, failed: false, error: `查询异常: ${message}` }\n  }\n}\n"
  },
  {
    "path": "src/lib/generator-api.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\n/**\n * 生成器统一入口（增强版）\n * \n * 支持：\n * - 严格使用 model_key（provider::modelId）\n * - 用户自定义模型的动态路由（仅通过配置中心）\n * - 统一错误处理\n */\n\nimport { createAudioGenerator, createImageGenerator, createVideoGenerator } from './generators/factory'\nimport type { GenerateResult } from './generators/base'\nimport { getProviderConfig, getProviderKey, resolveModelSelection } from './api-config'\nimport {\n    generateImageViaOpenAICompat,\n    generateImageViaOpenAICompatTemplate,\n    generateVideoViaOpenAICompat,\n    generateVideoViaOpenAICompatTemplate,\n    resolveModelGatewayRoute,\n} from './model-gateway'\nimport { generateBailianAudio, generateBailianImage, generateBailianVideo } from './providers/bailian'\nimport { generateSiliconFlowAudio, generateSiliconFlowImage, generateSiliconFlowVideo } from './providers/siliconflow'\n\nconst OFFICIAL_ONLY_PROVIDER_KEYS = new Set(['bailian', 'siliconflow'])\n\n/**\n * 将 aspectRatio 映射为 OpenAI 兼容的 size\n */\nfunction aspectRatioToOpenAISize(aspectRatio: string | undefined): string | undefined {\n    if (!aspectRatio) return undefined\n    const ratio = aspectRatio.trim()\n    // OpenAI 支持的尺寸: 1024x1024, 1792x1024, 1024x1792, 1536x1024, 1024x1536\n    const mapping: Record<string, string> = {\n        '1:1': '1024x1024',\n        '16:9': '1792x1024',\n        '9:16': '1024x1792',\n        '3:2': '1536x1024',\n        '2:3': '1024x1536',\n    }\n    return mapping[ratio] || undefined\n}\n\n/**\n * 生成图片（简化版）\n * \n * @param userId 用户 ID\n * @param modelKey 模型唯一键（provider::modelId）\n * @param prompt 提示词\n * @param options 生成选项\n */\nexport async function generateImage(\n    userId: string,\n    modelKey: string,\n    prompt: string,\n    options?: {\n        referenceImages?: string[]\n        aspectRatio?: string\n        resolution?: string\n        outputFormat?: string\n        keepOriginalAspectRatio?: boolean  // 🔥 编辑时保持原图比例\n        size?: string  // 🔥 直接指定像素尺寸如 \"5016x3344\"（优先于 aspectRatio）\n    }\n): Promise<GenerateResult> {\n    const selection = await resolveModelSelection(userId, modelKey, 'image')\n    _ulogInfo(`[generateImage] resolved model selection: ${selection.modelKey}`)\n    const providerConfig = await getProviderConfig(userId, selection.provider)\n    const providerKey = getProviderKey(selection.provider).toLowerCase()\n    if (providerKey === 'bailian') {\n        return await generateBailianImage({\n            userId,\n            prompt,\n            referenceImages: options?.referenceImages,\n            options: {\n                ...(options || {}),\n                provider: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n            },\n        })\n    }\n    if (providerKey === 'siliconflow') {\n        return await generateSiliconFlowImage({\n            userId,\n            prompt,\n            referenceImages: options?.referenceImages,\n            options: {\n                ...(options || {}),\n                provider: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n            },\n        })\n    }\n    const defaultGatewayRoute = resolveModelGatewayRoute(selection.provider)\n    let gatewayRoute = OFFICIAL_ONLY_PROVIDER_KEYS.has(providerKey)\n        ? 'official'\n        : (providerConfig.gatewayRoute || defaultGatewayRoute)\n    if (providerKey === 'gemini-compatible') {\n        // DEPRECATED: historical rows persisted gemini-compatible as openai-compat by default.\n        // Runtime now resolves route by apiMode to avoid requiring data migration SQL.\n        gatewayRoute = providerConfig.apiMode === 'openai-official' ? 'openai-compat' : 'official'\n    }\n\n    // 调用生成（提取 referenceImages 单独传递，其余选项合并进 options）\n    const { referenceImages, ...generatorOptions } = options || {}\n    if (gatewayRoute === 'openai-compat') {\n        const compatTemplate = selection.compatMediaTemplate\n        if (providerKey === 'openai-compatible' && !compatTemplate) {\n            throw new Error(`MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED: ${selection.modelKey}`)\n        }\n        if (compatTemplate) {\n            return await generateImageViaOpenAICompatTemplate({\n                userId,\n                providerId: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n                prompt,\n                referenceImages,\n                options: {\n                    ...generatorOptions,\n                    provider: selection.provider,\n                    modelId: selection.modelId,\n                    modelKey: selection.modelKey,\n                },\n                profile: 'openai-compatible',\n                template: compatTemplate,\n            })\n        }\n\n        // OpenAI 兼容模式：将 aspectRatio 转换为 size\n        let openaiCompatOptions = { ...generatorOptions }\n        if (openaiCompatOptions.aspectRatio) {\n            const mappedSize = aspectRatioToOpenAISize(openaiCompatOptions.aspectRatio)\n            if (mappedSize && !openaiCompatOptions.size) {\n                openaiCompatOptions = { ...openaiCompatOptions, size: mappedSize }\n            }\n            // 移除不支持的 aspectRatio\n            delete openaiCompatOptions.aspectRatio\n        }\n\n        return await generateImageViaOpenAICompat({\n            userId,\n            providerId: selection.provider,\n            modelId: selection.modelId,\n            prompt,\n            referenceImages,\n            options: {\n                ...openaiCompatOptions,\n                provider: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n            },\n            profile: 'openai-compatible',\n        })\n    }\n\n    const generator = createImageGenerator(selection.provider, selection.modelId)\n    return await generator.generate({\n        userId,\n        prompt,\n        referenceImages,\n        options: {\n            ...generatorOptions,\n            provider: selection.provider,\n            modelId: selection.modelId,\n            modelKey: selection.modelKey,\n        }\n    })\n}\n\n/**\n * 生成视频（增强版）\n * \n * @param userId 用户 ID\n * @param modelKey 模型唯一键（provider::modelId）\n * @param imageUrl 输入图片 URL\n * @param options 生成选项\n */\nexport async function generateVideo(\n    userId: string,\n    modelKey: string,\n    imageUrl: string,\n    options?: {\n        prompt?: string\n        duration?: number\n        fps?: number\n        resolution?: string      // '720p' | '1080p'\n        aspectRatio?: string     // '16:9' | '9:16'\n        generateAudio?: boolean  // 仅 Seedance 1.5 Pro 支持\n        lastFrameImageUrl?: string  // 首尾帧模式的尾帧图片\n        [key: string]: string | number | boolean | undefined\n    }\n): Promise<GenerateResult> {\n    const selection = await resolveModelSelection(userId, modelKey, 'video')\n    _ulogInfo(`[generateVideo] resolved model selection: ${selection.modelKey}`)\n    const providerKey = getProviderKey(selection.provider).toLowerCase()\n    if (providerKey === 'bailian') {\n        return await generateBailianVideo({\n            userId,\n            imageUrl,\n            prompt: options?.prompt,\n            options: {\n                ...(options || {}),\n                provider: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n            },\n        })\n    }\n    if (providerKey === 'siliconflow') {\n        return await generateSiliconFlowVideo({\n            userId,\n            imageUrl,\n            prompt: options?.prompt,\n            options: {\n                ...(options || {}),\n                provider: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n            },\n        })\n    }\n    const providerConfig = await getProviderConfig(userId, selection.provider)\n    const defaultGatewayRoute = resolveModelGatewayRoute(selection.provider)\n    const gatewayRoute = OFFICIAL_ONLY_PROVIDER_KEYS.has(providerKey)\n        ? 'official'\n        : (providerConfig.gatewayRoute || defaultGatewayRoute)\n\n    const { prompt, ...providerOptions } = options || {}\n    if (gatewayRoute === 'openai-compat') {\n        const compatTemplate = selection.compatMediaTemplate\n        if (providerKey === 'openai-compatible' && !compatTemplate) {\n            throw new Error(`MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED: ${selection.modelKey}`)\n        }\n        if (compatTemplate) {\n            return await generateVideoViaOpenAICompatTemplate({\n                userId,\n                providerId: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n                imageUrl,\n                prompt: prompt || '',\n                options: {\n                    ...providerOptions,\n                    provider: selection.provider,\n                    modelId: selection.modelId,\n                    modelKey: selection.modelKey,\n                },\n                profile: 'openai-compatible',\n                template: compatTemplate,\n            })\n        }\n\n        return await generateVideoViaOpenAICompat({\n            userId,\n            providerId: selection.provider,\n            modelId: selection.modelId,\n            modelKey: selection.modelKey,\n            imageUrl,\n            prompt: prompt || '',\n            options: {\n                ...providerOptions,\n                provider: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n            },\n            profile: 'openai-compatible',\n        })\n    }\n\n    const generator = createVideoGenerator(selection.provider)\n    return await generator.generate({\n        userId,\n        imageUrl,\n        prompt,\n        options: {\n            ...providerOptions,\n            provider: selection.provider,\n            modelId: selection.modelId,\n            modelKey: selection.modelKey,\n        }\n    })\n}\n\n/**\n * 生成语音\n */\nexport async function generateAudio(\n    userId: string,\n    modelKey: string,\n    text: string,\n    options?: {\n        voice?: string\n        rate?: number\n    }\n): Promise<GenerateResult> {\n    const selection = await resolveModelSelection(userId, modelKey, 'audio')\n    const providerKey = getProviderKey(selection.provider).toLowerCase()\n    if (providerKey === 'bailian') {\n        return await generateBailianAudio({\n            userId,\n            text,\n            voice: options?.voice,\n            rate: options?.rate,\n            options: {\n                provider: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n            },\n        })\n    }\n    if (providerKey === 'siliconflow') {\n        return await generateSiliconFlowAudio({\n            userId,\n            text,\n            voice: options?.voice,\n            rate: options?.rate,\n            options: {\n                provider: selection.provider,\n                modelId: selection.modelId,\n                modelKey: selection.modelKey,\n            },\n        })\n    }\n    const generator = createAudioGenerator(selection.provider)\n\n    return generator.generate({\n        userId,\n        text,\n        voice: options?.voice,\n        rate: options?.rate,\n        options: {\n            provider: selection.provider,\n            modelId: selection.modelId,\n            modelKey: selection.modelKey,\n        },\n    })\n}\n"
  },
  {
    "path": "src/lib/generators/ark.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\n/**\n * 火山引擎 ARK 生成器（统一图像 + 视频）\n * \n * 图像模型：\n * - Seedream 4.5 (doubao-seedream-4-5-251128)\n * - Seedream 4.0\n * \n * 视频模型：\n * - Seedance 1.0 Pro (doubao-seedance-1-0-pro-250528)\n * - Seedance 1.0 Lite (doubao-seedance-1-0-lite-i2v-250428)\n * - Seedance 1.5 Pro (doubao-seedance-1-5-pro-251215)\n * - 支持批量模式 (-batch 后缀)\n * - 支持首尾帧模式\n * - 支持音频生成 (Seedance 1.5 Pro)\n */\n\nimport {\n    BaseImageGenerator,\n    BaseVideoGenerator,\n    ImageGenerateParams,\n    VideoGenerateParams,\n    GenerateResult\n} from './base'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { arkImageGeneration, arkCreateVideoTask } from '@/lib/ark-api'\nimport { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'\n\ninterface ArkImageOptions {\n    aspectRatio?: string\n    modelId?: string\n    size?: string\n    resolution?: string\n    provider?: string\n    modelKey?: string\n}\n\ninterface ArkVideoOptions {\n    modelId?: string\n    resolution?: string\n    duration?: number\n    frames?: number\n    aspectRatio?: string\n    generateAudio?: boolean\n    lastFrameImageUrl?: string\n    serviceTier?: 'default' | 'flex'\n    executionExpiresAfter?: number\n    returnLastFrame?: boolean\n    draft?: boolean\n    seed?: number\n    cameraFixed?: boolean\n    watermark?: boolean\n    provider?: string\n    modelKey?: string\n}\n\ntype ArkVideoContentItem =\n    | { type: 'text'; text: string }\n    | { type: 'image_url'; image_url: { url: string }; role?: 'first_frame' | 'last_frame' | 'reference_image' }\n\ninterface ArkSeedanceModelSpec {\n    durationMin: number\n    durationMax: number\n    supportsFirstLastFrame: boolean\n    supportsGenerateAudio: boolean\n    supportsDraft: boolean\n    supportsFrames: boolean\n    resolutionOptions: ReadonlyArray<'480p' | '720p' | '1080p'>\n}\n\nconst ARK_SEEDANCE_MODEL_SPECS: Record<string, ArkSeedanceModelSpec> = {\n    'doubao-seedance-1-0-pro-fast-251015': {\n        durationMin: 2,\n        durationMax: 12,\n        supportsFirstLastFrame: false,\n        supportsGenerateAudio: false,\n        supportsDraft: false,\n        supportsFrames: true,\n        resolutionOptions: ['480p', '720p', '1080p'],\n    },\n    'doubao-seedance-1-0-pro-250528': {\n        durationMin: 2,\n        durationMax: 12,\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: false,\n        supportsDraft: false,\n        supportsFrames: true,\n        resolutionOptions: ['480p', '720p', '1080p'],\n    },\n    'doubao-seedance-1-0-lite-i2v-250428': {\n        durationMin: 2,\n        durationMax: 12,\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: false,\n        supportsDraft: false,\n        supportsFrames: true,\n        resolutionOptions: ['480p', '720p', '1080p'],\n    },\n    'doubao-seedance-1-5-pro-251215': {\n        durationMin: 4,\n        durationMax: 12,\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: true,\n        supportsDraft: true,\n        supportsFrames: false,\n        resolutionOptions: ['480p', '720p', '1080p'],\n    },\n}\n\nconst ARK_VIDEO_ALLOWED_RATIOS = new Set(['16:9', '4:3', '1:1', '3:4', '9:16', '21:9', 'adaptive'])\n\nfunction isInteger(value: unknown): value is number {\n    return typeof value === 'number' && Number.isInteger(value)\n}\n\n// ============================================================\n// 图像尺寸映射表\n// ============================================================\n\n// 4K 分辨率映射表（Seedream 4.x，上限 4096x4096 ≈ 16.7M 像素）\nconst SIZE_MAP_4K: Record<string, string> = {\n    '1:1': '4096x4096',\n    '16:9': '5456x3072',\n    '9:16': '3072x5456',\n    '4:3': '4728x3544',\n    '3:4': '3544x4728',\n    '3:2': '5016x3344',\n    '2:3': '3344x5016',\n    '21:9': '6256x2680',\n    '9:21': '2680x6256',\n}\n\n// 3K 分辨率映射表（Seedream 5.0，上限 ≈ 10,404,496 像素）\nconst SIZE_MAP_3K: Record<string, string> = {\n    '1:1': '3072x3072',\n    '16:9': '4096x2304',\n    '9:16': '2304x4096',\n    '4:3': '3648x2736',\n    '3:4': '2736x3648',\n    '3:2': '3888x2592',\n    '2:3': '2592x3888',\n    '21:9': '4704x2016',\n    '9:21': '2016x4704',\n}\n\n/** Seedream 5.0 系列使用 3K 尺寸映射 */\nfunction isSeedream5Model(modelId: string): boolean {\n    return modelId.includes('seedream-5')\n}\n\nfunction getSizeMapForModel(modelId: string): Record<string, string> {\n    return isSeedream5Model(modelId) ? SIZE_MAP_3K : SIZE_MAP_4K\n}\n\n// ============================================================\n// ARK 图像生成器 (Seedream)\n// ============================================================\n\nexport class ArkImageGenerator extends BaseImageGenerator {\n    protected async doGenerate(params: ImageGenerateParams): Promise<GenerateResult> {\n        const { userId, prompt, referenceImages = [], options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, 'ark')\n        const {\n            aspectRatio,\n            modelId = 'doubao-seedream-4-5-251128',\n            size: directSize  // 直接传入的像素尺寸（编辑模式）\n        } = options as ArkImageOptions\n\n        const allowedOptionKeys = new Set([\n            'provider',\n            'modelId',\n            'modelKey',\n            'aspectRatio',\n            'size',\n            'resolution',\n        ])\n        for (const [key, value] of Object.entries(options)) {\n            if (value === undefined) continue\n            if (!allowedOptionKeys.has(key)) {\n                throw new Error(`ARK_IMAGE_OPTION_UNSUPPORTED: ${key}`)\n            }\n        }\n\n        const resolution = (options as ArkImageOptions).resolution\n        if (resolution !== undefined && resolution !== '4K' && resolution !== '3K') {\n            throw new Error(`ARK_IMAGE_OPTION_VALUE_UNSUPPORTED: resolution=${resolution}`)\n        }\n\n        // 决定最终 size：根据模型选择合适的尺寸映射表\n        const sizeMap = getSizeMapForModel(modelId)\n        let size: string | undefined\n        if (directSize) {\n            size = directSize\n        } else {\n            if (!aspectRatio) {\n                throw new Error('ARK_IMAGE_OPTION_REQUIRED: aspectRatio or size must be provided')\n            }\n            size = sizeMap[aspectRatio]\n            if (!size) {\n                throw new Error(`ARK_IMAGE_OPTION_VALUE_UNSUPPORTED: aspectRatio=${aspectRatio}`)\n            }\n        }\n\n        _ulogInfo(`[ARK Image] 模型=${modelId}, aspectRatio=${aspectRatio || '(none)'}, size=${size || '(未传)'}`)\n\n        // 转换参考图片为 Base64\n        const base64Images: string[] = []\n        for (const imageUrl of referenceImages) {\n            try {\n                const base64 = await normalizeToBase64ForGeneration(imageUrl)\n                base64Images.push(base64)\n            } catch {\n                _ulogInfo(`[ARK Image] 参考图片转换失败: ${imageUrl}`)\n            }\n        }\n\n        // 构建请求体\n        const requestBody: {\n            model: string\n            prompt: string\n            sequential_image_generation: 'disabled'\n            response_format: 'url'\n            stream: false\n            watermark: false\n            size?: string\n            image?: string[]\n        } = {\n            model: modelId,\n            prompt: prompt,\n            sequential_image_generation: 'disabled',\n            response_format: 'url',\n            stream: false,\n            watermark: false\n        }\n\n        if (size) {\n            requestBody.size = size\n        }\n\n        if (base64Images.length > 0) {\n            requestBody.image = base64Images\n        }\n\n        // 调用 ARK API\n        const arkData = await arkImageGeneration(requestBody, {\n            apiKey,\n            logPrefix: '[ARK Image]'\n        })\n\n        const imageUrls = Array.isArray(arkData.data)\n            ? arkData.data\n                .map((item) => (typeof item?.url === 'string' ? item.url.trim() : ''))\n                .filter((item) => item.length > 0)\n            : []\n        const imageUrl = imageUrls[0]\n\n        if (!imageUrl) {\n            throw new Error('ARK 未返回图片 URL')\n        }\n\n        return {\n            success: true,\n            imageUrl,\n            ...(imageUrls.length > 1 ? { imageUrls } : {}),\n        }\n    }\n}\n\n// ============================================================\n// ARK 视频生成器 (Seedance)\n// ============================================================\n\nexport class ArkVideoGenerator extends BaseVideoGenerator {\n    protected async doGenerate(params: VideoGenerateParams): Promise<GenerateResult> {\n        const { userId, imageUrl, prompt = '', options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, 'ark')\n        const {\n            modelId = 'doubao-seedance-1-0-pro-fast-251015',\n            resolution,\n            duration,\n            frames,\n            aspectRatio,\n            generateAudio,\n            lastFrameImageUrl,  // 首尾帧模式的尾帧图片\n            serviceTier,\n            executionExpiresAfter,\n            returnLastFrame,\n            draft,\n            seed,\n            cameraFixed,\n            watermark,\n        } = options as ArkVideoOptions\n\n        const allowedOptionKeys = new Set([\n            'provider',\n            'modelId',\n            'modelKey',\n            'resolution',\n            'duration',\n            'frames',\n            'aspectRatio',\n            'generateAudio',\n            'lastFrameImageUrl',\n            'serviceTier',\n            'executionExpiresAfter',\n            'returnLastFrame',\n            'draft',\n            'seed',\n            'cameraFixed',\n            'watermark',\n        ])\n        for (const [key, value] of Object.entries(options)) {\n            if (value === undefined) continue\n            if (!allowedOptionKeys.has(key)) {\n                throw new Error(`ARK_VIDEO_OPTION_UNSUPPORTED: ${key}`)\n            }\n        }\n\n        // 解析批量模式\n        const isBatchMode = modelId.endsWith('-batch')\n        const realModel = isBatchMode ? modelId.replace('-batch', '') : modelId\n        const modelSpec = ARK_SEEDANCE_MODEL_SPECS[realModel]\n        if (!modelSpec) {\n            throw new Error(`ARK_VIDEO_MODEL_UNSUPPORTED: ${realModel}`)\n        }\n\n        if (resolution !== undefined && !modelSpec.resolutionOptions.includes(resolution as '480p' | '720p' | '1080p')) {\n            throw new Error(`ARK_VIDEO_OPTION_VALUE_UNSUPPORTED: resolution=${resolution}`)\n        }\n        if (duration !== undefined) {\n            if (!isInteger(duration)) {\n                throw new Error('ARK_VIDEO_OPTION_INVALID: duration must be integer')\n            }\n            const durationOutOfRange = duration !== -1 && (duration < modelSpec.durationMin || duration > modelSpec.durationMax)\n            if (durationOutOfRange) {\n                throw new Error(`ARK_VIDEO_OPTION_VALUE_UNSUPPORTED: duration=${duration}`)\n            }\n            if (duration === -1 && realModel !== 'doubao-seedance-1-5-pro-251215') {\n                throw new Error('ARK_VIDEO_OPTION_VALUE_UNSUPPORTED: duration=-1 only supported by Seedance 1.5 Pro')\n            }\n        }\n        if (frames !== undefined) {\n            if (!modelSpec.supportsFrames) {\n                throw new Error(`ARK_VIDEO_OPTION_UNSUPPORTED: frames for ${realModel}`)\n            }\n            if (!isInteger(frames)) {\n                throw new Error('ARK_VIDEO_OPTION_INVALID: frames must be integer')\n            }\n            if (frames < 29 || frames > 289 || (frames - 25) % 4 !== 0) {\n                throw new Error(`ARK_VIDEO_OPTION_VALUE_UNSUPPORTED: frames=${frames}`)\n            }\n        }\n        if (aspectRatio !== undefined && !ARK_VIDEO_ALLOWED_RATIOS.has(aspectRatio)) {\n            throw new Error(`ARK_VIDEO_OPTION_VALUE_UNSUPPORTED: aspectRatio=${aspectRatio}`)\n        }\n        if (lastFrameImageUrl && !modelSpec.supportsFirstLastFrame) {\n            throw new Error(`ARK_VIDEO_OPTION_UNSUPPORTED: lastFrameImageUrl for ${realModel}`)\n        }\n        if (generateAudio !== undefined && !modelSpec.supportsGenerateAudio) {\n            throw new Error(`ARK_VIDEO_OPTION_UNSUPPORTED: generateAudio for ${realModel}`)\n        }\n        if (serviceTier !== undefined && serviceTier !== 'default' && serviceTier !== 'flex') {\n            throw new Error(`ARK_VIDEO_OPTION_VALUE_UNSUPPORTED: serviceTier=${serviceTier}`)\n        }\n        if (executionExpiresAfter !== undefined) {\n            if (!isInteger(executionExpiresAfter)) {\n                throw new Error('ARK_VIDEO_OPTION_INVALID: executionExpiresAfter must be integer')\n            }\n            if (executionExpiresAfter < 3600 || executionExpiresAfter > 259200) {\n                throw new Error(`ARK_VIDEO_OPTION_VALUE_UNSUPPORTED: executionExpiresAfter=${executionExpiresAfter}`)\n            }\n        }\n        if (seed !== undefined) {\n            if (!isInteger(seed)) {\n                throw new Error('ARK_VIDEO_OPTION_INVALID: seed must be integer')\n            }\n            if (seed < -1 || seed > 4294967295) {\n                throw new Error(`ARK_VIDEO_OPTION_VALUE_UNSUPPORTED: seed=${seed}`)\n            }\n        }\n        if (draft === true) {\n            if (!modelSpec.supportsDraft) {\n                throw new Error(`ARK_VIDEO_OPTION_UNSUPPORTED: draft for ${realModel}`)\n            }\n            if (resolution !== undefined && resolution !== '480p') {\n                throw new Error('ARK_VIDEO_OPTION_INVALID: draft only supports 480p')\n            }\n            if (returnLastFrame === true) {\n                throw new Error('ARK_VIDEO_OPTION_INVALID: returnLastFrame is not supported when draft=true')\n            }\n            if (isBatchMode || serviceTier === 'flex') {\n                throw new Error('ARK_VIDEO_OPTION_INVALID: draft does not support flex service tier')\n            }\n        }\n\n        _ulogInfo(`[ARK Video] 模型: ${realModel}, 批量: ${isBatchMode}, 分辨率: ${resolution || '(默认)'}, 时长: ${duration ?? '(默认)'}`)\n\n        // 转换图片为 base64\n        const imageBase64 = await normalizeToBase64ForGeneration(imageUrl)\n\n        // 构建请求体 content\n        const content: ArkVideoContentItem[] = []\n        if (prompt.trim()) {\n            content.push({ type: 'text', text: prompt })\n        }\n\n        if (lastFrameImageUrl) {\n            // 首尾帧模式\n            const lastImageBase64 = await normalizeToBase64ForGeneration(lastFrameImageUrl)\n            content.push({\n                type: 'image_url',\n                image_url: { url: imageBase64 },\n                role: 'first_frame'\n            })\n            content.push({\n                type: 'image_url',\n                image_url: { url: lastImageBase64 },\n                role: 'last_frame'\n            })\n            _ulogInfo(`[ARK Video] 首尾帧模式`)\n        } else {\n            content.push({\n                type: 'image_url',\n                image_url: { url: imageBase64 }\n            })\n        }\n\n        const requestBody: {\n            model: string\n            content: ArkVideoContentItem[]\n            resolution?: '480p' | '720p' | '1080p'\n            ratio?: string\n            duration?: number\n            frames?: number\n            seed?: number\n            camera_fixed?: boolean\n            watermark?: boolean\n            return_last_frame?: boolean\n            service_tier?: 'default' | 'flex'\n            execution_expires_after?: number\n            generate_audio?: boolean\n            draft?: boolean\n        } = {\n            model: realModel,\n            content\n        }\n\n        if (resolution === '480p' || resolution === '720p' || resolution === '1080p') {\n            requestBody.resolution = resolution\n        }\n        if (aspectRatio) {\n            requestBody.ratio = aspectRatio\n        }\n        if (typeof duration === 'number') {\n            requestBody.duration = duration\n        }\n        if (typeof frames === 'number') {\n            requestBody.frames = frames\n        }\n        if (typeof seed === 'number') {\n            requestBody.seed = seed\n        }\n        if (typeof cameraFixed === 'boolean') {\n            requestBody.camera_fixed = cameraFixed\n        }\n        if (typeof watermark === 'boolean') {\n            requestBody.watermark = watermark\n        }\n        if (typeof returnLastFrame === 'boolean') {\n            requestBody.return_last_frame = returnLastFrame\n        }\n        if (typeof draft === 'boolean') {\n            requestBody.draft = draft\n        }\n        if (serviceTier !== undefined) {\n            requestBody.service_tier = serviceTier\n        }\n        if (typeof executionExpiresAfter === 'number') {\n            requestBody.execution_expires_after = executionExpiresAfter\n        }\n\n        // 批量模式参数\n        if (isBatchMode) {\n            requestBody.service_tier = 'flex'\n            if (requestBody.execution_expires_after === undefined) {\n                requestBody.execution_expires_after = 86400\n            }\n            _ulogInfo('[ARK Video] 批量模式: service_tier=flex')\n        }\n\n        // 音频生成（仅 Seedance 1.5 Pro）\n        if (generateAudio !== undefined) {\n            requestBody.generate_audio = generateAudio\n        }\n\n        try {\n            const taskData = await arkCreateVideoTask(requestBody, {\n                apiKey,\n                logPrefix: '[ARK Video]'\n            })\n\n            const taskId = taskData.id\n\n            if (!taskId) {\n                throw new Error('ARK 未返回 task_id')\n            }\n\n            _ulogInfo(`[ARK Video] 任务已创建: ${taskId}`)\n\n            return {\n                success: true,\n                async: true,\n                requestId: taskId,  // 向后兼容\n                externalId: `ARK:VIDEO:${taskId}`  // 🔥 标准格式\n            }\n        } catch (error: unknown) {\n            const message = error instanceof Error ? error.message : '未知错误'\n            _ulogError(`[ARK Video] 创建任务失败:`, message)\n            throw new Error(`ARK 视频任务创建失败: ${message}`)\n        }\n    }\n}\n\n// ============================================================\n// 向后兼容别名\n// ============================================================\n\nexport const ArkSeedreamGenerator = ArkImageGenerator\nexport const ArkSeedanceVideoGenerator = ArkVideoGenerator\n"
  },
  {
    "path": "src/lib/generators/audio/bailian.ts",
    "content": "/**\n * 阿里百炼语音生成器\n *\n * 支持：\n * - Bailian TTS\n */\n\nimport { BaseAudioGenerator, type AudioGenerateParams, type GenerateResult } from '../base'\nimport { getProviderConfig } from '@/lib/api-config'\n\nexport class BailianTTSGenerator extends BaseAudioGenerator {\n  protected async doGenerate(params: AudioGenerateParams): Promise<GenerateResult> {\n    const { userId, text, voice = 'default', rate = 1.0 } = params\n    const { apiKey } = await getProviderConfig(userId, 'bailian')\n\n    const body = {\n      text,\n      voice,\n      rate,\n    }\n\n    const response = await fetch('https://dashscope.aliyuncs.com/api/v1/audio/tts', {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        Authorization: `Bearer ${apiKey}`,\n      },\n      body: JSON.stringify(body),\n    })\n\n    if (!response.ok) {\n      const errorText = await response.text()\n      throw new Error(`Bailian TTS failed (${response.status}): ${errorText}`)\n    }\n\n    const data = await response.json() as {\n      audio_url?: string\n      output?: { audio_url?: string }\n    }\n    const audioUrl = data.audio_url || data.output?.audio_url\n    if (!audioUrl) {\n      throw new Error('Bailian TTS returned no audio URL')\n    }\n\n    return {\n      success: true,\n      audioUrl,\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/generators/audio/index.ts",
    "content": "/**\n * 语音生成器统一导出\n */\n\nexport { BailianTTSGenerator } from './bailian'\n"
  },
  {
    "path": "src/lib/generators/base.ts",
    "content": "import { logWarn as _ulogWarn } from '@/lib/logging/core'\n/**\n * 生成器基础接口和类型定义\n * \n * 策略模式核心：所有生成器实现统一接口\n */\n\n// ============================================================\n// 通用类型\n// ============================================================\n\nexport interface GenerateOptions {\n    aspectRatio?: string      // 宽高比，如 '16:9', '3:4'\n    resolution?: string        // 分辨率，如 '2K', '4K'\n    outputFormat?: string      // 输出格式，如 'png', 'jpg'\n    duration?: number          // 视频时长（秒）\n    fps?: number              // 帧率\n    [key: string]: unknown        // 其他厂商特定参数\n}\n\nexport interface GenerateResult {\n    success: boolean\n    imageUrl?: string         // 图片 URL（单图，向后兼容）\n    imageUrls?: string[]      // 多图 URL 列表（接口返回多张时填充）\n    imageBase64?: string      // 图片 base64（单图，向后兼容）\n    videoUrl?: string         // 视频 URL\n    audioUrl?: string         // 音频 URL\n    error?: string           // 错误信息\n    requestId?: string       // 异步任务 ID（原始格式，向后兼容）\n    async?: boolean          // 是否为异步任务\n    endpoint?: string        // 异步任务端点（向后兼容）\n    externalId?: string      // 🔥 标准格式的异步任务标识符（如 FAL:IMAGE:fal-ai/nano-banana-pro:requestId）\n}\n\n// ============================================================\n// 图片生成器接口\n// ============================================================\n\nexport interface ImageGenerateParams {\n    userId: string\n    prompt: string\n    referenceImages?: string[]  // 参考图片 URLs 或 base64\n    options?: GenerateOptions\n}\n\nexport interface ImageGenerator {\n    /**\n     * 生成图片\n     */\n    generate(params: ImageGenerateParams): Promise<GenerateResult>\n}\n\n// ============================================================\n// 视频生成器接口\n// ============================================================\n\nexport interface VideoGenerateParams {\n    userId: string\n    imageUrl: string           // 起始图片\n    prompt?: string            // 提示词（可选）\n    options?: GenerateOptions\n}\n\nexport interface VideoGenerator {\n    /**\n     * 生成视频\n     */\n    generate(params: VideoGenerateParams): Promise<GenerateResult>\n}\n\n// ============================================================\n// 语音生成器接口\n// ============================================================\n\nexport interface AudioGenerateParams {\n    userId: string\n    text: string              // 文本内容\n    voice?: string            // 音色\n    rate?: number             // 语速\n    options?: GenerateOptions\n}\n\nexport interface AudioGenerator {\n    /**\n     * 生成语音\n     */\n    generate(params: AudioGenerateParams): Promise<GenerateResult>\n}\n\n// ============================================================\n// 基类（可选，提供通用功能）\n// ============================================================\n\nexport abstract class BaseImageGenerator implements ImageGenerator {\n    /**\n     * 生成图片（带重试）\n     */\n    async generate(params: ImageGenerateParams): Promise<GenerateResult> {\n        const maxRetries = 2\n        let lastError: unknown = null\n\n        for (let attempt = 1; attempt <= maxRetries; attempt++) {\n            try {\n                return await this.doGenerate(params)\n            } catch (error: unknown) {\n                lastError = error\n                const message = error instanceof Error ? error.message : String(error)\n                _ulogWarn(`[Generator] 尝试 ${attempt}/${maxRetries} 失败: ${message}`)\n\n                // 最后一次尝试，直接抛出\n                if (attempt === maxRetries) {\n                    break\n                }\n\n                // 等待后重试\n                await new Promise(resolve => setTimeout(resolve, 1000 * attempt))\n            }\n        }\n\n        return {\n            success: false,\n            error: lastError instanceof Error ? lastError.message : '生成失败'\n        }\n    }\n\n    /**\n     * 子类实现具体生成逻辑\n     */\n    protected abstract doGenerate(params: ImageGenerateParams): Promise<GenerateResult>\n}\n\nexport abstract class BaseVideoGenerator implements VideoGenerator {\n    async generate(params: VideoGenerateParams): Promise<GenerateResult> {\n        const maxRetries = 2\n        let lastError: unknown = null\n\n        for (let attempt = 1; attempt <= maxRetries; attempt++) {\n            try {\n                return await this.doGenerate(params)\n            } catch (error: unknown) {\n                lastError = error\n                const message = error instanceof Error ? error.message : String(error)\n                _ulogWarn(`[Video Generator] 尝试 ${attempt}/${maxRetries} 失败: ${message}`)\n                if (attempt === maxRetries) break\n                await new Promise(resolve => setTimeout(resolve, 1000 * attempt))\n            }\n        }\n\n        return {\n            success: false,\n            error: lastError instanceof Error ? lastError.message : '视频生成失败'\n        }\n    }\n\n    protected abstract doGenerate(params: VideoGenerateParams): Promise<GenerateResult>\n}\n\nexport abstract class BaseAudioGenerator implements AudioGenerator {\n    async generate(params: AudioGenerateParams): Promise<GenerateResult> {\n        try {\n            return await this.doGenerate(params)\n        } catch (error: unknown) {\n            return {\n                success: false,\n                error: error instanceof Error ? error.message : '语音生成失败'\n            }\n        }\n    }\n\n    protected abstract doGenerate(params: AudioGenerateParams): Promise<GenerateResult>\n}\n"
  },
  {
    "path": "src/lib/generators/factory.ts",
    "content": "/**\n * 生成器工厂（增强版）\n * \n * 支持：\n * - 根据 provider 创建生成器\n */\n\nimport { ImageGenerator, VideoGenerator, AudioGenerator } from './base'\nimport { FalBananaGenerator } from './fal'\nimport { ArkSeedreamGenerator, ArkSeedanceVideoGenerator } from './ark'\nimport { FalVideoGenerator } from './fal'\nimport {\n    GoogleGeminiImageGenerator,\n    GoogleImagenGenerator,\n    GoogleGeminiBatchImageGenerator,\n    GeminiCompatibleImageGenerator,\n    OpenAICompatibleImageGenerator,\n} from './image'\nimport { GoogleVeoVideoGenerator } from './video/google'\nimport { OpenAICompatibleVideoGenerator } from './video'\nimport { MinimaxVideoGenerator } from './minimax'\nimport { ViduVideoGenerator } from './vidu'\nimport { getProviderKey } from '@/lib/api-config'\nimport {\n    BailianAudioGenerator,\n    BailianImageGenerator,\n    BailianVideoGenerator,\n    SiliconFlowAudioGenerator,\n    SiliconFlowImageGenerator,\n    SiliconFlowVideoGenerator,\n} from './official'\n\n/**\n * 根据 provider 创建图片生成器\n */\nexport function createImageGenerator(provider: string, modelId?: string): ImageGenerator {\n    const normalizeModelId = (rawModelId?: string): string | undefined => {\n        if (!rawModelId) return rawModelId\n        const delimiterIndex = rawModelId.indexOf('::')\n        return delimiterIndex === -1 ? rawModelId : rawModelId.slice(delimiterIndex + 2)\n    }\n\n    const actualModelId = normalizeModelId(modelId)\n    const providerKey = getProviderKey(provider).toLowerCase()\n    switch (providerKey) {\n        case 'fal':\n            return new FalBananaGenerator()\n        case 'google':\n            if (actualModelId === 'gemini-3-pro-image-preview-batch') {\n                return new GoogleGeminiBatchImageGenerator()\n            }\n            if (actualModelId && actualModelId.startsWith('imagen-')) {\n                return new GoogleImagenGenerator(actualModelId)\n            }\n            return new GoogleGeminiImageGenerator(actualModelId)\n        case 'google-batch':  // 🔥 Gemini Batch 异步模式\n            return new GoogleGeminiBatchImageGenerator()\n        case 'imagen':\n            return new GoogleImagenGenerator(actualModelId)\n        case 'ark':\n            return new ArkSeedreamGenerator()\n        case 'gemini-compatible':\n            return new GeminiCompatibleImageGenerator(actualModelId, provider)\n        case 'openai-compatible':\n            return new OpenAICompatibleImageGenerator(actualModelId, provider)\n        case 'bailian':\n            return new BailianImageGenerator()\n        case 'siliconflow':\n            return new SiliconFlowImageGenerator()\n        default:\n            throw new Error(`Unknown image generator provider: ${provider}`)\n    }\n}\n\n/**\n * 根据 provider 创建视频生成器\n */\nexport function createVideoGenerator(provider: string): VideoGenerator {\n    const providerKey = getProviderKey(provider).toLowerCase()\n    switch (providerKey) {\n        case 'fal':\n            return new FalVideoGenerator()\n        case 'ark':\n            return new ArkSeedanceVideoGenerator()\n        case 'google':\n            return new GoogleVeoVideoGenerator()\n        case 'gemini-compatible':\n            return new GoogleVeoVideoGenerator(provider)\n        case 'minimax':\n            return new MinimaxVideoGenerator()\n        case 'vidu':\n            return new ViduVideoGenerator()\n        case 'openai-compatible':\n            return new OpenAICompatibleVideoGenerator(provider)\n        case 'bailian':\n            return new BailianVideoGenerator()\n        case 'siliconflow':\n            return new SiliconFlowVideoGenerator()\n        default:\n            throw new Error(`Unknown video generator provider: ${provider}`)\n    }\n}\n\n/**\n * 创建语音生成器\n */\nexport function createAudioGenerator(provider: string): AudioGenerator {\n    const providerKey = getProviderKey(provider).toLowerCase()\n    switch (providerKey) {\n        case 'bailian':\n            return new BailianAudioGenerator()\n        case 'siliconflow':\n            return new SiliconFlowAudioGenerator()\n        default:\n            throw new Error(`Unknown audio generator provider: ${provider}`)\n    }\n}\n"
  },
  {
    "path": "src/lib/generators/fal.ts",
    "content": "import { createScopedLogger, logError as _ulogError } from '@/lib/logging/core'\n/**\n * FAL 生成器（统一图像 + 视频）\n * \n * 图像模型：\n * - Banana Pro (2K/4K) - fal-ai/nano-banana-pro       (modelId: 'banana')\n * - Banana 2  (1K/2K/4K) - fal-ai/nano-banana-2       (modelId: 'banana-2')\n * \n * 视频模型：\n * - Wan 2.6 (fal-wan25) - wan/v2.6/image-to-video\n * - Veo 3.1 (fal-veo31) - fal-ai/veo3.1/fast/image-to-video\n * - Sora 2 (fal-sora2) - fal-ai/sora-2/image-to-video  \n * - Kling 2.5 Turbo Pro - fal-ai/kling-video/v2.5-turbo/pro/image-to-video\n * - Kling 3 Standard - fal-ai/kling-video/v3/standard/image-to-video\n * - Kling 3 Pro - fal-ai/kling-video/v3/pro/image-to-video\n */\n\nimport {\n    BaseImageGenerator,\n    BaseVideoGenerator,\n    ImageGenerateParams,\n    VideoGenerateParams,\n    GenerateResult\n} from './base'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { submitFalTask } from '@/lib/async-submit'\nimport { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'\nimport { buildFalQueueUrl } from '@/lib/providers/fal/base-url'\n\n// ============================================================\n// 图像模型端点映射（modelId → FAL 端点前缀）\n// ============================================================\n\nconst FAL_IMAGE_ENDPOINTS: Record<string, { base: string; edit: string }> = {\n    'banana': { base: 'fal-ai/nano-banana-pro', edit: 'fal-ai/nano-banana-pro/edit' },\n    'banana-2': { base: 'fal-ai/nano-banana-2', edit: 'fal-ai/nano-banana-2/edit' },\n}\n\n// ============================================================\n// 视频模型端点映射\n// ============================================================\n\nconst FAL_VIDEO_ENDPOINTS: Record<string, string> = {\n    'fal-wan25': 'wan/v2.6/image-to-video',\n    'fal-veo31': 'fal-ai/veo3.1/fast/image-to-video',\n    'fal-sora2': 'fal-ai/sora-2/image-to-video',\n    'fal-ai/kling-video/v2.5-turbo/pro/image-to-video': 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',\n    'fal-ai/kling-video/v3/standard/image-to-video': 'fal-ai/kling-video/v3/standard/image-to-video',\n    'fal-ai/kling-video/v3/pro/image-to-video': 'fal-ai/kling-video/v3/pro/image-to-video',\n}\n\n// ============================================================\n// FAL 图像生成器 (Banana Pro / Banana 2)\n// ============================================================\n\nexport class FalImageGenerator extends BaseImageGenerator {\n    protected async doGenerate(params: ImageGenerateParams): Promise<GenerateResult> {\n        const { userId, prompt, referenceImages = [], options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, 'fal')\n        const {\n            aspectRatio,\n            resolution,\n            outputFormat = 'png',\n            modelId: optModelId = 'banana'\n        } = options as {\n            aspectRatio?: string\n            resolution?: string\n            outputFormat?: string\n            provider?: string\n            modelId?: string\n            modelKey?: string\n        }\n\n        const allowedOptionKeys = new Set([\n            'provider',\n            'modelId',\n            'modelKey',\n            'aspectRatio',\n            'resolution',\n            'outputFormat',\n        ])\n        for (const [key, value] of Object.entries(options)) {\n            if (value === undefined) continue\n            if (!allowedOptionKeys.has(key)) {\n                throw new Error(`FAL_IMAGE_OPTION_UNSUPPORTED: ${key}`)\n            }\n        }\n        if (resolution !== undefined && resolution !== '1K' && resolution !== '2K' && resolution !== '4K') {\n            throw new Error(`FAL_IMAGE_OPTION_VALUE_UNSUPPORTED: resolution=${resolution}`)\n        }\n\n        // 根据 modelId 和是否有参考图片选择端点\n        const hasReferenceImages = referenceImages.length > 0\n        const endpointConfig = FAL_IMAGE_ENDPOINTS[optModelId] || FAL_IMAGE_ENDPOINTS['banana']\n        const endpoint = hasReferenceImages ? endpointConfig.edit : endpointConfig.base\n\n        const logger = createScopedLogger({\n            module: 'worker.fal-image',\n            action: 'fal_image_generate',\n        })\n        logger.info({\n            message: 'FAL image generation request',\n            details: {\n                modelId: optModelId,\n                endpoint,\n                referenceImagesCount: referenceImages.length,\n                hasReferenceImages,\n                resolution: resolution ?? null,\n                aspectRatio: aspectRatio ?? null,\n                referenceImageUrls: referenceImages.map((u: string) => u.substring(0, 100)),\n            },\n        })\n\n        const body: Record<string, unknown> = {\n            prompt,\n            num_images: 1,\n            output_format: outputFormat\n        }\n        if (aspectRatio) {\n            body.aspect_ratio = aspectRatio\n        }\n        if (resolution) {\n            body.resolution = resolution\n        }\n\n        if (hasReferenceImages) {\n            // 🔥 转换参考图片为Data URL（适配内网/本地环境）\n            const dataUrls = await Promise.all(\n                referenceImages.map(async (url: string) => {\n                    // 如果已经是data URL，直接返回\n                    if (url.startsWith('data:')) return url\n                    // 否则转换为Data URL\n                    return await normalizeToBase64ForGeneration(url)\n                })\n            )\n            body.image_urls = dataUrls\n            logger.info({\n                message: 'FAL image reference images converted',\n                details: {\n                    count: referenceImages.length,\n                    sizes: dataUrls.map((d: string) => `${Math.round(d.length / 1024)}KB`),\n                },\n            })\n        }\n\n        logger.info({\n            message: 'FAL image request body summary',\n            details: {\n                url: buildFalQueueUrl(endpoint),\n                promptLength: prompt.length,\n                imageUrlsCount: hasReferenceImages ? (body.image_urls as string[]).length : 0,\n                resolution: body.resolution ?? null,\n                aspectRatio: body.aspect_ratio ?? null,\n                outputFormat: body.output_format,\n            },\n        })\n\n        // 提交异步任务\n        const submitResponse = await fetch(buildFalQueueUrl(endpoint), {\n            method: 'POST',\n            headers: {\n                'Content-Type': 'application/json',\n                'Authorization': `Key ${apiKey}`\n            },\n            body: JSON.stringify(body),\n            cache: 'no-store'\n        })\n\n        if (!submitResponse.ok) {\n            const errorText = await submitResponse.text()\n            throw new Error(`FAL 提交失败 (${submitResponse.status}): ${errorText}`)\n        }\n\n        const submitData = await submitResponse.json()\n        const requestId = submitData.request_id\n\n        if (!requestId) {\n            throw new Error('FAL 未返回 request_id')\n        }\n\n        return {\n            success: true,\n            async: true,\n            requestId,        // 向后兼容\n            endpoint,         // 向后兼容\n            externalId: `FAL:IMAGE:${endpoint}:${requestId}`  // 🔥 标准格式\n        }\n    }\n}\n\n// ============================================================\n// FAL 视频生成器 (Wan 2.6, Veo 3.1, Sora 2, Kling)\n// ============================================================\n\nexport class FalVideoGenerator extends BaseVideoGenerator {\n    protected async doGenerate(params: VideoGenerateParams): Promise<GenerateResult> {\n        const { userId, imageUrl, prompt = '', options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, 'fal')\n        const {\n            duration,\n            resolution,\n            aspectRatio,\n            modelId = 'fal-wan25'\n        } = options as {\n            duration?: number\n            resolution?: string\n            aspectRatio?: string\n            modelId?: string\n            provider?: string\n            modelKey?: string\n        }\n\n        const allowedOptionKeys = new Set([\n            'provider',\n            'modelId',\n            'modelKey',\n            'duration',\n            'resolution',\n            'aspectRatio',\n        ])\n        for (const [key, value] of Object.entries(options)) {\n            if (value === undefined) continue\n            if (!allowedOptionKeys.has(key)) {\n                throw new Error(`FAL_VIDEO_OPTION_UNSUPPORTED: ${key}`)\n            }\n        }\n\n        // 获取端点\n        const endpoint = FAL_VIDEO_ENDPOINTS[modelId]\n        if (!endpoint) {\n            throw new Error(`FAL_VIDEO_MODEL_UNSUPPORTED: ${modelId}`)\n        }\n        const vLogger = createScopedLogger({ module: 'worker.fal-video', action: 'fal_video_generate' })\n        vLogger.info({ message: 'FAL video generation request', details: { modelId, endpoint } })\n\n        // 根据模型构建不同的请求体\n        let input: Record<string, unknown>\n\n        switch (modelId) {\n            case 'fal-wan25':\n                input = {\n                    image_url: imageUrl,\n                    prompt,\n                    ...(resolution ? { resolution } : {}),\n                    ...(typeof duration === 'number' ? { duration: String(duration) } : {})\n                }\n                break\n            case 'fal-veo31':\n                input = {\n                    image_url: imageUrl,\n                    prompt,\n                    ...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),\n                    ...(typeof duration === 'number' ? { duration: `${duration}s` } : {}),\n                    generate_audio: false\n                }\n                break\n            case 'fal-sora2':\n                input = {\n                    image_url: imageUrl,\n                    prompt,\n                    ...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),\n                    ...(typeof duration === 'number' ? { duration } : {}),\n                    delete_video: false\n                }\n                break\n            case 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video':\n                input = {\n                    image_url: imageUrl,\n                    prompt,\n                    ...(typeof duration === 'number' ? { duration: String(duration) } : {}),\n                    negative_prompt: 'blur, distort, and low quality',\n                    cfg_scale: 0.5\n                }\n                break\n            case 'fal-ai/kling-video/v3/standard/image-to-video':\n            case 'fal-ai/kling-video/v3/pro/image-to-video':\n                input = {\n                    start_image_url: imageUrl,\n                    prompt,\n                    ...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),\n                    ...(typeof duration === 'number' ? { duration: String(duration) } : {}),\n                    generate_audio: false,\n                }\n                break\n            default:\n                throw new Error(`FAL_VIDEO_MODEL_UNSUPPORTED: ${modelId}`)\n        }\n\n        try {\n            const requestId = await submitFalTask(endpoint, input, apiKey)\n            vLogger.info({ message: 'FAL video task submitted', details: { requestId } })\n\n            return {\n                success: true,\n                async: true,\n                requestId,  // 向后兼容\n                endpoint,   // 向后兼容  \n                externalId: `FAL:VIDEO:${endpoint}:${requestId}`  // 🔥 标准格式\n            }\n        } catch (error: unknown) {\n            const message = error instanceof Error ? error.message : '未知错误'\n            _ulogError(`[FAL Video] 提交失败:`, message)\n            throw new Error(`FAL 视频任务提交失败: ${message}`)\n        }\n    }\n}\n\n// ============================================================\n// 向后兼容别名\n// ============================================================\n\nexport const FalBananaGenerator = FalImageGenerator\n"
  },
  {
    "path": "src/lib/generators/image/gemini-compatible.ts",
    "content": "import { GoogleGenAI, HarmBlockThreshold, HarmCategory } from '@google/genai'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { getInternalBaseUrl } from '@/lib/env'\nimport { getImageBase64Cached } from '@/lib/image-cache'\nimport { BaseImageGenerator, type GenerateResult, type ImageGenerateParams } from '../base'\nimport { setProxy } from '../../../../lib/prompts/proxy'\n\ntype GeminiCompatibleContentPart = { inlineData: { mimeType: string; data: string } } | { text: string }\n\ntype GeminiCompatibleOptions = {\n  aspectRatio?: string\n  resolution?: string\n  provider?: string\n  modelId?: string\n  modelKey?: string\n}\n\nfunction toAbsoluteUrlIfNeeded(value: string): string {\n  if (!value.startsWith('/')) return value\n  const baseUrl = getInternalBaseUrl()\n  return `${baseUrl}${value}`\n}\n\nfunction parseDataUrl(value: string): { mimeType: string; base64: string } | null {\n  const marker = ';base64,'\n  const markerIndex = value.indexOf(marker)\n  if (!value.startsWith('data:') || markerIndex === -1) return null\n  const mimeType = value.slice(5, markerIndex)\n  const base64 = value.slice(markerIndex + marker.length)\n  if (!mimeType || !base64) return null\n  return { mimeType, base64 }\n}\n\nasync function toInlineData(imageSource: string): Promise<{ mimeType: string; data: string } | null> {\n  const parsedDataUrl = parseDataUrl(imageSource)\n  if (parsedDataUrl) {\n    return { mimeType: parsedDataUrl.mimeType, data: parsedDataUrl.base64 }\n  }\n\n  if (imageSource.startsWith('http://') || imageSource.startsWith('https://') || imageSource.startsWith('/')) {\n    const cachedDataUrl = await getImageBase64Cached(toAbsoluteUrlIfNeeded(imageSource))\n    const parsedCachedDataUrl = parseDataUrl(cachedDataUrl)\n    if (!parsedCachedDataUrl) return null\n    return { mimeType: parsedCachedDataUrl.mimeType, data: parsedCachedDataUrl.base64 }\n  }\n\n  return { mimeType: 'image/png', data: imageSource }\n}\n\nfunction assertAllowedOptions(options: Record<string, unknown>) {\n  const allowedKeys = new Set([\n    'provider',\n    'modelId',\n    'modelKey',\n    'aspectRatio',\n    'resolution',\n  ])\n  for (const [key, value] of Object.entries(options)) {\n    if (value === undefined) continue\n    if (!allowedKeys.has(key)) {\n      throw new Error(`GEMINI_COMPATIBLE_IMAGE_OPTION_UNSUPPORTED: ${key}`)\n    }\n  }\n}\n\nexport class GeminiCompatibleImageGenerator extends BaseImageGenerator {\n  private readonly modelId?: string\n  private readonly providerId?: string\n\n  constructor(modelId?: string, providerId?: string) {\n    super()\n    this.modelId = modelId\n    this.providerId = providerId\n  }\n\n  protected async doGenerate(params: ImageGenerateParams): Promise<GenerateResult> {\n    const { userId, prompt, referenceImages = [], options = {} } = params\n    assertAllowedOptions(options)\n\n    const providerId = this.providerId || 'gemini-compatible'\n    const providerConfig = await getProviderConfig(userId, providerId)\n    if (!providerConfig.baseUrl) {\n      throw new Error(`PROVIDER_BASE_URL_MISSING: ${providerId}`)\n    }\n    await setProxy()\n\n    const ai = new GoogleGenAI({\n      apiKey: providerConfig.apiKey,\n      httpOptions: { baseUrl: providerConfig.baseUrl },\n    })\n    const normalizedOptions = options as GeminiCompatibleOptions\n    const parts: GeminiCompatibleContentPart[] = []\n\n    for (const referenceImage of referenceImages.slice(0, 14)) {\n      const inlineData = await toInlineData(referenceImage)\n      if (!inlineData) {\n        throw new Error('GEMINI_COMPATIBLE_REFERENCE_INVALID: failed to parse reference image')\n      }\n      parts.push({ inlineData })\n    }\n    parts.push({ text: prompt })\n\n    const response = await ai.models.generateContent({\n      model: this.modelId || normalizedOptions.modelId || 'gemini-2.5-flash-image-preview',\n      contents: [{ parts }],\n      config: {\n        responseModalities: ['TEXT', 'IMAGE'],\n        safetySettings: [\n          { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },\n          { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },\n          { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },\n          { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },\n        ],\n        ...(normalizedOptions.aspectRatio || normalizedOptions.resolution\n          ? {\n            imageConfig: {\n              ...(normalizedOptions.aspectRatio ? { aspectRatio: normalizedOptions.aspectRatio } : {}),\n              ...(normalizedOptions.resolution ? { imageSize: normalizedOptions.resolution } : {}),\n            },\n          }\n          : {}),\n      },\n    })\n\n    const candidate = response.candidates?.[0]\n    const responseParts = candidate?.content?.parts || []\n    for (const part of responseParts) {\n      if (part.inlineData?.data) {\n        const mimeType = part.inlineData.mimeType || 'image/png'\n        const imageBase64 = part.inlineData.data\n        return {\n          success: true,\n          imageBase64,\n          imageUrl: `data:${mimeType};base64,${imageBase64}`,\n        }\n      }\n    }\n\n    const finishReason = candidate?.finishReason\n    if (finishReason === 'IMAGE_SAFETY' || finishReason === 'SAFETY') {\n      throw new Error('内容因安全策略被过滤')\n    }\n\n    throw new Error('GEMINI_COMPATIBLE_IMAGE_EMPTY_RESPONSE: no image data returned')\n  }\n}\n"
  },
  {
    "path": "src/lib/generators/image/google.ts",
    "content": "import { logInfo as _ulogInfo, logWarn as _ulogWarn } from '@/lib/logging/core'\n/**\n * Google AI 图片生成器\n * \n * 支持：\n * - Gemini 3 Pro Image (实时)\n * - Gemini 2.5 Flash Image (实时)\n * - Imagen 4\n */\n\nimport { GoogleGenAI, HarmCategory, HarmBlockThreshold } from '@google/genai'\nimport { getInternalBaseUrl } from '@/lib/env'\nimport { BaseImageGenerator, ImageGenerateParams, GenerateResult } from '../base'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { getImageBase64Cached } from '@/lib/image-cache'\nimport { setProxy } from '../../../../lib/prompts/proxy'\n\ntype ContentPart = { inlineData: { mimeType: string; data: string } } | { text: string }\n\ninterface ImagenResponse {\n    generatedImages?: Array<{\n        image?: {\n            imageBytes?: string\n        }\n    }>\n}\n\nfunction getErrorMessage(error: unknown): string {\n    if (error instanceof Error) return error.message\n    if (typeof error === 'object' && error !== null) {\n        const candidate = (error as { message?: unknown }).message\n        if (typeof candidate === 'string') return candidate\n    }\n    return '未知错误'\n}\n\nexport class GoogleGeminiImageGenerator extends BaseImageGenerator {\n    private modelId: string\n\n    constructor(modelId: string = 'gemini-3-pro-image-preview') {\n        super()\n        this.modelId = modelId\n    }\n\n    protected async doGenerate(params: ImageGenerateParams): Promise<GenerateResult> {\n        const { userId, prompt, referenceImages = [], options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, 'google')\n        const {\n            aspectRatio,\n            resolution\n        } = options as {\n            aspectRatio?: string\n            resolution?: string\n            provider?: string\n            modelId?: string\n            modelKey?: string\n        }\n\n        const allowedOptionKeys = new Set([\n            'provider',\n            'modelId',\n            'modelKey',\n            'aspectRatio',\n            'resolution',\n        ])\n        for (const [key, value] of Object.entries(options)) {\n            if (value === undefined) continue\n            if (!allowedOptionKeys.has(key)) {\n                throw new Error(`GOOGLE_IMAGE_OPTION_UNSUPPORTED: ${key}`)\n            }\n        }\n\n        await setProxy()\n        const ai = new GoogleGenAI({ apiKey })\n\n        // 构建内容数组\n        const contentParts: ContentPart[] = []\n\n        // 添加参考图片（最多 14 张）\n        for (let i = 0; i < Math.min(referenceImages.length, 14); i++) {\n            const imageData = referenceImages[i]\n\n            if (imageData.startsWith('data:')) {\n                // Base64 格式\n                const base64Start = imageData.indexOf(';base64,')\n                if (base64Start !== -1) {\n                    const mimeType = imageData.substring(5, base64Start)\n                    const data = imageData.substring(base64Start + 8)\n                    contentParts.push({ inlineData: { mimeType, data } })\n                }\n            } else if (imageData.startsWith('http') || imageData.startsWith('/')) {\n                // URL 格式（包括本地相对路径 /api/files/...）：下载转 base64\n                try {\n                    // 🔧 本地模式修复：相对路径需要补全完整 URL\n                    let fullUrl = imageData\n                    if (imageData.startsWith('/')) {\n                        const baseUrl = getInternalBaseUrl()\n                        fullUrl = `${baseUrl}${imageData}`\n                    }\n                    const base64DataUrl = await getImageBase64Cached(fullUrl)\n                    const base64Start = base64DataUrl.indexOf(';base64,')\n                    if (base64Start !== -1) {\n                        const mimeType = base64DataUrl.substring(5, base64Start)\n                        const data = base64DataUrl.substring(base64Start + 8)\n                        contentParts.push({ inlineData: { mimeType, data } })\n                    }\n                } catch (e) {\n                    _ulogWarn(`下载参考图片 ${i + 1} 失败:`, e)\n                }\n            } else {\n                // 纯 base64\n                contentParts.push({\n                    inlineData: { mimeType: 'image/png', data: imageData }\n                })\n            }\n        }\n\n        // 添加文本提示\n        contentParts.push({ text: prompt })\n\n        // 安全配置（关闭过滤）\n        const safetySettings = [\n            { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },\n            { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },\n            { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },\n            { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },\n        ]\n\n        // 调用 API\n        const response = await ai.models.generateContent({\n            model: this.modelId,\n            contents: [{ parts: contentParts }],\n            config: {\n                responseModalities: ['TEXT', 'IMAGE'],\n                safetySettings,\n                ...(aspectRatio || resolution\n                    ? {\n                        imageConfig: {\n                            ...(aspectRatio ? { aspectRatio } : {}),\n                            ...(resolution ? { imageSize: resolution } : {}),\n                        },\n                    }\n                    : {})\n            }\n        })\n\n        // 提取图片\n        const candidate = response.candidates?.[0]\n        const parts = candidate?.content?.parts || []\n\n        for (const part of parts) {\n            if (part.inlineData) {\n                const imageBase64 = part.inlineData.data\n                if (imageBase64) {\n                    const mimeType = part.inlineData.mimeType || 'image/png'\n                    return {\n                        success: true,\n                        imageBase64,\n                        imageUrl: `data:${mimeType};base64,${imageBase64}`\n                    }\n                }\n            }\n        }\n\n        // 检查失败原因\n        const finishReason = candidate?.finishReason\n        if (finishReason === 'IMAGE_SAFETY' || finishReason === 'SAFETY') {\n            throw new Error('内容因安全策略被过滤')\n        }\n\n        throw new Error('Gemini 未返回图片')\n    }\n}\n\n/**\n * Google Imagen 4 图片生成器\n * \n * 使用 Imagen 4 API（与 Gemini 不同的 API）\n * 支持：imagen-4.0-generate-001, imagen-4.0-fast-generate-001, imagen-4.0-ultra-generate-001\n */\nexport class GoogleImagenGenerator extends BaseImageGenerator {\n    private modelId: string\n\n    constructor(modelId: string = 'imagen-4.0-generate-001') {\n        super()\n        this.modelId = modelId\n    }\n\n    protected async doGenerate(params: ImageGenerateParams): Promise<GenerateResult> {\n        const { userId, prompt, options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, 'google')\n        const {\n            aspectRatio,\n        } = options\n\n        await setProxy()\n        const ai = new GoogleGenAI({ apiKey })\n\n        try {\n            // 使用 Imagen API（不同于 Gemini generateContent）\n            const response = await ai.models.generateImages({\n                model: this.modelId,\n                prompt,\n                config: {\n                    numberOfImages: 1,\n                    ...(aspectRatio ? { aspectRatio } : {}),\n                }\n            })\n\n            // 提取图片\n            const generatedImages = (response as ImagenResponse).generatedImages\n            if (generatedImages && generatedImages.length > 0) {\n                const imageBytes = generatedImages[0].image?.imageBytes\n                if (imageBytes) {\n                    return {\n                        success: true,\n                        imageBase64: imageBytes,\n                        imageUrl: `data:image/png;base64,${imageBytes}`\n                    }\n                }\n            }\n\n            throw new Error('Imagen 未返回图片')\n        } catch (error: unknown) {\n            const message = getErrorMessage(error)\n            // 检查安全过滤\n            if (message.includes('SAFETY') || message.includes('blocked')) {\n                throw new Error('内容因安全策略被过滤')\n            }\n            throw error\n        }\n    }\n}\n\n/**\n * Google Gemini Batch 图片生成器（异步模式）\n * \n * 使用 ai.batches.create() 提交批量任务\n * 价格是标准 API 的 50%，处理时间 24 小时内\n */\nexport class GoogleGeminiBatchImageGenerator extends BaseImageGenerator {\n    protected async doGenerate(params: ImageGenerateParams): Promise<GenerateResult> {\n        const { userId, prompt, referenceImages = [], options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, 'google')\n        const {\n            aspectRatio,\n            resolution\n        } = options as {\n            aspectRatio?: string\n            resolution?: string\n            provider?: string\n            modelId?: string\n            modelKey?: string\n        }\n\n        // 使用 Batch API 提交异步任务\n        const { submitGeminiBatch } = await import('@/lib/gemini-batch-utils')\n        await setProxy()\n\n        const result = await submitGeminiBatch(apiKey, prompt, {\n            referenceImages,\n            ...(aspectRatio ? { aspectRatio } : {}),\n            ...(resolution ? { resolution } : {}),\n        })\n\n        if (!result.success || !result.batchName) {\n            return {\n                success: false,\n                error: result.error || 'Gemini Batch 提交失败'\n            }\n        }\n\n        // 返回异步标识\n        _ulogInfo(`[Gemini Batch Generator] ✅ 异步任务已提交: ${result.batchName}`)\n        return {\n            success: true,\n            async: true,\n            requestId: result.batchName,  // 向后兼容，格式: batches/xxx\n            externalId: `GEMINI:BATCH:${result.batchName}`  // 🔥 标准格式\n        }\n    }\n}\n"
  },
  {
    "path": "src/lib/generators/image/index.ts",
    "content": "/**\n * 图片生成器统一导出\n * \n * 🔥 FAL 和 Ark 已迁移到根目录的合并文件\n * - FAL: ../fal.ts\n * - Ark: ../ark.ts\n */\n\n// Google 生成器保持原位置\nexport { GoogleGeminiImageGenerator, GoogleImagenGenerator, GoogleGeminiBatchImageGenerator } from './google'\nexport { GeminiCompatibleImageGenerator } from './gemini-compatible'\nexport { OpenAICompatibleImageGenerator } from './openai-compatible'\n\n\n// 向后兼容：从合并文件重新导出\nexport { FalBananaGenerator, FalImageGenerator } from '../fal'\nexport { ArkSeedreamGenerator, ArkImageGenerator } from '../ark'\n"
  },
  {
    "path": "src/lib/generators/image/openai-compatible.ts",
    "content": "import { BaseImageGenerator, type GenerateResult, type ImageGenerateParams } from '../base'\nimport { generateImageViaOpenAICompat } from '@/lib/model-gateway'\n\nexport class OpenAICompatibleImageGenerator extends BaseImageGenerator {\n  private readonly modelId?: string\n  private readonly providerId?: string\n\n  constructor(modelId?: string, providerId?: string) {\n    super()\n    this.modelId = modelId\n    this.providerId = providerId\n  }\n\n  protected async doGenerate(params: ImageGenerateParams): Promise<GenerateResult> {\n    const { userId, prompt, referenceImages = [], options = {} } = params\n    return await generateImageViaOpenAICompat({\n      userId,\n      providerId: this.providerId || 'openai-compatible',\n      modelId: this.modelId,\n      prompt,\n      referenceImages,\n      options,\n      profile: 'openai-compatible',\n    })\n  }\n}\n"
  },
  {
    "path": "src/lib/generators/minimax.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\n/**\n * MiniMax (海螺) 视频生成器\n * \n * 支持模型：\n * 视频：MiniMax-Hailuo-2.3, MiniMax-Hailuo-2.3-Fast, MiniMax-Hailuo-02, T2V-01, T2V-01-Director\n */\n\nimport { BaseVideoGenerator, VideoGenerateParams, GenerateResult } from './base'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'\n\nconst MINIMAX_BASE_URL = 'https://api.minimaxi.com/v1'\ntype MinimaxVideoGenerationMode = 'normal' | 'firstlastframe'\ntype MinimaxResolution = '512P' | '720P' | '768P' | '1080P'\n\ninterface MinimaxVideoOptions {\n    modelId?: string\n    duration?: number\n    resolution?: string\n    generationMode?: MinimaxVideoGenerationMode\n    generateAudio?: boolean\n    lastFrameImageUrl?: string\n}\n\ninterface MinimaxResolutionDurationRule {\n    resolution: MinimaxResolution\n    durations: readonly number[]\n}\n\ninterface MinimaxVideoModelSpec {\n    apiModel: string\n    supportsImageInput: boolean\n    supportsFirstLastFrame: boolean\n    normalRules: readonly MinimaxResolutionDurationRule[]\n    firstLastFrameRules?: readonly MinimaxResolutionDurationRule[]\n}\n\nconst MINIMAX_VIDEO_MODEL_SPECS: Record<string, MinimaxVideoModelSpec> = {\n    'minimax-hailuo-2.3': {\n        apiModel: 'MiniMax-Hailuo-2.3',\n        supportsImageInput: true,\n        supportsFirstLastFrame: false,\n        normalRules: [\n            { resolution: '768P', durations: [6, 10] },\n            { resolution: '1080P', durations: [6] },\n        ],\n    },\n    'minimax-hailuo-2.3-fast': {\n        apiModel: 'MiniMax-Hailuo-2.3-Fast',\n        supportsImageInput: true,\n        supportsFirstLastFrame: false,\n        normalRules: [\n            { resolution: '768P', durations: [6, 10] },\n            { resolution: '1080P', durations: [6] },\n        ],\n    },\n    'minimax-hailuo-02': {\n        apiModel: 'MiniMax-Hailuo-02',\n        supportsImageInput: true,\n        supportsFirstLastFrame: true,\n        normalRules: [\n            { resolution: '512P', durations: [6, 10] },\n            { resolution: '768P', durations: [6, 10] },\n            { resolution: '1080P', durations: [6] },\n        ],\n        firstLastFrameRules: [\n            { resolution: '768P', durations: [6, 10] },\n            { resolution: '1080P', durations: [6] },\n        ],\n    },\n    't2v-01': {\n        apiModel: 'T2V-01',\n        supportsImageInput: false,\n        supportsFirstLastFrame: false,\n        normalRules: [\n            { resolution: '720P', durations: [6] },\n        ],\n    },\n    't2v-01-director': {\n        apiModel: 'T2V-01-Director',\n        supportsImageInput: false,\n        supportsFirstLastFrame: false,\n        normalRules: [\n            { resolution: '720P', durations: [6] },\n        ],\n    },\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n    return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction getVideoModelSpec(modelId: string): MinimaxVideoModelSpec {\n    const spec = MINIMAX_VIDEO_MODEL_SPECS[modelId]\n    if (!spec) {\n        throw new Error(`MINIMAX_VIDEO_OPTION_VALUE_UNSUPPORTED: modelId=${modelId}`)\n    }\n    return spec\n}\n\nfunction normalizeGenerationMode(raw: unknown): MinimaxVideoGenerationMode {\n    if (raw === undefined || raw === null || raw === '') return 'normal'\n    if (raw === 'normal' || raw === 'firstlastframe') return raw\n    throw new Error(`MINIMAX_VIDEO_OPTION_VALUE_UNSUPPORTED: generationMode=${String(raw)}`)\n}\n\nfunction normalizeResolution(raw: string): MinimaxResolution {\n    const normalized = raw.trim().toLowerCase()\n    if (normalized.includes('512')) return '512P'\n    if (normalized.includes('768')) return '768P'\n    if (normalized.includes('720')) return '720P'\n    if (normalized.includes('1080')) return '1080P'\n    throw new Error(`MINIMAX_VIDEO_OPTION_VALUE_UNSUPPORTED: resolution=${raw}`)\n}\n\nfunction pickRulesByMode(\n    modelId: string,\n    spec: MinimaxVideoModelSpec,\n    mode: MinimaxVideoGenerationMode,\n): readonly MinimaxResolutionDurationRule[] {\n    if (mode === 'normal') return spec.normalRules\n    if (!spec.supportsFirstLastFrame) {\n        throw new Error(`MINIMAX_VIDEO_OPTION_UNSUPPORTED: generationMode=${mode} for ${modelId}`)\n    }\n    return spec.firstLastFrameRules || []\n}\n\nfunction validateResolutionAndDuration(input: {\n    modelId: string\n    rules: readonly MinimaxResolutionDurationRule[]\n    resolution?: MinimaxResolution\n    duration?: number\n}) {\n    const { modelId, rules, resolution, duration } = input\n    if (rules.length === 0) {\n        throw new Error(`MINIMAX_VIDEO_OPTION_UNSUPPORTED: no rules for ${modelId}`)\n    }\n\n    if (resolution) {\n        const matchedResolutionRule = rules.find((rule) => rule.resolution === resolution)\n        if (!matchedResolutionRule) {\n            throw new Error(`MINIMAX_VIDEO_OPTION_VALUE_UNSUPPORTED: resolution=${resolution} for ${modelId}`)\n        }\n        if (typeof duration === 'number' && !matchedResolutionRule.durations.includes(duration)) {\n            throw new Error(\n                `MINIMAX_VIDEO_OPTION_VALUE_UNSUPPORTED: duration=${duration} for resolution=${resolution} in ${modelId}`,\n            )\n        }\n        return\n    }\n\n    if (typeof duration !== 'number') return\n\n    const supportsDuration = rules.some((rule) => rule.durations.includes(duration))\n    if (!supportsDuration) {\n        throw new Error(`MINIMAX_VIDEO_OPTION_VALUE_UNSUPPORTED: duration=${duration} for ${modelId}`)\n    }\n}\n\n// ==================== 视频生成器 ====================\n\nexport class MinimaxVideoGenerator extends BaseVideoGenerator {\n    protected async doGenerate(params: VideoGenerateParams): Promise<GenerateResult> {\n        const { userId, imageUrl, prompt = '', options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, 'minimax')\n        const {\n            modelId,\n            duration,\n            resolution,\n            generationMode: rawGenerationMode,\n            generateAudio,\n            lastFrameImageUrl,\n        } = options as MinimaxVideoOptions\n        if (!modelId) {\n            throw new Error('MINIMAX_VIDEO_OPTION_REQUIRED: modelId')\n        }\n        const modelSpec = getVideoModelSpec(modelId)\n        const inferredGenerationMode: MinimaxVideoGenerationMode = isNonEmptyString(lastFrameImageUrl)\n            ? 'firstlastframe'\n            : 'normal'\n        const generationMode = rawGenerationMode === undefined\n            ? inferredGenerationMode\n            : normalizeGenerationMode(rawGenerationMode)\n        if (rawGenerationMode !== undefined && generationMode !== inferredGenerationMode) {\n            throw new Error(`MINIMAX_VIDEO_OPTION_VALUE_UNSUPPORTED: generationMode=${String(rawGenerationMode)}`)\n        }\n        const resolvedRules = pickRulesByMode(modelId, modelSpec, generationMode)\n        const normalizedResolution = resolution ? normalizeResolution(resolution) : undefined\n        const hasFirstFrameImage = isNonEmptyString(imageUrl)\n        validateResolutionAndDuration({\n            modelId,\n            rules: resolvedRules,\n            resolution: normalizedResolution,\n            duration,\n        })\n\n        if (generateAudio === true) {\n            throw new Error(`MINIMAX_VIDEO_OPTION_UNSUPPORTED: generateAudio for ${modelId}`)\n        }\n        if (generateAudio !== undefined && typeof generateAudio !== 'boolean') {\n            throw new Error(`MINIMAX_VIDEO_OPTION_VALUE_UNSUPPORTED: generateAudio=${String(generateAudio)}`)\n        }\n        if (generationMode === 'firstlastframe' && !isNonEmptyString(lastFrameImageUrl)) {\n            throw new Error('MINIMAX_VIDEO_OPTION_REQUIRED: lastFrameImageUrl')\n        }\n        if (generationMode === 'normal' && lastFrameImageUrl !== undefined) {\n            throw new Error('MINIMAX_VIDEO_OPTION_UNSUPPORTED: lastFrameImageUrl for normal mode')\n        }\n        if (generationMode === 'firstlastframe' && !hasFirstFrameImage) {\n            throw new Error('MINIMAX_VIDEO_OPTION_REQUIRED: firstFrameImage')\n        }\n        if (\n            modelId === 'minimax-hailuo-02'\n            && generationMode === 'normal'\n            && normalizedResolution === '512P'\n            && !hasFirstFrameImage\n        ) {\n            throw new Error('MINIMAX_VIDEO_OPTION_REQUIRED: firstFrameImage for resolution=512P')\n        }\n\n        // aspectRatio 由 worker 层统一注入（来自项目 videoRatio），\n        // MiniMax 不使用此参数（通过 resolution 控制输出规格），在白名单内静默忽略。\n        const allowedOptionKeys = new Set([\n            'provider',\n            'modelId',\n            'modelKey',\n            'duration',\n            'resolution',\n            'generationMode',\n            'generateAudio',\n            'lastFrameImageUrl',\n            'aspectRatio',  // 接受但不传给 API，避免 worker 层统一注入时报错\n        ])\n        for (const [key, value] of Object.entries(options)) {\n            if (value === undefined) continue\n            if (!allowedOptionKeys.has(key)) {\n                throw new Error(`MINIMAX_VIDEO_OPTION_UNSUPPORTED: ${key}`)\n            }\n        }\n\n        const logPrefix = `[MiniMax Video ${modelId}]`\n\n        const requestBody: Record<string, unknown> = {\n            model: modelSpec.apiModel,\n            prompt: prompt,\n            prompt_optimizer: true\n        }\n        if (typeof duration === 'number') {\n            requestBody.duration = duration\n        }\n        if (normalizedResolution) {\n            requestBody.resolution = normalizedResolution\n        }\n\n        if (modelSpec.supportsImageInput && hasFirstFrameImage) {\n            const firstFrameDataUrl = imageUrl.startsWith('data:') ? imageUrl : await normalizeToBase64ForGeneration(imageUrl)\n            requestBody.first_frame_image = firstFrameDataUrl\n            if (generationMode === 'firstlastframe' && isNonEmptyString(lastFrameImageUrl)) {\n                const lastFrameDataUrl = lastFrameImageUrl.startsWith('data:')\n                    ? lastFrameImageUrl\n                    : await normalizeToBase64ForGeneration(lastFrameImageUrl)\n                requestBody.last_frame_image = lastFrameDataUrl\n                _ulogInfo(`${logPrefix} 使用首尾帧图片 (已转Data URL)`)\n            } else {\n                _ulogInfo(`${logPrefix} 使用首帧图片 (已转Data URL)`)\n            }\n        }\n\n        _ulogInfo(\n            `${logPrefix} 提交任务，mode=${generationMode}，duration=${duration ?? '(provider default)'}s，resolution=${normalizedResolution ?? '(provider default)'}`,\n        )\n\n        try {\n            const response = await fetch(`${MINIMAX_BASE_URL}/video_generation`, {\n                method: 'POST',\n                headers: {\n                    'Authorization': `Bearer ${apiKey}`,\n                    'Content-Type': 'application/json'\n                },\n                body: JSON.stringify(requestBody)\n            })\n\n            if (!response.ok) {\n                const errorText = await response.text()\n                _ulogError(`${logPrefix} API请求失败:`, response.status, errorText)\n                throw new Error(`MiniMax API Error: ${response.status} - ${errorText}`)\n            }\n\n            const data = await response.json()\n\n            // 检查响应\n            if (data.base_resp?.status_code !== 0) {\n                const errMsg = data.base_resp?.status_msg || '未知错误'\n                _ulogError(`${logPrefix} 任务提交失败:`, errMsg)\n                throw new Error(`MiniMax: ${errMsg}`)\n            }\n\n            const taskId = data.task_id\n            if (!taskId) {\n                _ulogError(`${logPrefix} 响应中缺少 task_id:`, data)\n                throw new Error('MiniMax未返回task_id')\n            }\n\n            _ulogInfo(`${logPrefix} 任务已提交，task_id=${taskId}`)\n\n            return {\n                success: true,\n                async: true,\n                requestId: taskId,\n                externalId: `MINIMAX:VIDEO:${taskId}`\n            }\n        } catch (error: unknown) {\n            _ulogError(`${logPrefix} 生成失败:`, error)\n            throw error\n        }\n    }\n}\n"
  },
  {
    "path": "src/lib/generators/official.ts",
    "content": "import {\n  BaseAudioGenerator,\n  BaseImageGenerator,\n  BaseVideoGenerator,\n  type AudioGenerateParams,\n  type GenerateResult,\n  type ImageGenerateParams,\n  type VideoGenerateParams,\n} from './base'\nimport { generateBailianAudio, generateBailianImage, generateBailianVideo } from '@/lib/providers/bailian'\nimport { generateSiliconFlowAudio, generateSiliconFlowImage, generateSiliconFlowVideo } from '@/lib/providers/siliconflow'\n\nexport class BailianImageGenerator extends BaseImageGenerator {\n  protected async doGenerate(params: ImageGenerateParams): Promise<GenerateResult> {\n    const modelId = typeof params.options?.modelId === 'string' ? params.options.modelId : ''\n    const modelKey = typeof params.options?.modelKey === 'string' ? params.options.modelKey : ''\n    const provider = typeof params.options?.provider === 'string' ? params.options.provider : 'bailian'\n    return await generateBailianImage({\n      userId: params.userId,\n      prompt: params.prompt,\n      referenceImages: params.referenceImages,\n      options: {\n        ...params.options,\n        provider,\n        modelId,\n        modelKey,\n      },\n    })\n  }\n}\n\nexport class SiliconFlowImageGenerator extends BaseImageGenerator {\n  protected async doGenerate(params: ImageGenerateParams): Promise<GenerateResult> {\n    const modelId = typeof params.options?.modelId === 'string' ? params.options.modelId : ''\n    const modelKey = typeof params.options?.modelKey === 'string' ? params.options.modelKey : ''\n    const provider = typeof params.options?.provider === 'string' ? params.options.provider : 'siliconflow'\n    return await generateSiliconFlowImage({\n      userId: params.userId,\n      prompt: params.prompt,\n      referenceImages: params.referenceImages,\n      options: {\n        ...params.options,\n        provider,\n        modelId,\n        modelKey,\n      },\n    })\n  }\n}\n\nexport class BailianVideoGenerator extends BaseVideoGenerator {\n  protected async doGenerate(params: VideoGenerateParams): Promise<GenerateResult> {\n    const modelId = typeof params.options?.modelId === 'string' ? params.options.modelId : ''\n    const modelKey = typeof params.options?.modelKey === 'string' ? params.options.modelKey : ''\n    const provider = typeof params.options?.provider === 'string' ? params.options.provider : 'bailian'\n    return await generateBailianVideo({\n      userId: params.userId,\n      imageUrl: params.imageUrl,\n      prompt: params.prompt,\n      options: {\n        ...params.options,\n        provider,\n        modelId,\n        modelKey,\n      },\n    })\n  }\n}\n\nexport class SiliconFlowVideoGenerator extends BaseVideoGenerator {\n  protected async doGenerate(params: VideoGenerateParams): Promise<GenerateResult> {\n    const modelId = typeof params.options?.modelId === 'string' ? params.options.modelId : ''\n    const modelKey = typeof params.options?.modelKey === 'string' ? params.options.modelKey : ''\n    const provider = typeof params.options?.provider === 'string' ? params.options.provider : 'siliconflow'\n    return await generateSiliconFlowVideo({\n      userId: params.userId,\n      imageUrl: params.imageUrl,\n      prompt: params.prompt,\n      options: {\n        ...params.options,\n        provider,\n        modelId,\n        modelKey,\n      },\n    })\n  }\n}\n\nexport class BailianAudioGenerator extends BaseAudioGenerator {\n  protected async doGenerate(params: AudioGenerateParams): Promise<GenerateResult> {\n    const modelId = typeof params.options?.modelId === 'string' ? params.options.modelId : ''\n    const modelKey = typeof params.options?.modelKey === 'string' ? params.options.modelKey : ''\n    const provider = typeof params.options?.provider === 'string' ? params.options.provider : 'bailian'\n    return await generateBailianAudio({\n      userId: params.userId,\n      text: params.text,\n      voice: params.voice,\n      rate: params.rate,\n      options: {\n        ...params.options,\n        provider,\n        modelId,\n        modelKey,\n      },\n    })\n  }\n}\n\nexport class SiliconFlowAudioGenerator extends BaseAudioGenerator {\n  protected async doGenerate(params: AudioGenerateParams): Promise<GenerateResult> {\n    const modelId = typeof params.options?.modelId === 'string' ? params.options.modelId : ''\n    const modelKey = typeof params.options?.modelKey === 'string' ? params.options.modelKey : ''\n    const provider = typeof params.options?.provider === 'string' ? params.options.provider : 'siliconflow'\n    return await generateSiliconFlowAudio({\n      userId: params.userId,\n      text: params.text,\n      voice: params.voice,\n      rate: params.rate,\n      options: {\n        ...params.options,\n        provider,\n        modelId,\n        modelKey,\n      },\n    })\n  }\n}\n"
  },
  {
    "path": "src/lib/generators/resolution-adapter.ts",
    "content": "import { logInfo as _ulogInfo, logWarn as _ulogWarn } from '@/lib/logging/core'\n/**\n * 🎯 集中式视频分辨率适配器\n * \n * 职责：\n * - 将用户的通用分辨率配置（720p/1080p/4K等）转换为各模型支持的特定格式\n * - 集中管理所有模型的分辨率映射规则\n * - 简化维护，一目了然\n * \n * 使用示例：\n * ```typescript\n * const resolution = adaptVideoResolution('minimax', '1080p')\n * // 返回: '1080P'\n * ```\n */\n\n// ============================================================\n// 类型定义\n// ============================================================\n\nexport type VideoProvider = 'minimax' | 'fal' | 'ark' | 'vidu'\n\n// ============================================================\n// 分辨率适配规则\n// ============================================================\n\n/**\n * 各模型的分辨率适配规则\n * key: provider名称\n * value: 适配函数\n */\nconst RESOLUTION_ADAPTERS: Record<VideoProvider, (input: string) => string> = {\n    /**\n     * MiniMax (海螺)\n     * 支持：768P, 1080P\n     * \n     * 映射规则：\n     * - 720p/768p → 768P（标清）\n     * - 1080p及以上 → 1080P（高清，最高支持）\n     */\n    minimax: (input: string): string => {\n        const normalized = input.toLowerCase().replace(/[^0-9kp]/g, '')\n\n        // 720p 系列 → 768P\n        if (normalized.includes('720') || normalized.includes('768')) {\n            return '768P'\n        }\n\n        // 1080p 及以上全部映射到 1080P（MiniMax最高支持）\n        return '1080P'\n    },\n\n    /**\n     * FAL 模型\n     * 支持：720p, 1080p, 1440p, 4K\n     * \n     * FAL直接支持标准分辨率，不需要转换，只做格式统一\n     */\n    fal: (input: string): string => {\n        const normalized = input.toLowerCase()\n\n        if (normalized.includes('720')) return '720p'\n        if (normalized.includes('1080')) return '1080p'\n        if (normalized.includes('1440') || normalized.includes('2k')) return '1440p'\n        if (normalized.includes('4k')) return '4K'\n\n        return '1080p' // 默认1080p\n    },\n\n    /**\n     * Ark 模型 (Seedance等)\n     * 支持：720p, 1080p\n     * \n     * 映射规则：\n     * - 720p及以下 → 720p\n     * - 1080p及以上 → 1080p\n     */\n    ark: (input: string): string => {\n        const normalized = input.toLowerCase()\n\n        if (normalized.includes('720')) return '720p'\n        return '1080p' // 默认和高于1080p的都映射到1080p\n    },\n\n    /**\n     * Vidu 模型（示例，根据实际情况调整）\n     * 支持：720p, 1080p, 2K\n     * \n     * 映射规则：\n     * - 720p → 720p\n     * - 1080p → 1080p\n     * - 1440p/2K/4K → 2K\n     */\n    vidu: (input: string): string => {\n        const normalized = input.toLowerCase()\n\n        if (normalized.includes('720')) return '720p'\n        if (normalized.includes('1440') || normalized.includes('2k') || normalized.includes('4k')) {\n            return '2K'\n        }\n        return '1080p' // 默认1080p\n    }\n}\n\n// ============================================================\n// 公共API\n// ============================================================\n\n/**\n * 适配视频分辨率\n * \n * @param provider - 模型提供商\n * @param inputResolution - 用户配置的分辨率（如 '720p', '1080p', '4K'）\n * @returns 适配后的分辨率（符合该模型的规格）\n * \n * @example\n * adaptVideoResolution('minimax', '720p')  // 返回: '768P'\n * adaptVideoResolution('minimax', '1080p') // 返回: '1080P'\n * adaptVideoResolution('fal', '1080p')     // 返回: '1080p'\n */\nexport function adaptVideoResolution(\n    provider: string,\n    inputResolution: string\n): string {\n    const adapter = RESOLUTION_ADAPTERS[provider as VideoProvider]\n\n    if (!adapter) {\n        _ulogWarn(`[分辨率适配] 未知provider: ${provider}，使用原始值: ${inputResolution}`)\n        return inputResolution\n    }\n\n    const adapted = adapter(inputResolution)\n    _ulogInfo(`[分辨率适配] provider=${provider}, 输入=${inputResolution} → 适配=${adapted}`)\n    return adapted\n}\n\n/**\n * 获取模型支持的分辨率列表（用于UI展示）\n * \n * @param provider - 模型提供商\n * @returns 支持的分辨率列表\n */\nexport function getSupportedResolutions(provider: string): string[] {\n    const resolutionMap: Record<VideoProvider, string[]> = {\n        minimax: ['768P', '1080P'],\n        fal: ['720p', '1080p', '1440p', '4K'],\n        ark: ['720p', '1080p'],\n        vidu: ['720p', '1080p', '2K']\n    }\n\n    return resolutionMap[provider as VideoProvider] || ['720p', '1080p']\n}\n\n/**\n * 检查分辨率是否被支持（避免不必要的适配）\n * \n * @param provider - 模型提供商\n * @param resolution - 分辨率\n * @returns 是否直接支持\n */\nexport function isResolutionSupported(provider: string, resolution: string): boolean {\n    const supported = getSupportedResolutions(provider)\n    return supported.includes(resolution)\n}\n"
  },
  {
    "path": "src/lib/generators/video/google.ts",
    "content": "/**\n * Google Veo 视频生成器\n */\n\nimport { GoogleGenAI } from '@google/genai'\nimport { BaseVideoGenerator, VideoGenerateParams, GenerateResult } from '../base'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'\n\ninterface GoogleVeoOptions {\n    modelId?: string\n    aspectRatio?: string\n    resolution?: string\n    duration?: number\n    lastFrameImageUrl?: string\n}\n\nfunction dataUrlToInlineData(dataUrl: string): { mimeType: string; imageBytes: string } | null {\n    const base64Start = dataUrl.indexOf(';base64,')\n    if (base64Start === -1) return null\n    const mimeType = dataUrl.substring(5, base64Start)\n    const imageBytes = dataUrl.substring(base64Start + 8)\n    return { mimeType, imageBytes }\n}\n\nfunction asRecord(value: unknown): Record<string, unknown> | null {\n    return value && typeof value === 'object' ? (value as Record<string, unknown>) : null\n}\n\nfunction extractOperationName(response: unknown): string | null {\n    const obj = asRecord(response)\n    if (!obj) return null\n    if (typeof obj.name === 'string') return obj.name\n    const operation = asRecord(obj.operation)\n    if (operation && typeof operation.name === 'string') return operation.name\n    if (typeof obj.operationName === 'string') return obj.operationName\n    if (typeof obj.id === 'string') return obj.id\n    return null\n}\n\nexport class GoogleVeoVideoGenerator extends BaseVideoGenerator {\n    private providerId: string\n\n    constructor(providerId?: string) {\n        super()\n        this.providerId = providerId || 'google'\n    }\n\n    protected async doGenerate(params: VideoGenerateParams): Promise<GenerateResult> {\n        const { userId, imageUrl, prompt = '', options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, this.providerId)\n        const ai = new GoogleGenAI({ apiKey })\n\n        const {\n            modelId = 'veo-3.1-generate-preview',\n            aspectRatio,\n            resolution,\n            duration,\n            lastFrameImageUrl,\n        } = options as GoogleVeoOptions\n\n        const allowedOptionKeys = new Set([\n            'provider',\n            'modelId',\n            'modelKey',\n            'aspectRatio',\n            'resolution',\n            'duration',\n            'lastFrameImageUrl',\n        ])\n        for (const [key, value] of Object.entries(options)) {\n            if (value === undefined) continue\n            if (!allowedOptionKeys.has(key)) {\n                throw new Error(`GOOGLE_VIDEO_OPTION_UNSUPPORTED: ${key}`)\n            }\n        }\n\n        const request: Record<string, unknown> = {\n            model: modelId,\n        }\n        if (prompt.trim().length > 0) {\n            request.prompt = prompt\n        }\n        const config: Record<string, unknown> = {}\n        if (aspectRatio) config.aspectRatio = aspectRatio\n        if (resolution) config.resolution = resolution\n        if (typeof duration === 'number') config.durationSeconds = duration\n\n        let hasImageInput = false\n        // 添加首帧图片（图生视频）\n        if (imageUrl) {\n            const dataUrl = imageUrl.startsWith('data:') ? imageUrl : await normalizeToBase64ForGeneration(imageUrl)\n            const inlineData = dataUrlToInlineData(dataUrl)\n            if (inlineData) {\n                request.image = inlineData\n                hasImageInput = true\n            }\n        }\n\n        if (lastFrameImageUrl) {\n            // 官方要求：lastFrame 仅支持 image-to-video，必须与 image 同时使用\n            if (!hasImageInput) {\n                throw new Error('Veo lastFrame requires image input')\n            }\n            const dataUrl = lastFrameImageUrl.startsWith('data:')\n                ? lastFrameImageUrl\n                : await normalizeToBase64ForGeneration(lastFrameImageUrl)\n            const inlineData = dataUrlToInlineData(dataUrl)\n            if (!inlineData) {\n                throw new Error('Veo lastFrame image is invalid')\n            }\n            config.lastFrame = inlineData\n        }\n\n        if (Object.keys(config).length > 0) {\n            request.config = config\n        }\n\n        const response = await ai.models.generateVideos(\n            request as unknown as Parameters<typeof ai.models.generateVideos>[0]\n        )\n        const operationName = extractOperationName(response)\n\n        if (!operationName) {\n            throw new Error('Veo 未返回 operation name')\n        }\n\n        return {\n            success: true,\n            async: true,\n            requestId: operationName,\n            externalId: `GOOGLE:VIDEO:${operationName}`\n        }\n    }\n}\n"
  },
  {
    "path": "src/lib/generators/video/index.ts",
    "content": "/**\n * 视频生成器统一导出\n * \n * 🔥 FAL 和 Ark 已迁移到根目录的合并文件\n * - FAL: ../fal.ts\n * - Ark: ../ark.ts\n */\n\n// 向后兼容：从合并文件重新导出\nexport { FalVideoGenerator } from '../fal'\nexport { ArkSeedanceVideoGenerator, ArkVideoGenerator } from '../ark'\nexport { GoogleVeoVideoGenerator } from './google'\nexport { OpenAICompatibleVideoGenerator } from './openai-compatible'\n"
  },
  {
    "path": "src/lib/generators/video/openai-compatible.ts",
    "content": "import { BaseVideoGenerator, type GenerateResult, type VideoGenerateParams } from '../base'\nimport { generateVideoViaOpenAICompat } from '@/lib/model-gateway'\n\nexport class OpenAICompatibleVideoGenerator extends BaseVideoGenerator {\n  private readonly providerId?: string\n\n  constructor(providerId?: string) {\n    super()\n    this.providerId = providerId\n  }\n\n  protected async doGenerate(params: VideoGenerateParams): Promise<GenerateResult> {\n    const { userId, imageUrl, prompt = '', options = {} } = params\n    return await generateVideoViaOpenAICompat({\n      userId,\n      providerId: this.providerId || 'openai-compatible',\n      modelId: typeof options.modelId === 'string' ? options.modelId : undefined,\n      imageUrl,\n      prompt,\n      options,\n      profile: 'openai-compatible',\n    })\n  }\n}\n"
  },
  {
    "path": "src/lib/generators/vidu.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\n/**\n * Vidu 视频生成器\n */\n\nimport { BaseVideoGenerator, VideoGenerateParams, GenerateResult } from './base'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'\n\nconst VIDU_BASE_URL = 'https://api.vidu.cn/ent/v2'\nconst VIDU_STANDARD_RATIOS = new Set(['16:9', '9:16', '1:1'])\nconst VIDU_Q2_EXTRA_RATIOS = new Set(['4:3', '3:4', '21:9', '2:3', '3:2', 'auto'])\nconst VIDU_AUDIO_TYPES = new Set(['all', 'speech_only', 'sound_effect_only'])\nconst VIDU_MOVEMENT_AMPLITUDES = new Set(['auto', 'small', 'medium', 'large'])\nconst VIDU_RATIO_PATTERN = /^(\\d{1,4}):(\\d{1,4})$/\n\nconst MAX_PROMPT_LENGTH = 5000\nconst MAX_PAYLOAD_LENGTH = 1048576\n\ntype ViduGenerationMode = 'normal' | 'firstlastframe'\ntype ViduAudioType = 'all' | 'speech_only' | 'sound_effect_only'\ntype ViduMovementAmplitude = 'auto' | 'small' | 'medium' | 'large'\ntype ViduAspectRatioProfile = 'standard' | 'q2-flex'\n\ntype ViduResolutionRule = {\n    defaultResolution: string\n    resolutionOptions: readonly string[]\n}\n\ntype ViduModeSpec = {\n    defaultDuration: number\n    durationOptions: readonly number[]\n    resolutionRulesByDuration: Readonly<Record<number, ViduResolutionRule>>\n}\n\ninterface ViduModelSpec {\n    aspectRatioProfile: ViduAspectRatioProfile\n    supportsFirstLastFrame: boolean\n    supportsGenerateAudio: boolean\n    defaultAudioByMode: Record<ViduGenerationMode, boolean>\n    normalMode: ViduModeSpec\n    firstLastMode?: ViduModeSpec\n}\n\ninterface ViduVideoOptions {\n    modelId?: string\n    duration?: number\n    resolution?: string\n    aspectRatio?: string\n    aspect_ratio?: string\n    generateAudio?: boolean\n    audio?: boolean\n    audioType?: ViduAudioType\n    audio_type?: ViduAudioType\n    generationMode?: ViduGenerationMode\n    lastFrameImageUrl?: string\n    seed?: number\n    movementAmplitude?: ViduMovementAmplitude\n    movement_amplitude?: ViduMovementAmplitude\n    bgm?: boolean\n    isRec?: boolean\n    is_rec?: boolean\n    voiceId?: string\n    voice_id?: string\n    payload?: string\n    offPeak?: boolean\n    off_peak?: boolean\n    watermark?: boolean\n    wmPosition?: number\n    wm_position?: number\n    wmUrl?: string\n    wm_url?: string\n    metaData?: string\n    meta_data?: string\n    callbackUrl?: string\n    callback_url?: string\n}\n\ninterface ViduRequestBody {\n    model: string\n    images: string[]\n    prompt?: string\n    duration: number\n    resolution: string\n    aspect_ratio?: string\n    seed?: number\n    audio?: boolean\n    audio_type?: ViduAudioType\n    voice_id?: string\n    is_rec?: boolean\n    movement_amplitude?: ViduMovementAmplitude\n    bgm?: boolean\n    payload?: string\n    off_peak?: boolean\n    watermark?: boolean\n    wm_position?: number\n    wm_url?: string\n    meta_data?: string\n    callback_url?: string\n}\n\nfunction isInteger(value: unknown): value is number {\n    return typeof value === 'number' && Number.isInteger(value)\n}\n\nfunction isBoolean(value: unknown): value is boolean {\n    return typeof value === 'boolean'\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n    return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction range(start: number, end: number): number[] {\n    const out: number[] = []\n    for (let value = start; value <= end; value += 1) {\n        out.push(value)\n    }\n    return out\n}\n\nfunction pickFirstDefined<T>(...values: Array<T | undefined>): T | undefined {\n    for (const value of values) {\n        if (value !== undefined) return value\n    }\n    return undefined\n}\n\nfunction buildUniformModeSpec(input: {\n    durationOptions: readonly number[]\n    defaultDuration: number\n    resolutionOptions: readonly string[]\n    defaultResolution: string\n}): ViduModeSpec {\n    const resolutionRulesByDuration: Record<number, ViduResolutionRule> = {}\n    for (const duration of input.durationOptions) {\n        resolutionRulesByDuration[duration] = {\n            defaultResolution: input.defaultResolution,\n            resolutionOptions: input.resolutionOptions,\n        }\n    }\n\n    return {\n        defaultDuration: input.defaultDuration,\n        durationOptions: input.durationOptions,\n        resolutionRulesByDuration,\n    }\n}\n\nfunction buildVidu20ModeSpec(): ViduModeSpec {\n    return {\n        defaultDuration: 4,\n        durationOptions: [4, 8],\n        resolutionRulesByDuration: {\n            4: {\n                defaultResolution: '360p',\n                resolutionOptions: ['360p', '720p', '1080p'],\n            },\n            8: {\n                defaultResolution: '720p',\n                resolutionOptions: ['720p'],\n            },\n        },\n    }\n}\n\nconst Q3_DURATIONS = range(1, 16)\nconst Q2_NORMAL_DURATIONS = range(1, 10)\nconst Q2_FIRSTLAST_DURATIONS = range(1, 8)\n\nconst VIDU_MODEL_SPECS: Record<string, ViduModelSpec> = {\n    'viduq3-pro': {\n        aspectRatioProfile: 'standard',\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: true,\n        defaultAudioByMode: {\n            normal: true,\n            firstlastframe: true,\n        },\n        normalMode: buildUniformModeSpec({\n            durationOptions: Q3_DURATIONS,\n            defaultDuration: 5,\n            resolutionOptions: ['540p', '720p', '1080p'],\n            defaultResolution: '720p',\n        }),\n        firstLastMode: buildUniformModeSpec({\n            durationOptions: Q3_DURATIONS,\n            defaultDuration: 5,\n            resolutionOptions: ['540p', '720p', '1080p'],\n            defaultResolution: '720p',\n        }),\n    },\n    'viduq2-pro-fast': {\n        aspectRatioProfile: 'q2-flex',\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: true,\n        defaultAudioByMode: {\n            normal: false,\n            firstlastframe: false,\n        },\n        normalMode: buildUniformModeSpec({\n            durationOptions: Q2_NORMAL_DURATIONS,\n            defaultDuration: 5,\n            resolutionOptions: ['720p', '1080p'],\n            defaultResolution: '720p',\n        }),\n        firstLastMode: buildUniformModeSpec({\n            durationOptions: Q2_FIRSTLAST_DURATIONS,\n            defaultDuration: 5,\n            resolutionOptions: ['720p', '1080p'],\n            defaultResolution: '720p',\n        }),\n    },\n    'viduq2-pro': {\n        aspectRatioProfile: 'q2-flex',\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: true,\n        defaultAudioByMode: {\n            normal: false,\n            firstlastframe: false,\n        },\n        normalMode: buildUniformModeSpec({\n            durationOptions: Q2_NORMAL_DURATIONS,\n            defaultDuration: 5,\n            resolutionOptions: ['540p', '720p', '1080p'],\n            defaultResolution: '720p',\n        }),\n        firstLastMode: buildUniformModeSpec({\n            durationOptions: Q2_FIRSTLAST_DURATIONS,\n            defaultDuration: 5,\n            resolutionOptions: ['540p', '720p', '1080p'],\n            defaultResolution: '720p',\n        }),\n    },\n    'viduq2-turbo': {\n        aspectRatioProfile: 'q2-flex',\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: true,\n        defaultAudioByMode: {\n            normal: false,\n            firstlastframe: false,\n        },\n        normalMode: buildUniformModeSpec({\n            durationOptions: Q2_NORMAL_DURATIONS,\n            defaultDuration: 5,\n            resolutionOptions: ['540p', '720p', '1080p'],\n            defaultResolution: '720p',\n        }),\n        firstLastMode: buildUniformModeSpec({\n            durationOptions: Q2_FIRSTLAST_DURATIONS,\n            defaultDuration: 5,\n            resolutionOptions: ['540p', '720p', '1080p'],\n            defaultResolution: '720p',\n        }),\n    },\n    'viduq1': {\n        aspectRatioProfile: 'standard',\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: false,\n        defaultAudioByMode: {\n            normal: false,\n            firstlastframe: false,\n        },\n        normalMode: buildUniformModeSpec({\n            durationOptions: [5],\n            defaultDuration: 5,\n            resolutionOptions: ['1080p'],\n            defaultResolution: '1080p',\n        }),\n        firstLastMode: buildUniformModeSpec({\n            durationOptions: [5],\n            defaultDuration: 5,\n            resolutionOptions: ['1080p'],\n            defaultResolution: '1080p',\n        }),\n    },\n    'viduq1-classic': {\n        aspectRatioProfile: 'standard',\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: false,\n        defaultAudioByMode: {\n            normal: false,\n            firstlastframe: false,\n        },\n        normalMode: buildUniformModeSpec({\n            durationOptions: [5],\n            defaultDuration: 5,\n            resolutionOptions: ['1080p'],\n            defaultResolution: '1080p',\n        }),\n        firstLastMode: buildUniformModeSpec({\n            durationOptions: [5],\n            defaultDuration: 5,\n            resolutionOptions: ['1080p'],\n            defaultResolution: '1080p',\n        }),\n    },\n    'vidu2.0': {\n        aspectRatioProfile: 'standard',\n        supportsFirstLastFrame: true,\n        supportsGenerateAudio: false,\n        defaultAudioByMode: {\n            normal: false,\n            firstlastframe: false,\n        },\n        normalMode: buildVidu20ModeSpec(),\n        firstLastMode: buildVidu20ModeSpec(),\n    },\n}\n\nfunction isQ3Model(modelId: string): boolean {\n    return modelId.startsWith('viduq3')\n}\n\nfunction resolveGenerationMode(lastFrameImageUrl: string | undefined): ViduGenerationMode {\n    return lastFrameImageUrl ? 'firstlastframe' : 'normal'\n}\n\nfunction normalizeGenerationMode(raw: unknown): ViduGenerationMode | undefined {\n    if (raw === undefined) return undefined\n    if (raw === 'normal' || raw === 'firstlastframe') return raw\n    throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: generationMode=${String(raw)}`)\n}\n\nfunction resolveViduEndpoint(mode: ViduGenerationMode): string {\n    return mode === 'firstlastframe' ? '/start-end2video' : '/img2video'\n}\n\nfunction resolveModeSpec(modelSpec: ViduModelSpec, mode: ViduGenerationMode): ViduModeSpec {\n    if (mode === 'normal') return modelSpec.normalMode\n    const firstLastMode = modelSpec.firstLastMode\n    if (!firstLastMode) {\n        throw new Error('VIDU_VIDEO_OPTION_UNSUPPORTED: firstlastframe')\n    }\n    return firstLastMode\n}\n\nfunction resolveResolutionRule(modeSpec: ViduModeSpec, duration: number): ViduResolutionRule {\n    const rule = modeSpec.resolutionRulesByDuration[duration]\n    if (!rule) {\n        throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: duration=${duration}`)\n    }\n    return rule\n}\n\nfunction isRatioAllowedByProfile(profile: ViduAspectRatioProfile, ratio: string): boolean {\n    if (VIDU_STANDARD_RATIOS.has(ratio)) return true\n    if (profile !== 'q2-flex') return false\n\n    if (VIDU_Q2_EXTRA_RATIOS.has(ratio)) return true\n    const match = ratio.match(VIDU_RATIO_PATTERN)\n    if (!match) return false\n    const width = Number(match[1])\n    const height = Number(match[2])\n    return Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0\n}\n\nfunction normalizeOptionalString(raw: unknown): string | undefined {\n    if (raw === undefined) return undefined\n    if (!isNonEmptyString(raw)) {\n        throw new Error(`VIDU_VIDEO_OPTION_INVALID: expected non-empty string, got ${String(raw)}`)\n    }\n    return raw.trim()\n}\n\nfunction normalizeOptionalBoolean(raw: unknown, field: string): boolean | undefined {\n    if (raw === undefined) return undefined\n    if (!isBoolean(raw)) {\n        throw new Error(`VIDU_VIDEO_OPTION_INVALID: ${field} must be boolean`)\n    }\n    return raw\n}\n\nexport class ViduVideoGenerator extends BaseVideoGenerator {\n    protected async doGenerate(params: VideoGenerateParams): Promise<GenerateResult> {\n        const { userId, imageUrl, prompt = '', options = {} } = params\n\n        const { apiKey } = await getProviderConfig(userId, 'vidu')\n        const rawOptions = options as ViduVideoOptions\n\n        const modelId = rawOptions.modelId || 'viduq2-turbo'\n        const modelSpec = VIDU_MODEL_SPECS[modelId]\n        if (!modelSpec) {\n            throw new Error(`VIDU_VIDEO_MODEL_UNSUPPORTED: ${modelId}`)\n        }\n\n        const allowedOptionKeys = new Set([\n            'provider',\n            'modelId',\n            'modelKey',\n            'duration',\n            'resolution',\n            'aspectRatio',\n            'aspect_ratio',\n            'generateAudio',\n            'audio',\n            'audioType',\n            'audio_type',\n            'generationMode',\n            'lastFrameImageUrl',\n            'seed',\n            'movementAmplitude',\n            'movement_amplitude',\n            'bgm',\n            'isRec',\n            'is_rec',\n            'voiceId',\n            'voice_id',\n            'payload',\n            'offPeak',\n            'off_peak',\n            'watermark',\n            'wmPosition',\n            'wm_position',\n            'wmUrl',\n            'wm_url',\n            'metaData',\n            'meta_data',\n            'callbackUrl',\n            'callback_url',\n        ])\n\n        for (const [key, value] of Object.entries(options)) {\n            if (value === undefined) continue\n            if (!allowedOptionKeys.has(key)) {\n                throw new Error(`VIDU_VIDEO_OPTION_UNSUPPORTED: ${key}`)\n            }\n        }\n\n        const lastFrameImageUrl = normalizeOptionalString(rawOptions.lastFrameImageUrl)\n        const inferredGenerationMode = resolveGenerationMode(lastFrameImageUrl)\n        const requestedGenerationMode = normalizeGenerationMode(rawOptions.generationMode)\n        if (requestedGenerationMode && requestedGenerationMode !== inferredGenerationMode) {\n            throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: generationMode=${requestedGenerationMode}`)\n        }\n        const generationMode = requestedGenerationMode || inferredGenerationMode\n\n        if (generationMode === 'firstlastframe' && !modelSpec.supportsFirstLastFrame) {\n            throw new Error(`VIDU_VIDEO_OPTION_UNSUPPORTED: firstlastframe for ${modelId}`)\n        }\n\n        const modeSpec = resolveModeSpec(modelSpec, generationMode)\n\n        const rawDuration = rawOptions.duration\n        if (rawDuration !== undefined) {\n            if (!isInteger(rawDuration)) {\n                throw new Error('VIDU_VIDEO_OPTION_INVALID: duration must be integer')\n            }\n        }\n        const duration = rawDuration ?? modeSpec.defaultDuration\n        if (!modeSpec.durationOptions.includes(duration)) {\n            throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: duration=${duration}`)\n        }\n\n        const resolutionRule = resolveResolutionRule(modeSpec, duration)\n        const rawResolution = rawOptions.resolution\n        if (rawResolution !== undefined && !isNonEmptyString(rawResolution)) {\n            throw new Error('VIDU_VIDEO_OPTION_INVALID: resolution must be non-empty string')\n        }\n        const pickedResolution = rawResolution ? rawResolution.trim() : resolutionRule.defaultResolution\n        if (!resolutionRule.resolutionOptions.includes(pickedResolution)) {\n            throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: resolution=${pickedResolution}`)\n        }\n\n        const pickedAspectRatio = pickFirstDefined(rawOptions.aspectRatio, rawOptions.aspect_ratio)\n        if (pickedAspectRatio !== undefined) {\n            if (!isNonEmptyString(pickedAspectRatio)) {\n                throw new Error('VIDU_VIDEO_OPTION_INVALID: aspectRatio must be non-empty string')\n            }\n            const normalizedRatio = pickedAspectRatio.trim()\n            if (!isRatioAllowedByProfile(modelSpec.aspectRatioProfile, normalizedRatio)) {\n                throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: aspectRatio=${normalizedRatio}`)\n            }\n        }\n\n        if (rawOptions.seed !== undefined && !isInteger(rawOptions.seed)) {\n            throw new Error('VIDU_VIDEO_OPTION_INVALID: seed must be integer')\n        }\n\n        const rawGenerateAudio = pickFirstDefined(rawOptions.generateAudio, rawOptions.audio)\n        if (rawGenerateAudio !== undefined && !isBoolean(rawGenerateAudio)) {\n            throw new Error('VIDU_VIDEO_OPTION_INVALID: generateAudio must be boolean')\n        }\n        const resolvedGenerateAudio = rawGenerateAudio ?? modelSpec.defaultAudioByMode[generationMode]\n        if (resolvedGenerateAudio && !modelSpec.supportsGenerateAudio) {\n            throw new Error(`VIDU_VIDEO_OPTION_UNSUPPORTED: generateAudio for ${modelId}`)\n        }\n\n        const rawAudioType = pickFirstDefined(rawOptions.audioType, rawOptions.audio_type)\n        if (rawAudioType !== undefined) {\n            if (!isNonEmptyString(rawAudioType)) {\n                throw new Error('VIDU_VIDEO_OPTION_INVALID: audioType must be non-empty string')\n            }\n            if (!VIDU_AUDIO_TYPES.has(rawAudioType)) {\n                throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: audioType=${rawAudioType}`)\n            }\n            if (!resolvedGenerateAudio) {\n                throw new Error('VIDU_VIDEO_OPTION_UNSUPPORTED: audioType requires generateAudio=true')\n            }\n        }\n        const resolvedAudioType = resolvedGenerateAudio\n            ? ((rawAudioType || 'all') as ViduAudioType)\n            : undefined\n\n        const movementAmplitude = pickFirstDefined(rawOptions.movementAmplitude, rawOptions.movement_amplitude)\n        if (movementAmplitude !== undefined) {\n            if (!isNonEmptyString(movementAmplitude)) {\n                throw new Error('VIDU_VIDEO_OPTION_INVALID: movementAmplitude must be non-empty string')\n            }\n            if (!VIDU_MOVEMENT_AMPLITUDES.has(movementAmplitude)) {\n                throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: movementAmplitude=${movementAmplitude}`)\n            }\n        }\n\n        const bgm = normalizeOptionalBoolean(rawOptions.bgm, 'bgm')\n        const isRec = normalizeOptionalBoolean(pickFirstDefined(rawOptions.isRec, rawOptions.is_rec), 'isRec')\n        const offPeak = normalizeOptionalBoolean(pickFirstDefined(rawOptions.offPeak, rawOptions.off_peak), 'offPeak')\n        const watermark = normalizeOptionalBoolean(rawOptions.watermark, 'watermark')\n\n        if (offPeak === true && resolvedGenerateAudio) {\n            if (!isQ3Model(modelId)) {\n                throw new Error('VIDU_VIDEO_OPTION_UNSUPPORTED: offPeak with generateAudio=true for non-Q3 model')\n            }\n        }\n        if (offPeak === true && isQ3Model(modelId) && !resolvedGenerateAudio) {\n            throw new Error('VIDU_VIDEO_OPTION_UNSUPPORTED: offPeak for Q3 requires generateAudio=true')\n        }\n\n        const voiceId = normalizeOptionalString(pickFirstDefined(rawOptions.voiceId, rawOptions.voice_id))\n        if (voiceId && !resolvedGenerateAudio) {\n            throw new Error('VIDU_VIDEO_OPTION_UNSUPPORTED: voiceId requires generateAudio=true')\n        }\n\n        const payload = normalizeOptionalString(rawOptions.payload)\n        if (payload && payload.length > MAX_PAYLOAD_LENGTH) {\n            throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: payload length > ${MAX_PAYLOAD_LENGTH}`)\n        }\n\n        const wmPositionRaw = pickFirstDefined(rawOptions.wmPosition, rawOptions.wm_position)\n        if (wmPositionRaw !== undefined && !isInteger(wmPositionRaw)) {\n            throw new Error('VIDU_VIDEO_OPTION_INVALID: wmPosition must be integer')\n        }\n        const wmPosition = wmPositionRaw\n        if (wmPosition !== undefined && (wmPosition < 1 || wmPosition > 4)) {\n            throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: wmPosition=${wmPosition}`)\n        }\n\n        const wmUrl = normalizeOptionalString(pickFirstDefined(rawOptions.wmUrl, rawOptions.wm_url))\n        const metaData = normalizeOptionalString(pickFirstDefined(rawOptions.metaData, rawOptions.meta_data))\n        const callbackUrl = normalizeOptionalString(pickFirstDefined(rawOptions.callbackUrl, rawOptions.callback_url))\n\n        if (prompt.length > MAX_PROMPT_LENGTH) {\n            throw new Error(`VIDU_VIDEO_OPTION_VALUE_UNSUPPORTED: prompt length > ${MAX_PROMPT_LENGTH}`)\n        }\n\n        const logPrefix = `[Vidu Video ${modelId}]`\n\n        const firstFrameDataUrl = imageUrl.startsWith('data:') ? imageUrl : await normalizeToBase64ForGeneration(imageUrl)\n        const images: string[] = [firstFrameDataUrl]\n        if (generationMode === 'firstlastframe') {\n            if (!lastFrameImageUrl) {\n                throw new Error('VIDU_VIDEO_OPTION_REQUIRED: lastFrameImageUrl')\n            }\n            images.push(\n                lastFrameImageUrl.startsWith('data:')\n                    ? lastFrameImageUrl\n                    : await normalizeToBase64ForGeneration(lastFrameImageUrl),\n            )\n        }\n\n        const requestBody: ViduRequestBody = {\n            model: modelId,\n            images,\n            duration,\n            resolution: pickedResolution,\n        }\n\n        if (prompt) {\n            requestBody.prompt = prompt\n        }\n        if (pickedAspectRatio) {\n            requestBody.aspect_ratio = pickedAspectRatio\n        }\n        if (rawOptions.seed !== undefined) {\n            requestBody.seed = rawOptions.seed\n        }\n        if (modelSpec.supportsGenerateAudio || rawGenerateAudio !== undefined) {\n            requestBody.audio = resolvedGenerateAudio\n        }\n        if (resolvedAudioType) {\n            requestBody.audio_type = resolvedAudioType\n        }\n        if (voiceId) {\n            requestBody.voice_id = voiceId\n        }\n        if (isRec !== undefined) {\n            requestBody.is_rec = isRec\n        }\n        if (movementAmplitude) {\n            requestBody.movement_amplitude = movementAmplitude\n        }\n        if (bgm !== undefined) {\n            requestBody.bgm = bgm\n        }\n        if (payload) {\n            requestBody.payload = payload\n        }\n        if (offPeak !== undefined) {\n            requestBody.off_peak = offPeak\n        }\n        if (watermark !== undefined) {\n            requestBody.watermark = watermark\n        }\n        if (wmPosition !== undefined) {\n            requestBody.wm_position = wmPosition\n        }\n        if (wmUrl) {\n            requestBody.wm_url = wmUrl\n        }\n        if (metaData) {\n            requestBody.meta_data = metaData\n        }\n        if (callbackUrl) {\n            requestBody.callback_url = callbackUrl\n        }\n\n        const endpoint = resolveViduEndpoint(generationMode)\n\n        _ulogInfo(`${logPrefix} 提交任务`)\n        _ulogInfo(`${logPrefix} - Model: ${modelId}`)\n        _ulogInfo(`${logPrefix} - Duration: ${duration}s`)\n        _ulogInfo(`${logPrefix} - Resolution: ${pickedResolution}`)\n        _ulogInfo(`${logPrefix} - Mode: ${generationMode}`)\n        _ulogInfo(`${logPrefix} - GenerateAudio: ${resolvedGenerateAudio}`)\n        _ulogInfo(`${logPrefix} - 完整请求体:`, JSON.stringify(requestBody, null, 2))\n\n        try {\n            const response = await fetch(`${VIDU_BASE_URL}${endpoint}`, {\n                method: 'POST',\n                headers: {\n                    Authorization: `Token ${apiKey}`,\n                    'Content-Type': 'application/json',\n                },\n                body: JSON.stringify(requestBody),\n            })\n\n            if (!response.ok) {\n                const errorText = await response.text()\n                _ulogError(`${logPrefix} API请求失败:`, response.status, errorText)\n                throw new Error(`Vidu API Error: ${response.status} - ${errorText}`)\n            }\n\n            const data = await response.json()\n\n            const taskId = data.task_id\n            if (!taskId) {\n                _ulogError(`${logPrefix} 响应中缺少 task_id:`, data)\n                throw new Error('Vidu未返回task_id')\n            }\n\n            const state = data.state\n            if (state === 'failed') {\n                _ulogError(`${logPrefix} 任务提交失败:`, data)\n                throw new Error('Vidu: 任务提交失败')\n            }\n\n            _ulogInfo(`${logPrefix} 任务已提交，task_id=${taskId}, state=${state}`)\n\n            return {\n                success: true,\n                async: true,\n                requestId: taskId,\n                externalId: `VIDU:VIDEO:${taskId}`,\n            }\n        } catch (error: unknown) {\n            _ulogError(`${logPrefix} 生成失败:`, error)\n            throw error\n        }\n    }\n}\n"
  },
  {
    "path": "src/lib/image-cache.ts",
    "content": "import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { toFetchableUrl } from '@/lib/storage'\nimport { LRUCache } from 'lru-cache'\n/**\n * 🔥 图片下载缓存系统\n * \n * 解决问题：批量生成分镜时，每个请求都重复下载相同的参考图片\n * \n * 实现方式：\n * - 使用 LRU 缓存正在进行的下载 Promise\n * - 同一 URL 的并发请求共享同一个 Promise\n * - 缓存有 TTL，避免内存泄漏\n */\n\n// 缓存条目类型\ninterface CacheEntry {\n    promise: Promise<string>  // Base64 结果的 Promise\n    expiresAt: number         // 过期时间戳\n    size?: number             // 图片大小（字节）\n}\n\n// 缓存配置\nconst CACHE_TTL_MS = 5 * 60 * 1000  // 5 分钟 TTL\nconst MAX_CACHE_SIZE = 100          // 最多缓存 100 张图片\nconst CLEANUP_INTERVAL_MS = 60 * 1000  // 每分钟清理一次\n\n// 全局缓存\nconst imageCache = new LRUCache<string, CacheEntry>({\n    max: MAX_CACHE_SIZE,\n    ttl: CACHE_TTL_MS,\n    ttlAutopurge: true,\n})\n\n// 统计信息\nlet cacheHits = 0\nlet cacheMisses = 0\nlet totalDownloadTime = 0\n\n/**\n * 获取图片的 Base64（带缓存）\n * \n * @param imageUrl 图片 URL（http/https）或已经是 base64\n * @param options 选项\n * @returns Base64 格式的图片数据（data:image/...;base64,...）\n */\nexport async function getImageBase64Cached(\n    imageUrl: string,\n    options: {\n        logPrefix?: string\n        forceRefresh?: boolean\n    } = {}\n): Promise<string> {\n    const { logPrefix = '[图片缓存]', forceRefresh = false } = options\n\n    // 如果已经是 base64，直接返回\n    if (imageUrl.startsWith('data:')) {\n        return imageUrl\n    }\n\n    let fullUrl = imageUrl\n    if (!imageUrl.startsWith('http') && !imageUrl.startsWith('/')) {\n        throw new Error(`无效的图片 URL: ${imageUrl.substring(0, 50)}...`)\n    }\n    fullUrl = toFetchableUrl(fullUrl)\n\n    const cacheKey = imageUrl\n\n    // 检查缓存\n    if (!forceRefresh) {\n        const cached = imageCache.get(cacheKey)\n        if (cached && cached.expiresAt > Date.now()) {\n            cacheHits++\n            _ulogInfo(`${logPrefix} ✅ 缓存命中 (${cacheHits}/${cacheHits + cacheMisses})`)\n            return cached.promise\n        }\n    }\n\n    cacheMisses++\n\n    // 创建下载 Promise（共享给所有并发请求）\n    const downloadPromise = downloadImageAsBase64(fullUrl, logPrefix)\n\n    // 存入缓存\n    imageCache.set(cacheKey, {\n        promise: downloadPromise,\n        expiresAt: Date.now() + CACHE_TTL_MS\n    })\n\n    // 下载完成后更新大小\n    downloadPromise.then(base64 => {\n        const entry = imageCache.get(cacheKey)\n        if (entry) {\n            entry.size = base64.length\n        }\n    }).catch(() => {\n        // 下载失败，从缓存中移除\n        imageCache.delete(cacheKey)\n    })\n\n    return downloadPromise\n}\n\n/**\n * 实际下载图片并转换为 Base64\n */\nasync function downloadImageAsBase64(imageUrl: string, logPrefix: string): Promise<string> {\n    const startTime = Date.now()\n    _ulogInfo(`${logPrefix} 开始下载: ${imageUrl.substring(0, 80)}...`)\n\n    try {\n        const response = await fetch(toFetchableUrl(imageUrl), {\n            headers: {\n                'User-Agent': 'Mozilla/5.0 (compatible; ImageDownloader/1.0)'\n            }\n        })\n\n        if (!response.ok) {\n            throw new Error(`HTTP ${response.status}: ${response.statusText}`)\n        }\n\n        const buffer = await response.arrayBuffer()\n        const base64 = Buffer.from(buffer).toString('base64')\n        const contentType = response.headers.get('content-type') || 'image/png'\n\n        const duration = Date.now() - startTime\n        totalDownloadTime += duration\n        const sizeKB = Math.round(buffer.byteLength / 1024)\n\n        _ulogInfo(`${logPrefix} ✅ 下载完成: ${sizeKB}KB, ${duration}ms`)\n\n        return `data:${contentType};base64,${base64}`\n    } catch (error: unknown) {\n        const duration = Date.now() - startTime\n        const message =\n            error instanceof Error\n                ? error.message\n                : (typeof error === 'object' && error !== null && typeof (error as { message?: unknown }).message === 'string')\n                    ? (error as { message: string }).message\n                    : '未知错误'\n        _ulogError(`${logPrefix} ❌ 下载失败 (${duration}ms): ${message}`)\n        throw error\n    }\n}\n\n/**\n * 批量预加载图片（并行下载，共享缓存）\n * \n * @param imageUrls 图片 URL 列表\n * @param options 选项\n * @returns Base64 图片数组（按原顺序）\n */\nexport async function preloadImagesParallel(\n    imageUrls: string[],\n    options: {\n        logPrefix?: string\n        maxConcurrency?: number\n    } = {}\n): Promise<string[]> {\n    const { logPrefix = '[批量预加载]' } = options\n\n    // 去重（支持 http URL 和本地相对路径 /api/files/...）\n    const uniqueUrls = [...new Set(imageUrls.filter(url => url && (url.startsWith('http') || url.startsWith('/'))))]\n\n    if (uniqueUrls.length === 0) {\n        return imageUrls.map(url => url?.startsWith('data:') ? url : '')\n    }\n\n    _ulogInfo(`${logPrefix} 开始预加载 ${uniqueUrls.length} 张唯一图片 (原始: ${imageUrls.length} 张)`)\n\n    const startTime = Date.now()\n\n    // 并行下载所有唯一图片\n    const downloadPromises = uniqueUrls.map(url =>\n        getImageBase64Cached(url, { logPrefix })\n    )\n\n    // 等待所有下载完成\n    const results = await Promise.allSettled(downloadPromises)\n\n    // 构建 URL -> Base64 映射\n    const urlToBase64 = new Map<string, string>()\n    results.forEach((result, index) => {\n        if (result.status === 'fulfilled') {\n            urlToBase64.set(uniqueUrls[index], result.value)\n        }\n    })\n\n    const duration = Date.now() - startTime\n    const successCount = results.filter(r => r.status === 'fulfilled').length\n    _ulogInfo(`${logPrefix} 预加载完成: ${successCount}/${uniqueUrls.length} 成功, ${duration}ms`)\n\n    // 按原顺序返回\n    return imageUrls.map(url => {\n        if (!url) return ''\n        if (url.startsWith('data:')) return url\n        return urlToBase64.get(url) || ''\n    })\n}\n\n/**\n * 清理过期缓存\n */\nfunction cleanupExpiredCache() {\n    const before = imageCache.size\n    imageCache.purgeStale()\n    const cleaned = before - imageCache.size\n\n    if (cleaned > 0) {\n        _ulogInfo(`[图片缓存] 清理 ${cleaned} 个过期条目，剩余 ${imageCache.size} 个`)\n    }\n}\n\n/**\n * 获取缓存统计信息\n */\nexport function getImageCacheStats() {\n    const now = Date.now()\n    let validCount = 0\n    let totalSize = 0\n\n    for (const entry of imageCache.values()) {\n        if (entry.expiresAt > now) {\n            validCount++\n            totalSize += entry.size || 0\n        }\n    }\n\n    return {\n        cacheSize: imageCache.size,\n        validEntries: validCount,\n        totalSizeKB: Math.round(totalSize / 1024),\n        cacheHits,\n        cacheMisses,\n        hitRate: cacheHits + cacheMisses > 0\n            ? Math.round(cacheHits / (cacheHits + cacheMisses) * 100)\n            : 0,\n        totalDownloadTimeMs: totalDownloadTime\n    }\n}\n\n/**\n * 清空缓存\n */\nexport function clearImageCache() {\n    imageCache.clear()\n    cacheHits = 0\n    cacheMisses = 0\n    totalDownloadTime = 0\n    _ulogInfo('[图片缓存] 已清空')\n}\n\n// 定期清理\nsetInterval(cleanupExpiredCache, CLEANUP_INTERVAL_MS)\n"
  },
  {
    "path": "src/lib/image-generation/count-preference.ts",
    "content": "'use client'\n\nimport {\n  getImageGenerationCountConfig,\n  getImageGenerationCountStorageKey,\n  normalizeImageGenerationCount,\n  type ImageGenerationCountScope,\n} from './count'\n\nfunction getStorage(): Storage | null {\n  if (typeof window === 'undefined') return null\n  try {\n    return window.localStorage\n  } catch {\n    return null\n  }\n}\n\nexport function getImageGenerationCount(scope: ImageGenerationCountScope): number {\n  const storage = getStorage()\n  const fallback = getImageGenerationCountConfig(scope).defaultValue\n  if (!storage) return fallback\n  const rawValue = storage.getItem(getImageGenerationCountStorageKey(scope))\n  return normalizeImageGenerationCount(scope, rawValue, fallback)\n}\n\nexport function setImageGenerationCount(scope: ImageGenerationCountScope, value: unknown): number {\n  const normalized = normalizeImageGenerationCount(scope, value)\n  const storage = getStorage()\n  if (storage) {\n    storage.setItem(getImageGenerationCountStorageKey(scope), String(normalized))\n  }\n  return normalized\n}\n"
  },
  {
    "path": "src/lib/image-generation/count.ts",
    "content": "export type ImageGenerationCountScope =\n  | 'character'\n  | 'location'\n  | 'storyboard-candidates'\n  | 'reference-to-character'\n\ninterface ImageGenerationCountConfig {\n  defaultValue: number\n  min: number\n  max: number\n  storageKey: string\n}\n\nconst IMAGE_GENERATION_COUNT_CONFIG: Record<ImageGenerationCountScope, ImageGenerationCountConfig> = {\n  character: {\n    defaultValue: 3,\n    min: 1,\n    max: 6,\n    storageKey: 'image-count:character',\n  },\n  location: {\n    defaultValue: 3,\n    min: 1,\n    max: 6,\n    storageKey: 'image-count:location',\n  },\n  'storyboard-candidates': {\n    defaultValue: 1,\n    min: 1,\n    max: 4,\n    storageKey: 'image-count:storyboard-candidates',\n  },\n  'reference-to-character': {\n    defaultValue: 3,\n    min: 1,\n    max: 6,\n    storageKey: 'image-count:reference-to-character',\n  },\n}\n\nfunction toFiniteNumber(value: unknown): number | null {\n  if (typeof value === 'number' && Number.isFinite(value)) {\n    return value\n  }\n\n  if (typeof value === 'string') {\n    const trimmed = value.trim()\n    if (!trimmed) return null\n    const parsed = Number(trimmed)\n    return Number.isFinite(parsed) ? parsed : null\n  }\n\n  return null\n}\n\nexport function getImageGenerationCountConfig(scope: ImageGenerationCountScope): ImageGenerationCountConfig {\n  return IMAGE_GENERATION_COUNT_CONFIG[scope]\n}\n\nexport function normalizeImageGenerationCount(\n  scope: ImageGenerationCountScope,\n  value: unknown,\n  fallback = getImageGenerationCountConfig(scope).defaultValue,\n): number {\n  const config = getImageGenerationCountConfig(scope)\n  const numericValue = toFiniteNumber(value)\n  const baseValue = numericValue === null ? fallback : Math.trunc(numericValue)\n  if (baseValue < config.min) return config.min\n  if (baseValue > config.max) return config.max\n  return baseValue\n}\n\nexport function getImageGenerationCountOptions(scope: ImageGenerationCountScope): number[] {\n  const config = getImageGenerationCountConfig(scope)\n  return Array.from(\n    { length: config.max - config.min + 1 },\n    (_value, index) => config.min + index,\n  )\n}\n\nexport function getImageGenerationCountStorageKey(scope: ImageGenerationCountScope): string {\n  return getImageGenerationCountConfig(scope).storageKey\n}\n"
  },
  {
    "path": "src/lib/image-generation/location-slots.ts",
    "content": "import { prisma } from '@/lib/prisma'\n\nexport async function ensureProjectLocationImageSlots(input: {\n  locationId: string\n  count: number\n  fallbackDescription: string\n}) {\n  const existing = await prisma.locationImage.findMany({\n    where: { locationId: input.locationId },\n    select: { imageIndex: true },\n    orderBy: { imageIndex: 'asc' },\n  })\n  const existingIndexes = new Set(existing.map((item) => item.imageIndex))\n  const toCreate: Array<{ locationId: string; imageIndex: number; description: string }> = []\n\n  for (let imageIndex = 0; imageIndex < input.count; imageIndex += 1) {\n    if (existingIndexes.has(imageIndex)) continue\n    toCreate.push({\n      locationId: input.locationId,\n      imageIndex,\n      description: input.fallbackDescription,\n    })\n  }\n\n  if (toCreate.length > 0) {\n    await prisma.locationImage.createMany({ data: toCreate })\n  }\n}\n\nexport async function ensureGlobalLocationImageSlots(input: {\n  locationId: string\n  count: number\n  fallbackDescription: string\n}) {\n  const existing = await prisma.globalLocationImage.findMany({\n    where: { locationId: input.locationId },\n    select: { imageIndex: true },\n    orderBy: { imageIndex: 'asc' },\n  })\n  const existingIndexes = new Set(existing.map((item) => item.imageIndex))\n  const toCreate: Array<{ locationId: string; imageIndex: number; description: string }> = []\n\n  for (let imageIndex = 0; imageIndex < input.count; imageIndex += 1) {\n    if (existingIndexes.has(imageIndex)) continue\n    toCreate.push({\n      locationId: input.locationId,\n      imageIndex,\n      description: input.fallbackDescription,\n    })\n  }\n\n  if (toCreate.length > 0) {\n    await prisma.globalLocationImage.createMany({ data: toCreate })\n  }\n}\n"
  },
  {
    "path": "src/lib/image-generation/slot-state.ts",
    "content": "export type ImageSlotLike = {\n  imageUrl: string | null\n}\n\nexport type DisplayableImageSlotLike = ImageSlotLike & {\n  lastError?: { code: string; message: string } | null\n  imageErrorMessage?: string | null\n}\n\nexport type ImageSlotPhase =\n  | 'idle-empty'\n  | 'idle-filled'\n  | 'generating'\n  | 'regenerating'\n\nexport function countGeneratedImageSlots<T extends ImageSlotLike>(slots: readonly T[]): number {\n  return slots.reduce((count, slot) => (slot.imageUrl ? count + 1 : count), 0)\n}\n\nfunction hasImageSlotError(slot: DisplayableImageSlotLike): boolean {\n  return Boolean(slot.lastError || slot.imageErrorMessage)\n}\n\nexport function resolveDisplayImageSlots<T extends DisplayableImageSlotLike>(\n  slots: readonly T[],\n  input: {\n    hasRunningTask: boolean\n    requestedCount: number\n  },\n): T[] {\n  if (input.hasRunningTask) {\n    const visibleCount = Math.min(slots.length, Math.max(countGeneratedImageSlots(slots), input.requestedCount))\n    return slots.slice(0, visibleCount)\n  }\n\n  return slots.filter((slot) => slot.imageUrl || hasImageSlotError(slot))\n}\n\nexport function resolveImageSlotPhase(slot: ImageSlotLike, isRunning: boolean): ImageSlotPhase {\n  if (isRunning) {\n    return slot.imageUrl ? 'regenerating' : 'generating'\n  }\n  return slot.imageUrl ? 'idle-filled' : 'idle-empty'\n}\n\nexport function resolveGroupedImageSlotPhase(\n  slot: ImageSlotLike,\n  input: {\n    isGroupRunning: boolean\n    isSlotRunning: boolean\n    hasPendingEmptySlots: boolean\n  },\n): ImageSlotPhase {\n  if (input.isSlotRunning) {\n    return slot.imageUrl ? 'regenerating' : 'generating'\n  }\n  if (input.isGroupRunning) {\n    if (!slot.imageUrl) return 'generating'\n    if (input.hasPendingEmptySlots) return 'idle-filled'\n    return 'regenerating'\n  }\n  return slot.imageUrl ? 'idle-filled' : 'idle-empty'\n}\n\ninterface ShowSlotGridInput {\n  totalSlotCount: number\n  generatedCount: number\n  hasRunningTask: boolean\n  hasAnyError: boolean\n}\n\nexport function shouldShowImageSlotGrid(input: ShowSlotGridInput): boolean {\n  if (input.totalSlotCount <= 1) return false\n  if (input.hasRunningTask) return true\n  if (input.generatedCount > 0) return true\n  if (input.hasAnyError) return true\n  return false\n}\n"
  },
  {
    "path": "src/lib/image-generation/use-image-generation-count.ts",
    "content": "'use client'\n\nimport { useCallback, useState } from 'react'\nimport {\n  getImageGenerationCount,\n  setImageGenerationCount,\n} from './count-preference'\nimport type { ImageGenerationCountScope } from './count'\n\nexport function useImageGenerationCount(scope: ImageGenerationCountScope) {\n  const [count, setCountState] = useState<number>(() => getImageGenerationCount(scope))\n\n  const updateCount = useCallback((value: number) => {\n    const normalized = setImageGenerationCount(scope, value)\n    setCountState(normalized)\n    return normalized\n  }, [scope])\n\n  return {\n    count,\n    setCount: updateCount,\n  }\n}\n"
  },
  {
    "path": "src/lib/image-label.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\n/**\n * 图片黑边标签处理工具\n * 用于给图片添加/更新顶部的黑边文字标签\n */\n\nimport sharp from 'sharp'\nimport { uploadObject, getSignedUrl, generateUniqueKey, toFetchableUrl } from '@/lib/storage'\nimport { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { initializeFonts, createLabelSVG } from '@/lib/fonts'\n\n/**\n * 更新图片的黑边标签（裁剪旧标签 + 添加新标签）\n * \n * @param imageUrl - 原始图片 URL 或 COS key\n * @param newLabelText - 新的标签文本\n * @param options - 可选配置\n * @returns 更新后的 COS key\n */\nexport async function updateImageLabel(\n    imageUrl: string,\n    newLabelText: string,\n    options?: {\n        /** 是否生成新的 key（默认覆盖原 key） */\n        generateNewKey?: boolean\n        /** 新 key 的前缀（仅当 generateNewKey=true 时有效） */\n        keyPrefix?: string\n    }\n): Promise<string> {\n    await initializeFonts()\n\n    const originalKey = await resolveStorageKeyFromMediaValue(imageUrl)\n    if (!originalKey) {\n        throw new Error(`无法归一化媒体 key: ${imageUrl}`)\n    }\n    const signedUrl = getSignedUrl(originalKey, 3600)\n\n    // 下载图片\n    const response = await fetch(toFetchableUrl(signedUrl))\n    if (!response.ok) {\n        throw new Error(`Failed to download image: ${response.status}`)\n    }\n    const buffer = Buffer.from(await response.arrayBuffer())\n\n    // 获取图片元数据\n    const meta = await sharp(buffer).metadata()\n    const w = meta.width || 2160\n    const h = meta.height || 2160\n\n    // 计算标签条高度（与生成时一致：高度的 4%）\n    const fontSize = Math.floor(h * 0.04)\n    const pad = Math.floor(fontSize * 0.5)\n    const barH = fontSize + pad * 2\n\n    // 裁剪掉顶部的旧标签条\n    const croppedBuffer = await sharp(buffer)\n        .extract({ left: 0, top: barH, width: w, height: h - barH })\n        .toBuffer()\n\n    // 创建新的 SVG 标签条\n    const svg = await createLabelSVG(w, barH, fontSize, pad, newLabelText)\n\n    // 添加新标签条到图片顶部\n    const processed = await sharp(croppedBuffer)\n        .extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })\n        .composite([{ input: svg, top: 0, left: 0 }])\n        .jpeg({ quality: 90, mozjpeg: true })\n        .toBuffer()\n\n    // 决定使用原始 key 还是生成新 key\n    const finalKey = options?.generateNewKey\n        ? generateUniqueKey(options.keyPrefix || 'labeled-image', 'jpg')\n        : originalKey\n\n    await uploadObject(processed, finalKey)\n    return finalKey\n}\n\n/**\n * 批量更新角色形象的标签\n * 用于从资产中心复制角色到项目时更新标签\n */\nexport async function updateCharacterAppearanceLabels(\n    appearances: Array<{\n        imageUrl: string | null\n        imageUrls: string\n        changeReason: string\n    }>,\n    characterName: string\n): Promise<Array<{ imageUrl: string | null; imageUrls: string }>> {\n    const results: Array<{ imageUrl: string | null; imageUrls: string }> = []\n\n    for (const appearance of appearances) {\n        try {\n            // 获取图片 URLs\n            let imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'appearance.imageUrls')\n            if (imageUrls.length === 0 && appearance.imageUrl) {\n                imageUrls = [appearance.imageUrl]\n            }\n\n            if (imageUrls.length === 0) {\n                results.push({ imageUrl: null, imageUrls: encodeImageUrls([]) })\n                continue\n            }\n\n            // 更新每张图片的标签\n            const newLabelText = `${characterName} - ${appearance.changeReason}`\n            const newImageUrls: string[] = await Promise.all(\n                imageUrls.map(async (url) => {\n                    if (!url) return ''\n                    try {\n                        // 生成新的 key，避免覆盖资产中心的原图\n                        return await updateImageLabel(url, newLabelText, {\n                            generateNewKey: true,\n                            keyPrefix: `project-char-copy`\n                        })\n                    } catch (e) {\n                        _ulogError(`Failed to update label for image:`, e)\n                        return url // 失败时保留原 URL\n                    }\n                })\n            )\n\n            const firstUrl = newImageUrls.find((u) => !!u) || null\n            results.push({\n                imageUrl: firstUrl,\n                imageUrls: encodeImageUrls(newImageUrls)\n            })\n        } catch (e) {\n            _ulogError('Failed to update appearance labels:', e)\n            results.push({ imageUrl: appearance.imageUrl, imageUrls: appearance.imageUrls })\n        }\n    }\n\n    return results\n}\n\n/**\n * 批量更新场景图片的标签\n * 用于从资产中心复制场景到项目时更新标签\n */\nexport async function updateLocationImageLabels(\n    images: Array<{\n        imageUrl: string | null\n    }>,\n    locationName: string\n): Promise<Array<{ imageUrl: string | null }>> {\n    const results: Array<{ imageUrl: string | null }> = []\n\n    for (const image of images) {\n        if (!image.imageUrl) {\n            results.push({ imageUrl: null })\n            continue\n        }\n\n        try {\n            // 生成新的 key，避免覆盖资产中心的原图\n            const newImageUrl = await updateImageLabel(image.imageUrl, locationName, {\n                generateNewKey: true,\n                keyPrefix: `project-loc-copy`\n            })\n            results.push({ imageUrl: newImageUrl })\n        } catch (e) {\n            _ulogError('Failed to update location image label:', e)\n            results.push({ imageUrl: image.imageUrl })\n        }\n    }\n\n    return results\n}\n"
  },
  {
    "path": "src/lib/json-repair.ts",
    "content": "import { jsonrepair } from 'jsonrepair'\n\n/**\n * Strip markdown code fences (```json ... ```) from LLM output.\n */\nfunction stripMarkdownFence(input: string): string {\n    return input\n        .replace(/^```json\\s*/i, '')\n        .replace(/^```\\s*/, '')\n        .replace(/\\s*```$/g, '')\n        .replace(/```json\\s*/gi, '')\n        .replace(/```\\s*/g, '')\n        .trim()\n}\n/**\n * Try to extract a JSON object or array substring from mixed text.\n * Returns the extracted substring, or the original input if no clear\n * JSON boundary is found.\n */\nfunction extractJsonSubstring(input: string): string {\n    const firstBrace = input.indexOf('{')\n    const firstBracket = input.indexOf('[')\n    if (firstBrace === -1 && firstBracket === -1) return input\n\n    // Pick whichever delimiter comes first\n    const isObject = firstBracket === -1 || (firstBrace !== -1 && firstBrace < firstBracket)\n    const openChar = isObject ? '{' : '['\n    const closeChar = isObject ? '}' : ']'\n    const start = isObject ? firstBrace : firstBracket\n\n    // Walk forward to find the matching close bracket\n    let depth = 0\n    let inString = false\n    let escaped = false\n    for (let i = start; i < input.length; i++) {\n        const ch = input[i]\n        if (escaped) { escaped = false; continue }\n        if (ch === '\\\\') { escaped = true; continue }\n        if (ch === '\"') { inString = !inString; continue }\n        if (inString) continue\n        if (ch === openChar) depth++\n        else if (ch === closeChar) {\n            depth--\n            if (depth === 0) {\n                return input.slice(start, i + 1)\n            }\n        }\n    }\n    return input\n}\n\n/**\n * Safely parse JSON text from LLM output.\n * First attempts a direct JSON.parse; on failure, tries to extract a JSON\n * substring and uses jsonrepair to fix common LLM issues (unescaped quotes,\n * control characters, trailing commas, single quotes, etc.) before re-parsing.\n */\nexport function safeParseJson(input: string): unknown {\n    const cleaned = stripMarkdownFence(input.trim())\n    // Fast path: direct parse\n    try {\n        return JSON.parse(cleaned)\n    } catch { /* continue to repair */ }\n\n    // Try extracting a JSON substring first (handles LLM explanatory text)\n    const extracted = extractJsonSubstring(cleaned)\n    try {\n        return JSON.parse(extracted)\n    } catch { /* continue to repair */ }\n\n    // Last resort: jsonrepair on the extracted substring\n    return JSON.parse(jsonrepair(extracted))\n}\n\n/**\n * Parse LLM output as a JSON object.\n * Throws if the result is not a plain object.\n */\nexport function safeParseJsonObject(input: string): Record<string, unknown> {\n    const result = safeParseJson(input)\n    if (result && typeof result === 'object' && !Array.isArray(result)) {\n        return result as Record<string, unknown>\n    }\n    throw new Error('Expected JSON object from LLM output')\n}\n\n/**\n * Parse LLM output as a JSON array of objects.\n * Also handles the case where the LLM wraps the array inside an object.\n */\nexport function safeParseJsonArray(\n    input: string,\n    fallbackKey?: string,\n): Record<string, unknown>[] {\n    const result = safeParseJson(input)\n\n    if (Array.isArray(result)) {\n        return result.filter(\n            (item): item is Record<string, unknown> => !!item && typeof item === 'object',\n        )\n    }\n\n    // LLM sometimes wraps the array in an object like { \"clips\": [...] }\n    if (result && typeof result === 'object') {\n        const obj = result as Record<string, unknown>\n        // Try the explicit fallback key first, then common wrapper keys\n        const keys = fallbackKey ? [fallbackKey] : Object.keys(obj)\n        for (const key of keys) {\n            const value = obj[key]\n            if (Array.isArray(value)) {\n                return value.filter(\n                    (item): item is Record<string, unknown> => !!item && typeof item === 'object',\n                )\n            }\n        }\n    }\n\n    throw new Error('Expected JSON array from LLM output')\n}\n"
  },
  {
    "path": "src/lib/lipsync/index.ts",
    "content": "import { logError as _ulogError, logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { getProviderKey, resolveModelSelectionOrSingle } from '@/lib/api-config'\nimport { preprocessLipSyncParams, type LipSyncProviderKey } from '@/lib/lipsync/preprocess'\nimport { submitBailianLipSync } from '@/lib/lipsync/providers/bailian'\nimport { submitFalLipSync } from '@/lib/lipsync/providers/fal'\nimport { submitViduLipSync } from '@/lib/lipsync/providers/vidu'\nimport type { LipSyncParams, LipSyncResult, LipSyncSubmitContext } from '@/lib/lipsync/types'\n\nfunction createSubmitContext(\n  userId: string,\n  selection: { provider: string; modelId: string; modelKey: string },\n): LipSyncSubmitContext {\n  return {\n    userId,\n    providerId: selection.provider,\n    modelId: selection.modelId,\n    modelKey: selection.modelKey,\n  }\n}\n\nfunction resolveProviderKey(value: string): LipSyncProviderKey {\n  const providerKey = value.toLowerCase()\n  if (providerKey === 'fal' || providerKey === 'vidu' || providerKey === 'bailian') {\n    return providerKey\n  }\n  throw new Error(`LIPSYNC_PROVIDER_UNSUPPORTED: ${value}`)\n}\n\nexport async function generateLipSync(\n  params: LipSyncParams,\n  userId: string,\n  modelKey?: string,\n): Promise<LipSyncResult> {\n  _ulogInfo('[LipSync Async] 开始提交口型同步任务')\n\n  try {\n    const selection = await resolveModelSelectionOrSingle(userId, modelKey, 'lipsync')\n    const context = createSubmitContext(userId, selection)\n    const providerKey = resolveProviderKey(getProviderKey(selection.provider))\n    const { params: preprocessedParams } = await preprocessLipSyncParams(params, { providerKey })\n\n    if (providerKey === 'fal') {\n      const result = await submitFalLipSync(preprocessedParams, context)\n      _ulogInfo(`[LipSync Async] FAL 任务已提交: ${result.requestId}`)\n      return result\n    }\n\n    if (providerKey === 'vidu') {\n      const result = await submitViduLipSync(preprocessedParams, context)\n      _ulogInfo(`[LipSync Async] Vidu 任务已提交: ${result.requestId}`)\n      return result\n    }\n\n    if (providerKey === 'bailian') {\n      const result = await submitBailianLipSync(preprocessedParams, context)\n      _ulogInfo(`[LipSync Async] Bailian 任务已提交: ${result.requestId}`)\n      return result\n    }\n\n    throw new Error(`LIPSYNC_PROVIDER_UNSUPPORTED: ${selection.provider}`)\n  } catch (error: unknown) {\n    const errorObject =\n      typeof error === 'object' && error !== null\n        ? (error as { message?: unknown; body?: unknown })\n        : null\n    _ulogError('[LipSync Async] 错误:', error)\n    let errorDetails = typeof errorObject?.message === 'string' ? errorObject.message : '未知错误'\n    const body = (errorObject?.body && typeof errorObject.body === 'object')\n      ? (errorObject.body as { detail?: unknown })\n      : null\n    if (body) {\n      _ulogError('[LipSync Async] 错误详情:', JSON.stringify(body, null, 2))\n      if (body.detail) {\n        errorDetails = typeof body.detail === 'string'\n          ? body.detail\n          : JSON.stringify(body.detail)\n      }\n    }\n    throw new Error(`口型同步任务提交失败: ${errorDetails}`)\n  }\n}\n\nexport type { LipSyncParams, LipSyncResult } from '@/lib/lipsync/types'\n"
  },
  {
    "path": "src/lib/lipsync/preprocess.ts",
    "content": "import { randomUUID } from 'node:crypto'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { normalizeToOriginalMediaUrl } from '@/lib/media/outbound-image'\nimport { toFetchableUrl } from '@/lib/storage/utils'\nimport type { LipSyncParams } from '@/lib/lipsync/types'\n\nconst LIPSYNC_MIN_AUDIO_DURATION_MS = 2000\n\nexport type LipSyncProviderKey = 'fal' | 'vidu' | 'bailian'\n\ninterface LoadedBinary {\n  buffer: Buffer\n  mimeType: string\n}\n\ninterface WavInfo {\n  byteRate: number\n  blockAlign: number\n  dataSize: number\n  dataOffset: number\n}\n\ninterface Mp4Box {\n  start: number\n  end: number\n  type: string\n  headerSize: number\n}\n\nexport interface LipSyncPreprocessContext {\n  providerKey: LipSyncProviderKey\n}\n\nexport interface LipSyncPreprocessResult {\n  params: LipSyncParams\n  paddedAudio: boolean\n  trimmedAudio: boolean\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction normalizeDurationMs(value: number | null | undefined): number | null {\n  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {\n    return null\n  }\n  return Math.round(value)\n}\n\nfunction parseDataUrl(input: string): LoadedBinary {\n  const marker = input.indexOf(',')\n  if (marker <= 5) {\n    throw new Error('LIPSYNC_AUDIO_DATA_URL_INVALID')\n  }\n  const header = input.slice(5, marker)\n  const payload = input.slice(marker + 1)\n  if (!header.includes(';base64')) {\n    throw new Error('LIPSYNC_AUDIO_DATA_URL_BASE64_REQUIRED')\n  }\n  const contentTypeRaw = header.split(';')[0]\n  const mimeType = readTrimmedString(contentTypeRaw) || 'application/octet-stream'\n  return {\n    mimeType,\n    buffer: Buffer.from(payload, 'base64'),\n  }\n}\n\nasync function loadBinaryFromInput(input: string): Promise<LoadedBinary> {\n  const trimmed = readTrimmedString(input)\n  if (!trimmed) {\n    throw new Error('LIPSYNC_INPUT_EMPTY')\n  }\n\n  if (trimmed.startsWith('data:')) {\n    return parseDataUrl(trimmed)\n  }\n\n  const normalizedUrl = await normalizeToOriginalMediaUrl(trimmed)\n  if (normalizedUrl.startsWith('data:')) {\n    return parseDataUrl(normalizedUrl)\n  }\n\n  const fetchUrl = toFetchableUrl(normalizedUrl)\n  const response = await fetch(fetchUrl)\n  if (!response.ok) {\n    throw new Error(`LIPSYNC_INPUT_FETCH_FAILED(${response.status})`)\n  }\n  const mimeType = readTrimmedString(response.headers.get('content-type')) || 'application/octet-stream'\n  return {\n    mimeType,\n    buffer: Buffer.from(await response.arrayBuffer()),\n  }\n}\n\nfunction parseWavInfo(buffer: Buffer): WavInfo | null {\n  if (buffer.length < 44) return null\n  if (buffer.subarray(0, 4).toString('ascii') !== 'RIFF') return null\n  if (buffer.subarray(8, 12).toString('ascii') !== 'WAVE') return null\n\n  let offset = 12\n  let byteRate = 0\n  let blockAlign = 0\n  let dataSize = 0\n  let dataOffset = 0\n\n  while (offset + 8 <= buffer.length) {\n    const chunkId = buffer.subarray(offset, offset + 4).toString('ascii')\n    const chunkSize = buffer.readUInt32LE(offset + 4)\n    const chunkStart = offset + 8\n    const chunkEnd = chunkStart + chunkSize\n    if (chunkEnd > buffer.length) return null\n\n    if (chunkId === 'fmt ') {\n      if (chunkSize < 16) return null\n      byteRate = buffer.readUInt32LE(chunkStart + 8)\n      blockAlign = buffer.readUInt16LE(chunkStart + 12)\n    } else if (chunkId === 'data') {\n      dataSize = chunkSize\n      dataOffset = chunkStart\n      break\n    }\n\n    offset = chunkEnd + (chunkSize % 2)\n  }\n\n  if (byteRate <= 0 || blockAlign <= 0 || dataSize <= 0 || dataOffset <= 0) {\n    return null\n  }\n\n  return {\n    byteRate,\n    blockAlign,\n    dataSize,\n    dataOffset,\n  }\n}\n\nfunction getWavDurationMs(buffer: Buffer): number | null {\n  const info = parseWavInfo(buffer)\n  if (!info) return null\n  return Math.round((info.dataSize / info.byteRate) * 1000)\n}\n\nfunction toBlockAlignedByteLength(byteLength: number, blockAlign: number): number {\n  if (blockAlign <= 1) return byteLength\n  return Math.floor(byteLength / blockAlign) * blockAlign\n}\n\nfunction padWavToMinDuration(buffer: Buffer, targetDurationMs: number): Buffer {\n  const info = parseWavInfo(buffer)\n  if (!info) {\n    throw new Error('LIPSYNC_AUDIO_WAV_PARSE_FAILED')\n  }\n\n  const currentDurationMs = Math.round((info.dataSize / info.byteRate) * 1000)\n  if (currentDurationMs >= targetDurationMs) {\n    return buffer\n  }\n\n  const targetBytesRaw = Math.ceil((targetDurationMs / 1000) * info.byteRate)\n  const targetBytes = toBlockAlignedByteLength(targetBytesRaw, info.blockAlign)\n  const additionalBytes = targetBytes - info.dataSize\n  if (additionalBytes <= 0) return buffer\n\n  const header = buffer.subarray(0, info.dataOffset)\n  const originalData = buffer.subarray(info.dataOffset, info.dataOffset + info.dataSize)\n  const silenceData = Buffer.alloc(additionalBytes, 0)\n  const merged = Buffer.concat([header, originalData, silenceData])\n\n  merged.writeUInt32LE(merged.length - 8, 4)\n  let offset = 12\n  while (offset + 8 <= merged.length) {\n    const chunkId = merged.subarray(offset, offset + 4).toString('ascii')\n    const chunkSize = merged.readUInt32LE(offset + 4)\n    if (chunkId === 'data') {\n      merged.writeUInt32LE(info.dataSize + additionalBytes, offset + 4)\n      break\n    }\n    offset = offset + 8 + chunkSize + (chunkSize % 2)\n  }\n\n  return merged\n}\n\nfunction trimWavToDuration(buffer: Buffer, targetDurationMs: number): Buffer {\n  const info = parseWavInfo(buffer)\n  if (!info) {\n    throw new Error('LIPSYNC_AUDIO_WAV_PARSE_FAILED')\n  }\n\n  const currentDurationMs = Math.round((info.dataSize / info.byteRate) * 1000)\n  if (currentDurationMs <= targetDurationMs) {\n    return buffer\n  }\n\n  const targetBytesRaw = Math.floor((targetDurationMs / 1000) * info.byteRate)\n  const targetBytes = toBlockAlignedByteLength(Math.max(targetBytesRaw, info.blockAlign), info.blockAlign)\n  const clippedBytes = Math.min(targetBytes, info.dataSize)\n\n  const header = buffer.subarray(0, info.dataOffset)\n  const clippedData = buffer.subarray(info.dataOffset, info.dataOffset + clippedBytes)\n  const merged = Buffer.concat([header, clippedData])\n\n  merged.writeUInt32LE(merged.length - 8, 4)\n  let offset = 12\n  while (offset + 8 <= merged.length) {\n    const chunkId = merged.subarray(offset, offset + 4).toString('ascii')\n    const chunkSize = merged.readUInt32LE(offset + 4)\n    if (chunkId === 'data') {\n      merged.writeUInt32LE(clippedBytes, offset + 4)\n      break\n    }\n    offset = offset + 8 + chunkSize + (chunkSize % 2)\n  }\n\n  return merged\n}\n\nfunction readUint64BE(buffer: Buffer, offset: number): number {\n  const high = buffer.readUInt32BE(offset)\n  const low = buffer.readUInt32BE(offset + 4)\n  return high * 2 ** 32 + low\n}\n\nfunction readMp4Box(buffer: Buffer, offset: number, limit: number): Mp4Box | null {\n  if (offset + 8 > limit) return null\n  const size32 = buffer.readUInt32BE(offset)\n  const type = buffer.subarray(offset + 4, offset + 8).toString('ascii')\n  if (!type) return null\n\n  let headerSize = 8\n  let size = size32\n  if (size32 === 1) {\n    if (offset + 16 > limit) return null\n    size = readUint64BE(buffer, offset + 8)\n    headerSize = 16\n  } else if (size32 === 0) {\n    size = limit - offset\n  }\n\n  if (size < headerSize || offset + size > limit) return null\n\n  return {\n    start: offset,\n    end: offset + size,\n    type,\n    headerSize,\n  }\n}\n\nfunction parseMp4DurationMs(buffer: Buffer): number {\n  const limit = buffer.length\n  let offset = 0\n  while (offset + 8 <= limit) {\n    const box = readMp4Box(buffer, offset, limit)\n    if (!box) break\n    if (box.type === 'moov') {\n      let innerOffset = box.start + box.headerSize\n      while (innerOffset + 8 <= box.end) {\n        const inner = readMp4Box(buffer, innerOffset, box.end)\n        if (!inner) break\n        if (inner.type === 'mvhd') {\n          const contentOffset = inner.start + inner.headerSize\n          if (contentOffset + 1 > inner.end) {\n            throw new Error('LIPSYNC_VIDEO_DURATION_PARSE_FAILED')\n          }\n          const version = buffer.readUInt8(contentOffset)\n          if (version === 0) {\n            if (contentOffset + 20 > inner.end) {\n              throw new Error('LIPSYNC_VIDEO_DURATION_PARSE_FAILED')\n            }\n            const timescale = buffer.readUInt32BE(contentOffset + 12)\n            const duration = buffer.readUInt32BE(contentOffset + 16)\n            if (timescale <= 0 || duration <= 0) {\n              throw new Error('LIPSYNC_VIDEO_DURATION_PARSE_FAILED')\n            }\n            return Math.round((duration / timescale) * 1000)\n          }\n          if (version === 1) {\n            if (contentOffset + 32 > inner.end) {\n              throw new Error('LIPSYNC_VIDEO_DURATION_PARSE_FAILED')\n            }\n            const timescale = buffer.readUInt32BE(contentOffset + 20)\n            const duration = readUint64BE(buffer, contentOffset + 24)\n            if (timescale <= 0 || duration <= 0) {\n              throw new Error('LIPSYNC_VIDEO_DURATION_PARSE_FAILED')\n            }\n            return Math.round((duration / timescale) * 1000)\n          }\n          throw new Error('LIPSYNC_VIDEO_DURATION_PARSE_FAILED')\n        }\n        innerOffset = inner.end\n      }\n      throw new Error('LIPSYNC_VIDEO_DURATION_PARSE_FAILED')\n    }\n    offset = box.end\n  }\n  throw new Error('LIPSYNC_VIDEO_DURATION_PARSE_FAILED')\n}\n\nasync function resolveVideoDurationMs(params: LipSyncParams): Promise<number | null> {\n  const knownDuration = normalizeDurationMs(params.videoDurationMs)\n  if (knownDuration) return knownDuration\n  const videoBinary = await loadBinaryFromInput(params.videoUrl)\n  return parseMp4DurationMs(videoBinary.buffer)\n}\n\nfunction toAudioDataUrl(buffer: Buffer): string {\n  return `data:audio/wav;base64,${buffer.toString('base64')}`\n}\n\nasync function toProviderAudioInput(\n  providerKey: LipSyncProviderKey,\n  buffer: Buffer,\n): Promise<string> {\n  if (providerKey === 'vidu') {\n    const { uploadObject, getSignedUrl } = await import('@/lib/storage')\n    const storageKey = `voice/temp/lip-sync-preprocessed/${randomUUID()}.wav`\n    await uploadObject(buffer, storageKey, 1, 'audio/wav')\n    return toFetchableUrl(getSignedUrl(storageKey, 7200))\n  }\n\n  return toAudioDataUrl(buffer)\n}\n\nexport async function preprocessLipSyncParams(\n  params: LipSyncParams,\n  context: LipSyncPreprocessContext,\n): Promise<LipSyncPreprocessResult> {\n  const inputAudioDurationMs = normalizeDurationMs(params.audioDurationMs)\n  const videoDurationMs = await resolveVideoDurationMs(params)\n  let audioDurationMs = inputAudioDurationMs\n\n  const needsDurationProbe = audioDurationMs === null\n  const shouldPadByKnown = audioDurationMs !== null && audioDurationMs < LIPSYNC_MIN_AUDIO_DURATION_MS\n  const shouldTrimByKnown = audioDurationMs !== null && videoDurationMs !== null && audioDurationMs > videoDurationMs\n\n  if (!needsDurationProbe && !shouldPadByKnown && !shouldTrimByKnown) {\n    return {\n      params: {\n        ...params,\n        videoDurationMs: videoDurationMs ?? params.videoDurationMs,\n      },\n      paddedAudio: false,\n      trimmedAudio: false,\n    }\n  }\n\n  const audioBinary = await loadBinaryFromInput(params.audioUrl)\n  if (!audioBinary.mimeType.includes('wav') && parseWavInfo(audioBinary.buffer) === null) {\n    throw new Error('LIPSYNC_AUDIO_PREPROCESS_WAV_REQUIRED')\n  }\n\n  const parsedAudioDuration = getWavDurationMs(audioBinary.buffer)\n  if (audioDurationMs === null) {\n    if (parsedAudioDuration === null) {\n      throw new Error('LIPSYNC_AUDIO_DURATION_PARSE_FAILED')\n    }\n    audioDurationMs = parsedAudioDuration\n  }\n\n  let processedAudio = audioBinary.buffer\n  let paddedAudio = false\n  let trimmedAudio = false\n\n  if (audioDurationMs < LIPSYNC_MIN_AUDIO_DURATION_MS) {\n    processedAudio = padWavToMinDuration(processedAudio, LIPSYNC_MIN_AUDIO_DURATION_MS)\n    audioDurationMs = getWavDurationMs(processedAudio) ?? LIPSYNC_MIN_AUDIO_DURATION_MS\n    paddedAudio = true\n  }\n\n  if (videoDurationMs !== null && audioDurationMs > videoDurationMs) {\n    processedAudio = trimWavToDuration(processedAudio, videoDurationMs)\n    audioDurationMs = getWavDurationMs(processedAudio) ?? videoDurationMs\n    trimmedAudio = true\n  }\n\n  if (!paddedAudio && !trimmedAudio) {\n    return {\n      params: {\n        ...params,\n        audioDurationMs,\n        videoDurationMs,\n      },\n      paddedAudio: false,\n      trimmedAudio: false,\n    }\n  }\n\n  const providerAudioInput = await toProviderAudioInput(context.providerKey, processedAudio)\n\n  _ulogInfo(`[LipSync Preprocess] provider=${context.providerKey} padded=${paddedAudio} trimmed=${trimmedAudio} audioDurationMs=${audioDurationMs} videoDurationMs=${videoDurationMs ?? 'unknown'}`)\n\n  return {\n    params: {\n      ...params,\n      audioUrl: providerAudioInput,\n      audioDurationMs,\n      videoDurationMs,\n    },\n    paddedAudio,\n    trimmedAudio,\n  }\n}\n\nexport const LIPSYNC_PREPROCESS_AUDIO_MIN_MS = LIPSYNC_MIN_AUDIO_DURATION_MS\n"
  },
  {
    "path": "src/lib/lipsync/providers/bailian.ts",
    "content": "import { randomUUID } from 'node:crypto'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { normalizeToOriginalMediaUrl } from '@/lib/media/outbound-image'\nimport { toFetchableUrl } from '@/lib/storage/utils'\nimport type { LipSyncParams, LipSyncResult, LipSyncSubmitContext } from '@/lib/lipsync/types'\n\nconst BAILIAN_LIPSYNC_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis'\nconst BAILIAN_UPLOAD_POLICY_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/uploads'\nconst BAILIAN_OSS_RESOLVE_VALUE = 'enable'\n\ntype LipSyncInputType = 'video' | 'audio'\n\ninterface LipSyncInputAsset {\n  fileName: string\n  contentType: string\n  buffer: Buffer\n}\n\ninterface BailianUploadPolicyData {\n  upload_host?: string\n  upload_dir?: string\n  oss_access_key_id?: string\n  policy?: string\n  signature?: string\n  x_oss_object_acl?: string\n  x_oss_forbid_overwrite?: string\n  x_oss_security_token?: string\n  security_token?: string\n}\n\ninterface BailianUploadPolicyResponse {\n  code?: string\n  message?: string\n  data?: BailianUploadPolicyData\n}\n\ninterface BailianUploadPolicy {\n  uploadHost: string\n  uploadDir: string\n  accessKeyId: string\n  policy: string\n  signature: string\n  objectAcl?: string\n  forbidOverwrite?: string\n  securityToken?: string\n}\n\ninterface BailianLipSyncSubmitResponse {\n  code?: string\n  message?: string\n  output?: {\n    task_id?: string\n    task_status?: string\n  }\n}\n\nconst EXT_BY_CONTENT_TYPE: Record<string, string> = {\n  'video/mp4': 'mp4',\n  'video/quicktime': 'mov',\n  'video/x-msvideo': 'avi',\n  'video/webm': 'webm',\n  'audio/wav': 'wav',\n  'audio/x-wav': 'wav',\n  'audio/mpeg': 'mp3',\n  'audio/mp3': 'mp3',\n  'audio/aac': 'aac',\n  'audio/mp4': 'm4a',\n  'audio/flac': 'flac',\n  'audio/ogg': 'ogg',\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction readContentType(value: string | null | undefined): string {\n  const raw = readTrimmedString(value ?? '')\n  if (!raw) return ''\n  const marker = raw.indexOf(';')\n  return (marker === -1 ? raw : raw.slice(0, marker)).trim().toLowerCase()\n}\n\nfunction parseDataUrl(dataUrl: string): { contentType: string; buffer: Buffer } {\n  const marker = dataUrl.indexOf(',')\n  if (marker <= 5) {\n    throw new Error('BAILIAN_LIPSYNC_DATA_URL_INVALID')\n  }\n  const header = dataUrl.slice(5, marker)\n  const payload = dataUrl.slice(marker + 1)\n  if (!header.includes(';base64')) {\n    throw new Error('BAILIAN_LIPSYNC_DATA_URL_BASE64_REQUIRED')\n  }\n  const contentType = readContentType(header.split(';')[0]) || 'application/octet-stream'\n  try {\n    return {\n      contentType,\n      buffer: Buffer.from(payload, 'base64'),\n    }\n  } catch {\n    throw new Error('BAILIAN_LIPSYNC_DATA_URL_DECODE_FAILED')\n  }\n}\n\nfunction readPathExt(urlLike: string): string {\n  try {\n    const parsed = new URL(urlLike)\n    const match = parsed.pathname.match(/\\.([a-z0-9]+)$/i)\n    return match ? match[1].toLowerCase() : ''\n  } catch {\n    const match = urlLike.match(/\\.([a-z0-9]+)(?:\\?|#|$)/i)\n    return match ? match[1].toLowerCase() : ''\n  }\n}\n\nfunction inferInputFileName(kind: LipSyncInputType, sourceHint: string, contentType: string): string {\n  const extFromPath = readPathExt(sourceHint)\n  if (extFromPath) {\n    return `${kind}-${randomUUID()}.${extFromPath}`\n  }\n  const extFromMime = EXT_BY_CONTENT_TYPE[readContentType(contentType)]\n  if (extFromMime) {\n    return `${kind}-${randomUUID()}.${extFromMime}`\n  }\n  return `${kind}-${randomUUID()}.${kind === 'video' ? 'mp4' : 'wav'}`\n}\n\nasync function resolveLipSyncInputAsset(rawInput: string, kind: LipSyncInputType): Promise<LipSyncInputAsset> {\n  const input = readTrimmedString(rawInput)\n  if (!input) {\n    throw new Error(`BAILIAN_LIPSYNC_${kind.toUpperCase()}_INPUT_REQUIRED`)\n  }\n\n  if (input.startsWith('data:')) {\n    const parsed = parseDataUrl(input)\n    return {\n      fileName: inferInputFileName(kind, input, parsed.contentType),\n      contentType: parsed.contentType,\n      buffer: parsed.buffer,\n    }\n  }\n\n  const normalizedInput = await normalizeToOriginalMediaUrl(input)\n  if (normalizedInput.startsWith('data:')) {\n    const parsed = parseDataUrl(normalizedInput)\n    return {\n      fileName: inferInputFileName(kind, normalizedInput, parsed.contentType),\n      contentType: parsed.contentType,\n      buffer: parsed.buffer,\n    }\n  }\n\n  const fetchUrl = toFetchableUrl(normalizedInput)\n  let response: Response\n  try {\n    response = await fetch(fetchUrl)\n  } catch {\n    throw new Error(`BAILIAN_LIPSYNC_${kind.toUpperCase()}_FETCH_EXCEPTION`)\n  }\n  if (!response.ok) {\n    throw new Error(`BAILIAN_LIPSYNC_${kind.toUpperCase()}_FETCH_FAILED(${response.status})`)\n  }\n\n  const contentType = readContentType(response.headers.get('content-type')) || 'application/octet-stream'\n  return {\n    fileName: inferInputFileName(kind, normalizedInput, contentType),\n    contentType,\n    buffer: Buffer.from(await response.arrayBuffer()),\n  }\n}\n\nasync function parseBailianUploadPolicyResponse(response: Response): Promise<BailianUploadPolicyResponse> {\n  const raw = await response.text()\n  if (!raw) return {}\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!parsed || typeof parsed !== 'object') {\n      throw new Error('BAILIAN_UPLOAD_POLICY_RESPONSE_INVALID')\n    }\n    return parsed as BailianUploadPolicyResponse\n  } catch {\n    throw new Error('BAILIAN_UPLOAD_POLICY_RESPONSE_INVALID_JSON')\n  }\n}\n\nfunction resolveBailianUploadPolicy(data: BailianUploadPolicyResponse): BailianUploadPolicy {\n  const policyData = data.data\n  const uploadHost = readTrimmedString(policyData?.upload_host)\n  const uploadDir = readTrimmedString(policyData?.upload_dir)\n  const accessKeyId = readTrimmedString(policyData?.oss_access_key_id)\n  const policy = readTrimmedString(policyData?.policy)\n  const signature = readTrimmedString(policyData?.signature)\n  if (!uploadHost || !uploadDir || !accessKeyId || !policy || !signature) {\n    throw new Error('BAILIAN_UPLOAD_POLICY_DATA_MISSING')\n  }\n  const objectAcl = readTrimmedString(policyData?.x_oss_object_acl) || undefined\n  const forbidOverwrite = readTrimmedString(policyData?.x_oss_forbid_overwrite) || undefined\n  const securityToken = readTrimmedString(policyData?.x_oss_security_token || policyData?.security_token) || undefined\n  return {\n    uploadHost,\n    uploadDir,\n    accessKeyId,\n    policy,\n    signature,\n    objectAcl,\n    forbidOverwrite,\n    securityToken,\n  }\n}\n\nasync function getBailianUploadPolicy(apiKey: string, modelId: string): Promise<BailianUploadPolicy> {\n  const response = await fetch(\n    `${BAILIAN_UPLOAD_POLICY_ENDPOINT}?action=getPolicy&model=${encodeURIComponent(modelId)}`,\n    {\n      method: 'GET',\n      headers: {\n        Authorization: `Bearer ${apiKey}`,\n        'Content-Type': 'application/json',\n      },\n    },\n  )\n  const data = await parseBailianUploadPolicyResponse(response)\n  if (!response.ok) {\n    const code = readTrimmedString(data.code)\n    const message = readTrimmedString(data.message)\n    throw new Error(`BAILIAN_UPLOAD_POLICY_FAILED(${response.status}): ${code || message || 'unknown error'}`)\n  }\n  return resolveBailianUploadPolicy(data)\n}\n\nasync function uploadToBailianTempStorage(\n  policy: BailianUploadPolicy,\n  asset: LipSyncInputAsset,\n): Promise<string> {\n  const objectKey = `${policy.uploadDir}/${asset.fileName}`\n  const form = new FormData()\n  form.append('OSSAccessKeyId', policy.accessKeyId)\n  form.append('Signature', policy.signature)\n  form.append('policy', policy.policy)\n  if (policy.objectAcl) {\n    form.append('x-oss-object-acl', policy.objectAcl)\n  }\n  if (policy.forbidOverwrite) {\n    form.append('x-oss-forbid-overwrite', policy.forbidOverwrite)\n  }\n  if (policy.securityToken) {\n    form.append('x-oss-security-token', policy.securityToken)\n  }\n  form.append('key', objectKey)\n  form.append('success_action_status', '200')\n  const assetBytes = Uint8Array.from(asset.buffer)\n  form.append('file', new Blob([assetBytes], { type: asset.contentType }), asset.fileName)\n\n  const uploadResponse = await fetch(policy.uploadHost, {\n    method: 'POST',\n    body: form,\n  })\n  if (!uploadResponse.ok) {\n    const errorText = await uploadResponse.text()\n    throw new Error(`BAILIAN_UPLOAD_FILE_FAILED(${uploadResponse.status}): ${errorText || 'unknown error'}`)\n  }\n  return `oss://${objectKey}`\n}\n\nasync function parseBailianLipSyncSubmitResponse(response: Response): Promise<BailianLipSyncSubmitResponse> {\n  const raw = await response.text()\n  if (!raw) return {}\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!parsed || typeof parsed !== 'object') {\n      throw new Error('BAILIAN_LIPSYNC_RESPONSE_INVALID')\n    }\n    return parsed as BailianLipSyncSubmitResponse\n  } catch {\n    throw new Error('BAILIAN_LIPSYNC_RESPONSE_INVALID_JSON')\n  }\n}\n\nexport async function submitBailianLipSync(\n  params: LipSyncParams,\n  context: LipSyncSubmitContext,\n): Promise<LipSyncResult> {\n  const modelId = readTrimmedString(context.modelId)\n  if (!modelId) {\n    throw new Error(`LIPSYNC_ENDPOINT_MISSING: ${context.modelKey}`)\n  }\n\n  const { apiKey } = await getProviderConfig(context.userId, context.providerId)\n  const policy = await getBailianUploadPolicy(apiKey, modelId)\n  const [videoAsset, audioAsset] = await Promise.all([\n    resolveLipSyncInputAsset(params.videoUrl, 'video'),\n    resolveLipSyncInputAsset(params.audioUrl, 'audio'),\n  ])\n  const [videoUrl, audioUrl] = await Promise.all([\n    uploadToBailianTempStorage(policy, videoAsset),\n    uploadToBailianTempStorage(policy, audioAsset),\n  ])\n\n  const response = await fetch(BAILIAN_LIPSYNC_ENDPOINT, {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${apiKey}`,\n      'Content-Type': 'application/json',\n      'X-DashScope-Async': 'enable',\n      'X-DashScope-OssResourceResolve': BAILIAN_OSS_RESOLVE_VALUE,\n    },\n    body: JSON.stringify({\n      model: modelId,\n      input: {\n        video_url: videoUrl,\n        audio_url: audioUrl,\n      },\n    }),\n  })\n\n  const data = await parseBailianLipSyncSubmitResponse(response)\n  if (!response.ok) {\n    const code = readTrimmedString(data.code)\n    const message = readTrimmedString(data.message)\n    throw new Error(`BAILIAN_LIPSYNC_SUBMIT_FAILED(${response.status}): ${code || message || 'unknown error'}`)\n  }\n\n  const taskId = readTrimmedString(data.output?.task_id)\n  if (!taskId) {\n    throw new Error('BAILIAN_LIPSYNC_TASK_ID_MISSING')\n  }\n\n  return {\n    requestId: taskId,\n    externalId: `BAILIAN:VIDEO:${taskId}`,\n    async: true,\n  }\n}\n"
  },
  {
    "path": "src/lib/lipsync/providers/fal.ts",
    "content": "import { submitFalTask } from '@/lib/async-submit'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'\nimport type { LipSyncParams, LipSyncResult, LipSyncSubmitContext } from '@/lib/lipsync/types'\n\nexport async function submitFalLipSync(\n  params: LipSyncParams,\n  context: LipSyncSubmitContext,\n): Promise<LipSyncResult> {\n  const endpoint = context.modelId.trim()\n  if (!endpoint) {\n    throw new Error(`LIPSYNC_ENDPOINT_MISSING: ${context.modelKey}`)\n  }\n\n  const videoDataUrl = params.videoUrl.startsWith('data:')\n    ? params.videoUrl\n    : await normalizeToBase64ForGeneration(params.videoUrl)\n  const audioDataUrl = params.audioUrl.startsWith('data:')\n    ? params.audioUrl\n    : await normalizeToBase64ForGeneration(params.audioUrl)\n\n  const { apiKey } = await getProviderConfig(context.userId, context.providerId)\n  const requestId = await submitFalTask(endpoint, {\n    video_url: videoDataUrl,\n    audio_url: audioDataUrl,\n  }, apiKey)\n\n  return {\n    requestId,\n    externalId: `FAL:VIDEO:${endpoint}:${requestId}`,\n    async: true,\n  }\n}\n"
  },
  {
    "path": "src/lib/lipsync/providers/vidu.ts",
    "content": "import { getProviderConfig } from '@/lib/api-config'\nimport type { LipSyncParams, LipSyncResult, LipSyncSubmitContext } from '@/lib/lipsync/types'\n\ninterface ViduLipSyncSubmitResponse {\n  task_id?: string\n  state?: string\n  err_code?: string\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction toAbsoluteHttpUrl(rawUrl: string): string {\n  const normalized = readTrimmedString(rawUrl)\n  if (!normalized) return ''\n  if (normalized.startsWith('http://') || normalized.startsWith('https://')) {\n    return normalized\n  }\n  if (normalized.startsWith('/')) {\n    const baseUrl = (process.env.NEXTAUTH_URL || 'http://localhost:3000').trim()\n    const trimmedBase = baseUrl.replace(/\\/+$/, '')\n    return `${trimmedBase}${normalized}`\n  }\n  return normalized\n}\n\nfunction isPrivateHost(hostname: string): boolean {\n  const host = hostname.toLowerCase()\n  if (\n    host === 'localhost'\n    || host === '127.0.0.1'\n    || host === '::1'\n    || host.endsWith('.local')\n  ) {\n    return true\n  }\n\n  const parts = host.split('.')\n  if (parts.length !== 4) return false\n  const octets = parts.map((part) => Number.parseInt(part, 10))\n  if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return false\n\n  const [a, b] = octets\n  if (a === 10) return true\n  if (a === 127) return true\n  if (a === 169 && b === 254) return true\n  if (a === 192 && b === 168) return true\n  if (a === 172 && b >= 16 && b <= 31) return true\n  return false\n}\n\nfunction normalizeProviderPullUrl(inputUrl: string, field: 'video_url' | 'audio_url'): string {\n  const absoluteUrl = toAbsoluteHttpUrl(inputUrl)\n  if (!absoluteUrl.startsWith('http://') && !absoluteUrl.startsWith('https://')) {\n    throw new Error(`LIPSYNC_INPUT_URL_INVALID: ${field}`)\n  }\n\n  let parsed: URL\n  try {\n    parsed = new URL(absoluteUrl)\n  } catch {\n    throw new Error(`LIPSYNC_INPUT_URL_INVALID: ${field}`)\n  }\n\n  if (isPrivateHost(parsed.hostname)) {\n    throw new Error(`LIPSYNC_INPUT_URL_NOT_PUBLIC: ${field}`)\n  }\n\n  return absoluteUrl\n}\n\nexport async function submitViduLipSync(\n  params: LipSyncParams,\n  context: LipSyncSubmitContext,\n): Promise<LipSyncResult> {\n  const videoUrl = normalizeProviderPullUrl(params.videoUrl, 'video_url')\n  const audioUrl = normalizeProviderPullUrl(params.audioUrl, 'audio_url')\n  const { apiKey } = await getProviderConfig(context.userId, context.providerId)\n  const response = await fetch('https://api.vidu.cn/ent/v2/lip-sync', {\n    method: 'POST',\n    headers: {\n      Authorization: `Token ${apiKey}`,\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      video_url: videoUrl,\n      audio_url: audioUrl,\n    }),\n  })\n\n  if (!response.ok) {\n    const errorText = await response.text()\n    throw new Error(`VIDU_LIPSYNC_SUBMIT_FAILED: ${response.status} ${errorText}`)\n  }\n\n  const data = (await response.json()) as ViduLipSyncSubmitResponse\n  const taskId = readTrimmedString(data.task_id)\n  if (!taskId) {\n    throw new Error('VIDU_LIPSYNC_TASK_ID_MISSING')\n  }\n  if (data.state === 'failed') {\n    throw new Error(`VIDU_LIPSYNC_SUBMIT_FAILED: ${data.err_code || 'unknown'}`)\n  }\n\n  return {\n    requestId: taskId,\n    externalId: `VIDU:VIDEO:${taskId}`,\n    async: true,\n  }\n}\n"
  },
  {
    "path": "src/lib/lipsync/types.ts",
    "content": "export interface LipSyncResult {\n  videoUrl?: string\n  requestId: string\n  externalId?: string\n  async?: boolean\n}\n\nexport interface LipSyncParams {\n  videoUrl: string\n  audioUrl: string\n  audioDurationMs?: number | null\n  videoDurationMs?: number | null\n}\n\nexport interface LipSyncSubmitContext {\n  userId: string\n  providerId: string\n  modelId: string\n  modelKey: string\n}\n"
  },
  {
    "path": "src/lib/llm/chat-completion.ts",
    "content": "import OpenAI from 'openai'\nimport { generateText, type ModelMessage } from 'ai'\nimport { createOpenAI } from '@ai-sdk/openai'\nimport { GoogleGenAI } from '@google/genai'\nimport {\n  resolveModelGatewayRoute,\n  runOpenAICompatChatCompletion,\n  runOpenAICompatResponsesCompletion,\n} from '@/lib/model-gateway'\nimport {\n  getProviderConfig,\n  getProviderKey,\n} from '../api-config'\nimport { getInternalLLMStreamCallbacks } from '../llm-observe/internal-stream-context'\nimport type { ChatCompletionOptions } from './types'\nimport { extractGoogleParts, extractGoogleUsage, GoogleEmptyResponseError } from './providers/google'\nimport { buildOpenAIChatCompletion } from './providers/openai-compat'\nimport { getCompletionParts } from './completion-parts'\nimport {\n  buildReasoningAwareContent,\n  getConversationMessages,\n  getSystemPrompt,\n  mapReasoningEffort,\n} from './utils'\nimport { shouldUseOpenAIReasoningProviderOptions } from './reasoning-capability'\nimport {\n  _ulogError,\n  _ulogWarn,\n  completionUsageSummary,\n  isRetryableError,\n  llmLogger,\n  logLlmRawInput,\n  logLlmRawOutput,\n  recordCompletionUsage,\n  resolveLlmRuntimeModel,\n} from './runtime-shared'\nimport { completeBailianLlm } from '@/lib/providers/bailian'\nimport { completeSiliconFlowLlm } from '@/lib/providers/siliconflow'\n\nconst OFFICIAL_ONLY_PROVIDER_KEYS = new Set(['bailian', 'siliconflow'])\n\nfunction toRecord(value: unknown): Record<string, unknown> | null {\n  return value && typeof value === 'object' ? (value as Record<string, unknown>) : null\n}\n\nfunction errorMessage(error: unknown): string {\n  if (error instanceof Error) return error.message\n  const record = toRecord(error)\n  if (record && typeof record.message === 'string') return record.message\n  return 'unknown error'\n}\n\n\n\nexport async function chatCompletion(\n  userId: string,\n  model: string | null | undefined,\n  messages: { role: 'user' | 'assistant' | 'system'; content: string }[],\n  options: ChatCompletionOptions = {},\n): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  const internalCallbacks = getInternalLLMStreamCallbacks()\n  if (internalCallbacks && !options.__skipAutoStream) {\n    const { chatCompletionStream } = await import('./chat-stream')\n    return await chatCompletionStream(\n      userId,\n      model,\n      messages,\n      { ...options, __skipAutoStream: true },\n      internalCallbacks,\n    )\n  }\n\n  if (!model) {\n    _ulogError('[LLM] 模型未配置，调用栈:', new Error().stack)\n    throw new Error('ANALYSIS_MODEL_NOT_CONFIGURED: 请先在设置页面配置分析模型')\n  }\n\n  const selection = await resolveLlmRuntimeModel(userId, model)\n  const resolvedModelId = selection.modelId\n  const provider = selection.provider\n  const providerKey = getProviderKey(provider).toLowerCase()\n  const providerConfig = await getProviderConfig(userId, provider)\n  const gatewayRoute = OFFICIAL_ONLY_PROVIDER_KEYS.has(providerKey)\n    ? 'official'\n    : (providerConfig.gatewayRoute || resolveModelGatewayRoute(provider))\n\n  const {\n    temperature = 0.7,\n    reasoning = true,\n    reasoningEffort = 'high',\n    maxRetries = 2,\n  } = options\n  const projectId =\n    typeof options.projectId === 'string' && options.projectId.trim().length > 0\n      ? options.projectId.trim()\n      : undefined\n  logLlmRawInput({\n    userId,\n    projectId,\n    provider: providerKey,\n    modelId: resolvedModelId,\n    modelKey: selection.modelKey,\n    stream: false,\n    reasoning,\n    reasoningEffort,\n    temperature,\n    action: options.action,\n    messages,\n  })\n\n  let lastError: Error | null = null\n\n  for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {\n    const attemptStartedAt = Date.now()\n    try {\n      if (gatewayRoute === 'openai-compat') {\n        // openai-compatible protocol probing only applies to openai-compatible + llm.\n        // gemini-compatible is explicitly excluded and must not enter this branch.\n        if (providerKey !== 'openai-compatible') {\n          throw new Error(`OPENAI_COMPAT_PROVIDER_UNSUPPORTED: ${provider}`)\n        }\n        if (!selection.llmProtocol) {\n          throw new Error(`MODEL_LLM_PROTOCOL_REQUIRED: ${selection.modelKey}`)\n        }\n\n        const completion = selection.llmProtocol === 'responses'\n          ? await runOpenAICompatResponsesCompletion({\n            userId,\n            providerId: provider,\n            modelId: resolvedModelId,\n            messages,\n            temperature,\n          })\n          : await runOpenAICompatChatCompletion({\n            userId,\n            providerId: provider,\n            modelId: resolvedModelId,\n            messages,\n            temperature,\n          })\n        const completionParts = getCompletionParts(completion)\n        const compatEngine = selection.llmProtocol === 'responses'\n          ? 'openai_compat_responses'\n          : 'openai_compat_chat_completions'\n        logLlmRawOutput({\n          userId,\n          projectId,\n          provider: compatEngine,\n          modelId: resolvedModelId,\n          modelKey: selection.modelKey,\n          stream: false,\n          action: options.action,\n          text: completionParts.text,\n          reasoning: completionParts.reasoning,\n          usage: completionUsageSummary(completion),\n        })\n        recordCompletionUsage(resolvedModelId, completion)\n        llmLogger.info({\n          action: 'llm.call.success',\n          message: 'llm call succeeded',\n          provider: compatEngine,\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n            llmProtocol: selection.llmProtocol,\n          },\n        })\n        return completion\n      }\n\n      if (providerKey === 'google' || providerKey === 'gemini-compatible') {\n        const googleAiOptions = providerConfig.baseUrl\n          ? { apiKey: providerConfig.apiKey, httpOptions: { baseUrl: providerConfig.baseUrl } }\n          : { apiKey: providerConfig.apiKey }\n        const ai = new GoogleGenAI(googleAiOptions)\n\n        const systemParts = messages\n          .filter((m) => m.role === 'system')\n          .map((m) => m.content)\n          .filter(Boolean)\n        const contents = messages\n          .filter((m) => m.role !== 'system')\n          .map((m) => ({\n            role: m.role === 'assistant' ? 'model' : 'user',\n            parts: [{ text: m.content }],\n          }))\n\n        const systemInstruction = systemParts.length > 0\n          ? { parts: [{ text: systemParts.join('\\n') }] }\n          : undefined\n        const supportsThinkingLevel = resolvedModelId.startsWith('gemini-3')\n        const thinkingConfig = reasoning && supportsThinkingLevel\n          ? { thinkingLevel: reasoningEffort, includeThoughts: true }\n          : undefined\n\n        const googleRequest = {\n          model: resolvedModelId,\n          contents,\n          config: {\n            temperature,\n            ...(systemInstruction ? { systemInstruction } : {}),\n            ...(thinkingConfig ? { thinkingConfig } : {}),\n          },\n        }\n        const response = await ai.models.generateContent(\n          googleRequest as unknown as Parameters<typeof ai.models.generateContent>[0],\n        )\n\n        const googleParts = extractGoogleParts(response, true)\n        const usage = extractGoogleUsage(response)\n        const completion = buildOpenAIChatCompletion(\n          resolvedModelId,\n          buildReasoningAwareContent(googleParts.text, googleParts.reasoning),\n          usage,\n        )\n        logLlmRawOutput({\n          userId,\n          projectId,\n          provider: providerKey,\n          modelId: resolvedModelId,\n          modelKey: selection.modelKey,\n          stream: false,\n          action: options.action,\n          text: googleParts.text,\n          reasoning: googleParts.reasoning,\n          usage,\n        })\n        recordCompletionUsage(resolvedModelId, completion)\n        llmLogger.info({\n          action: 'llm.call.success',\n          message: 'llm call succeeded',\n          provider: providerKey,\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n          },\n        })\n        return completion\n      }\n\n      if (providerKey === 'bailian') {\n        const completion = await completeBailianLlm({\n          modelId: resolvedModelId,\n          messages,\n          apiKey: providerConfig.apiKey,\n          baseUrl: providerConfig.baseUrl,\n          temperature,\n        })\n        const completionParts = getCompletionParts(completion)\n        logLlmRawOutput({\n          userId,\n          projectId,\n          provider: providerKey,\n          modelId: resolvedModelId,\n          modelKey: selection.modelKey,\n          stream: false,\n          action: options.action,\n          text: completionParts.text,\n          reasoning: completionParts.reasoning,\n          usage: completionUsageSummary(completion),\n        })\n        recordCompletionUsage(resolvedModelId, completion)\n        llmLogger.info({\n          action: 'llm.call.success',\n          message: 'llm call succeeded',\n          provider: providerKey,\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n          },\n        })\n        return completion\n      }\n\n      if (providerKey === 'siliconflow') {\n        const completion = await completeSiliconFlowLlm({\n          modelId: resolvedModelId,\n          messages,\n          apiKey: providerConfig.apiKey,\n          baseUrl: providerConfig.baseUrl,\n          temperature,\n        })\n        const completionParts = getCompletionParts(completion)\n        logLlmRawOutput({\n          userId,\n          projectId,\n          provider: providerKey,\n          modelId: resolvedModelId,\n          modelKey: selection.modelKey,\n          stream: false,\n          action: options.action,\n          text: completionParts.text,\n          reasoning: completionParts.reasoning,\n          usage: completionUsageSummary(completion),\n        })\n        recordCompletionUsage(resolvedModelId, completion)\n        llmLogger.info({\n          action: 'llm.call.success',\n          message: 'llm call succeeded',\n          provider: providerKey,\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n          },\n        })\n        return completion\n      }\n\n\n      if (providerKey === 'ark') {\n        const { arkResponsesCompletion, convertChatMessagesToArkInput, buildArkThinkingParam } = await import('@/lib/ark-llm')\n        const arkThinkingParams = buildArkThinkingParam(resolvedModelId, reasoning)\n\n        const arkResult = await arkResponsesCompletion({\n          apiKey: providerConfig.apiKey,\n          model: resolvedModelId,\n          input: convertChatMessagesToArkInput(messages),\n          thinking: arkThinkingParams.thinking,\n        })\n\n        const completion = buildOpenAIChatCompletion(\n          resolvedModelId,\n          buildReasoningAwareContent(arkResult.text, arkResult.reasoning),\n          arkResult.usage,\n        )\n        logLlmRawOutput({\n          userId,\n          projectId,\n          provider: 'ark',\n          modelId: resolvedModelId,\n          modelKey: selection.modelKey,\n          stream: false,\n          action: options.action,\n          text: arkResult.text,\n          reasoning: arkResult.reasoning,\n          usage: arkResult.usage,\n        })\n        recordCompletionUsage(resolvedModelId, completion)\n        llmLogger.info({\n          action: 'llm.call.success',\n          message: 'llm call succeeded',\n          provider: 'ark',\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n            engine: 'ark_responses',\n          },\n        })\n        return completion\n      }\n\n      if (!providerConfig.baseUrl) {\n        throw new Error(`PROVIDER_BASE_URL_MISSING: ${provider} (llm)`)\n      }\n\n      const isOpenRouter = !!providerConfig.baseUrl?.includes('openrouter')\n      const providerName = isOpenRouter ? 'openrouter' : 'openai_compatible'\n      if (!isOpenRouter) {\n        const aiOpenAI = createOpenAI({\n          baseURL: providerConfig.baseUrl,\n          apiKey: providerConfig.apiKey,\n          name: providerName,\n        })\n        // 只有原生 OpenAI 推理模型才支持 forceReasoning/reasoningEffort\n        // gemini-compatible 等 OAI-compat 提供商传这些参数会导致空响应\n        const isNativeOpenAIReasoning = shouldUseOpenAIReasoningProviderOptions({\n          providerKey,\n          providerApiMode: providerConfig.apiMode,\n          modelId: resolvedModelId,\n        })\n        const aiSdkProviderOptions = reasoning && isNativeOpenAIReasoning\n          ? {\n            openai: {\n              reasoningEffort: mapReasoningEffort(reasoningEffort),\n              forceReasoning: true,\n            },\n          }\n          : undefined\n        const generateParams: Parameters<typeof generateText>[0] = {\n          model: aiOpenAI.chat(resolvedModelId),\n          system: getSystemPrompt(messages),\n          messages: getConversationMessages(messages) as ModelMessage[],\n          // 推理模型不支持 temperature，仅在非推理模式下传递\n          ...(reasoning ? {} : { temperature }),\n          maxRetries,\n          ...(aiSdkProviderOptions ? { providerOptions: aiSdkProviderOptions } : {}),\n        }\n        const aiSdkResult = await generateText(generateParams)\n\n        const usage = aiSdkResult.usage || aiSdkResult.totalUsage\n        const completion = buildOpenAIChatCompletion(\n          resolvedModelId,\n          buildReasoningAwareContent(aiSdkResult.text || '', aiSdkResult.reasoningText || ''),\n          {\n            promptTokens: usage?.inputTokens ?? 0,\n            completionTokens: usage?.outputTokens ?? 0,\n          },\n        )\n        logLlmRawOutput({\n          userId,\n          projectId,\n          provider: providerName,\n          modelId: resolvedModelId,\n          modelKey: selection.modelKey,\n          stream: false,\n          action: options.action,\n          text: aiSdkResult.text || '',\n          reasoning: aiSdkResult.reasoningText || '',\n          usage: {\n            promptTokens: usage?.inputTokens ?? 0,\n            completionTokens: usage?.outputTokens ?? 0,\n          },\n        })\n        recordCompletionUsage(resolvedModelId, completion)\n        llmLogger.info({\n          action: 'llm.call.success',\n          message: 'llm call succeeded',\n          provider: providerName,\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n            engine: 'ai_sdk',\n          },\n        })\n        return completion\n      }\n\n      const client = new OpenAI({\n        baseURL: providerConfig.baseUrl,\n        apiKey: providerConfig.apiKey,\n      })\n\n      const extraParams: Record<string, unknown> = {}\n      if (isOpenRouter && reasoning) {\n        extraParams.reasoning = { effort: reasoningEffort }\n      }\n\n      const completion = await client.chat.completions.create({\n        model: resolvedModelId,\n        messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],\n        temperature,\n        ...extraParams,\n      })\n      const normalizedCompletion = completion as OpenAI.Chat.Completions.ChatCompletion\n      const completionParts = getCompletionParts(normalizedCompletion)\n      logLlmRawOutput({\n        userId,\n        projectId,\n        provider: providerName,\n        modelId: resolvedModelId,\n        modelKey: selection.modelKey,\n        stream: false,\n        action: options.action,\n        text: completionParts.text,\n        reasoning: completionParts.reasoning,\n        usage: completionUsageSummary(normalizedCompletion),\n      })\n      recordCompletionUsage(resolvedModelId, normalizedCompletion)\n      llmLogger.info({\n        action: 'llm.call.success',\n        message: 'llm call succeeded',\n        provider: providerName,\n        durationMs: Date.now() - attemptStartedAt,\n        details: {\n          model: resolvedModelId,\n          attempt,\n          maxRetries,\n          engine: 'openai_sdk',\n        },\n      })\n      return completion\n    } catch (error: unknown) {\n      const normalizedError = error instanceof Error ? error : new Error(errorMessage(error))\n      lastError = normalizedError\n      llmLogger.warn({\n        action: 'llm.call.attempt_failed',\n        message: errorMessage(error) || 'llm call attempt failed',\n        provider,\n        durationMs: Date.now() - attemptStartedAt,\n        details: {\n          model: resolvedModelId,\n          attempt,\n          maxRetries,\n        },\n      })\n      const errorBody = toRecord(toRecord(error)?.error) || toRecord(error)\n      if (errorBody?.message === 'PROHIBITED_CONTENT' || errorBody?.code === 502) {\n        _ulogError('[LLM] ❌ 内容安全检测失败 - Google AI Studio 拒绝处理此内容')\n        throw new Error('SENSITIVE_CONTENT: 内容包含敏感信息,无法处理。请修改内容后重试')\n      }\n\n      // Google Gemini 返回空响应时，视为可重试错误（不抛出，继续重试循环）\n      if (error instanceof GoogleEmptyResponseError) {\n        _ulogWarn(`[LLM] Google 返回空响应，将重试 (${attempt}/${maxRetries + 1}): ${errorMessage(error)}`)\n        if (attempt > maxRetries) break\n        const delayMs = Math.min(2000 * attempt, 8000)\n        await new Promise((resolve) => setTimeout(resolve, delayMs))\n        continue\n      }\n\n      _ulogWarn(`[LLM] 调用失败 (${attempt}/${maxRetries + 1}): ${errorMessage(error)}`)\n\n      if (!isRetryableError(error) || attempt > maxRetries) break\n      const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 5000)\n      await new Promise((resolve) => setTimeout(resolve, delayMs))\n    }\n  }\n\n  throw lastError || new Error('LLM 调用失败')\n}\n"
  },
  {
    "path": "src/lib/llm/chat-stream.ts",
    "content": "import OpenAI from 'openai'\nimport { generateText, streamText, type ModelMessage } from 'ai'\nimport { createOpenAI } from '@ai-sdk/openai'\nimport { GoogleGenAI } from '@google/genai'\nimport {\n  resolveModelGatewayRoute,\n  runOpenAICompatChatCompletion,\n  runOpenAICompatResponsesCompletion,\n} from '@/lib/model-gateway'\nimport {\n  getProviderConfig,\n  getProviderKey,\n} from '../api-config'\nimport type { ChatCompletionOptions, ChatCompletionStreamCallbacks } from './types'\nimport { extractGoogleParts, extractGoogleUsage, GoogleEmptyResponseError } from './providers/google'\nimport { buildOpenAIChatCompletion } from './providers/openai-compat'\nimport {\n  buildReasoningAwareContent,\n  extractStreamDeltaParts,\n  getConversationMessages,\n  mapReasoningEffort,\n  getSystemPrompt,\n} from './utils'\nimport {\n  emitStreamChunk,\n  emitStreamStage,\n  resolveStreamStepMeta,\n} from './stream-helpers'\nimport {\n  completionUsageSummary,\n  llmLogger,\n  logLlmRawInput,\n  logLlmRawOutput,\n  recordCompletionUsage,\n  resolveLlmRuntimeModel,\n} from './runtime-shared'\nimport { getCompletionParts } from './completion-parts'\nimport { withStreamChunkTimeout } from './stream-timeout'\nimport { shouldUseOpenAIReasoningProviderOptions } from './reasoning-capability'\nimport { completeBailianLlm } from '@/lib/providers/bailian'\nimport { completeSiliconFlowLlm } from '@/lib/providers/siliconflow'\n\nconst OFFICIAL_ONLY_PROVIDER_KEYS = new Set(['bailian', 'siliconflow'])\n\ntype GoogleModelClient = {\n  generateContentStream?: (params: unknown) => Promise<unknown>\n}\n\ntype GoogleChunk = {\n  stream?: AsyncIterable<unknown>\n}\n\ntype AISdkStreamChunk = {\n  type?: string\n  text?: string\n}\n\ntype OpenAIStreamWithFinal = AsyncIterable<unknown> & {\n  finalChatCompletion?: () => Promise<OpenAI.Chat.Completions.ChatCompletion>\n}\n\n\n\nexport async function chatCompletionStream(\n  userId: string,\n  model: string | null | undefined,\n  messages: { role: 'user' | 'assistant' | 'system'; content: string }[],\n  options: ChatCompletionOptions = {},\n  callbacks?: ChatCompletionStreamCallbacks,\n): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  const streamStep = resolveStreamStepMeta(options)\n  emitStreamStage(callbacks, streamStep, 'submit')\n  if (!model) {\n    const error = new Error('ANALYSIS_MODEL_NOT_CONFIGURED: 请先在设置页面配置分析模型')\n    callbacks?.onError?.(error, streamStep)\n    throw error\n  }\n\n  const selection = await resolveLlmRuntimeModel(userId, model)\n  const resolvedModelId = selection.modelId\n  const provider = selection.provider\n  const providerKey = getProviderKey(provider).toLowerCase()\n  const providerConfig = await getProviderConfig(userId, provider)\n  const gatewayRoute = OFFICIAL_ONLY_PROVIDER_KEYS.has(providerKey)\n    ? 'official'\n    : (providerConfig.gatewayRoute || resolveModelGatewayRoute(provider))\n  const temperature = options.temperature ?? 0.7\n  const reasoning = options.reasoning ?? true\n  const reasoningEffort = options.reasoningEffort || 'high'\n  const projectId =\n    typeof options.projectId === 'string' && options.projectId.trim().length > 0\n      ? options.projectId.trim()\n      : undefined\n  logLlmRawInput({\n    userId,\n    projectId,\n    provider: providerKey,\n    modelId: resolvedModelId,\n    modelKey: selection.modelKey,\n    stream: true,\n    reasoning,\n    reasoningEffort,\n    temperature,\n    action: options.action,\n    messages,\n  })\n\n  try {\n    if (gatewayRoute === 'openai-compat') {\n      // openai-compatible protocol probing only applies to openai-compatible + llm.\n      // gemini-compatible is explicitly excluded and must not enter this branch.\n      if (providerKey !== 'openai-compatible') {\n        throw new Error(`OPENAI_COMPAT_PROVIDER_UNSUPPORTED: ${provider}`)\n      }\n      if (!selection.llmProtocol) {\n        throw new Error(`MODEL_LLM_PROTOCOL_REQUIRED: ${selection.modelKey}`)\n      }\n      const compatEngine = selection.llmProtocol === 'responses'\n        ? 'openai_compat_responses'\n        : 'openai_compat_chat_completions'\n      emitStreamStage(callbacks, streamStep, 'streaming', 'openai-compat')\n      const completion = selection.llmProtocol === 'responses'\n        ? await runOpenAICompatResponsesCompletion({\n          userId,\n          providerId: provider,\n          modelId: resolvedModelId,\n          messages,\n          temperature,\n        })\n        : await runOpenAICompatChatCompletion({\n          userId,\n          providerId: provider,\n          modelId: resolvedModelId,\n          messages,\n          temperature,\n        })\n      const completionParts = getCompletionParts(completion)\n      let seq = 1\n      if (completionParts.reasoning) {\n        emitStreamChunk(callbacks, streamStep, {\n          kind: 'reasoning',\n          delta: completionParts.reasoning,\n          seq,\n          lane: 'reasoning',\n        })\n        seq += 1\n      }\n      if (completionParts.text) {\n        emitStreamChunk(callbacks, streamStep, {\n          kind: 'text',\n          delta: completionParts.text,\n          seq,\n          lane: 'main',\n        })\n      }\n      logLlmRawOutput({\n        userId,\n        projectId,\n        provider: compatEngine,\n        modelId: resolvedModelId,\n        modelKey: selection.modelKey,\n        stream: true,\n        action: options.action,\n        text: completionParts.text,\n        reasoning: completionParts.reasoning,\n        usage: completionUsageSummary(completion),\n      })\n      recordCompletionUsage(resolvedModelId, completion)\n      emitStreamStage(callbacks, streamStep, 'completed', compatEngine)\n      callbacks?.onComplete?.(completionParts.text, streamStep)\n      return completion\n    }\n\n    if (providerKey === 'google' || providerKey === 'gemini-compatible') {\n      const googleAiOptions = providerConfig.baseUrl\n        ? { apiKey: providerConfig.apiKey, httpOptions: { baseUrl: providerConfig.baseUrl } }\n        : { apiKey: providerConfig.apiKey }\n      const ai = new GoogleGenAI(googleAiOptions)\n      const modelClient = (ai as unknown as { models?: GoogleModelClient }).models\n      if (!modelClient || typeof modelClient.generateContentStream !== 'function') {\n        throw new Error('GOOGLE_STREAM_UNAVAILABLE: google provider does not expose generateContentStream')\n      }\n\n      const systemParts = messages\n        .filter((m) => m.role === 'system')\n        .map((m) => m.content)\n        .filter(Boolean)\n      const contents = messages\n        .filter((m) => m.role !== 'system')\n        .map((m) => ({\n          role: m.role === 'assistant' ? 'model' : 'user',\n          parts: [{ text: m.content }],\n        }))\n      const systemInstruction = systemParts.length > 0\n        ? { parts: [{ text: systemParts.join('\\n') }] }\n        : undefined\n      const supportsThinkingLevel = resolvedModelId.startsWith('gemini-3')\n      const thinkingConfig = (options.reasoning ?? true) && supportsThinkingLevel\n        ? { thinkingLevel: options.reasoningEffort || 'high', includeThoughts: true }\n        : undefined\n\n      emitStreamStage(callbacks, streamStep, 'streaming', providerKey)\n      const stream = await modelClient.generateContentStream({\n        model: resolvedModelId,\n        contents,\n        config: {\n          temperature: options.temperature ?? 0.7,\n          ...(systemInstruction ? { systemInstruction } : {}),\n          ...(thinkingConfig ? { thinkingConfig } : {}),\n        },\n      })\n      const streamChunk = stream as GoogleChunk\n      const streamIterable = streamChunk?.stream || (stream as AsyncIterable<unknown>)\n\n      let seq = 1\n      let text = ''\n      let reasoning = ''\n      let lastChunk: unknown = null\n      for await (const chunk of withStreamChunkTimeout(streamIterable)) {\n        lastChunk = chunk\n        const chunkParts = extractGoogleParts(chunk)\n\n        let reasoningDelta = chunkParts.reasoning\n        if (reasoningDelta && reasoning && reasoningDelta.startsWith(reasoning)) {\n          reasoningDelta = reasoningDelta.slice(reasoning.length)\n        }\n        if (reasoningDelta) {\n          reasoning += reasoningDelta\n          emitStreamChunk(callbacks, streamStep, {\n            kind: 'reasoning',\n            delta: reasoningDelta,\n            seq,\n            lane: 'reasoning',\n          })\n          seq += 1\n        }\n\n        let textDelta = chunkParts.text\n        if (textDelta && text && textDelta.startsWith(text)) {\n          textDelta = textDelta.slice(text.length)\n        }\n        if (textDelta) {\n          text += textDelta\n          emitStreamChunk(callbacks, streamStep, {\n            kind: 'text',\n            delta: textDelta,\n            seq,\n            lane: 'main',\n          })\n          seq += 1\n        }\n      }\n\n      const usage = extractGoogleUsage(lastChunk)\n      // 如果流式传输结束后 text 仍然为空，抛出可重试错误\n      if (!text) {\n        throw new GoogleEmptyResponseError('stream_empty')\n      }\n      const completion = buildOpenAIChatCompletion(\n        resolvedModelId,\n        buildReasoningAwareContent(text, reasoning),\n        usage,\n      )\n      logLlmRawOutput({\n        userId,\n        projectId,\n        provider: providerKey,\n        modelId: resolvedModelId,\n        modelKey: selection.modelKey,\n        stream: true,\n        action: options.action,\n        text,\n        reasoning,\n        usage,\n      })\n      recordCompletionUsage(resolvedModelId, completion)\n      emitStreamStage(callbacks, streamStep, 'completed', providerKey)\n      callbacks?.onComplete?.(text, streamStep)\n      return completion\n    }\n\n    if (providerKey === 'bailian') {\n      emitStreamStage(callbacks, streamStep, 'streaming', providerKey)\n      const completion = await completeBailianLlm({\n        modelId: resolvedModelId,\n        messages,\n        apiKey: providerConfig.apiKey,\n        baseUrl: providerConfig.baseUrl,\n        temperature: options.temperature ?? 0.7,\n      })\n      const completionParts = getCompletionParts(completion)\n      let seq = 1\n      if (completionParts.reasoning) {\n        emitStreamChunk(callbacks, streamStep, {\n          kind: 'reasoning',\n          delta: completionParts.reasoning,\n          seq,\n          lane: 'reasoning',\n        })\n        seq += 1\n      }\n      if (completionParts.text) {\n        emitStreamChunk(callbacks, streamStep, {\n          kind: 'text',\n          delta: completionParts.text,\n          seq,\n          lane: 'main',\n        })\n      }\n      logLlmRawOutput({\n        userId,\n        projectId,\n        provider: providerKey,\n        modelId: resolvedModelId,\n        modelKey: selection.modelKey,\n        stream: true,\n        action: options.action,\n        text: completionParts.text,\n        reasoning: completionParts.reasoning,\n        usage: completionUsageSummary(completion),\n      })\n      recordCompletionUsage(resolvedModelId, completion)\n      emitStreamStage(callbacks, streamStep, 'completed', providerKey)\n      callbacks?.onComplete?.(completionParts.text, streamStep)\n      return completion\n    }\n\n    if (providerKey === 'siliconflow') {\n      emitStreamStage(callbacks, streamStep, 'streaming', providerKey)\n      const completion = await completeSiliconFlowLlm({\n        modelId: resolvedModelId,\n        messages,\n        apiKey: providerConfig.apiKey,\n        baseUrl: providerConfig.baseUrl,\n        temperature: options.temperature ?? 0.7,\n      })\n      const completionParts = getCompletionParts(completion)\n      let seq = 1\n      if (completionParts.reasoning) {\n        emitStreamChunk(callbacks, streamStep, {\n          kind: 'reasoning',\n          delta: completionParts.reasoning,\n          seq,\n          lane: 'reasoning',\n        })\n        seq += 1\n      }\n      if (completionParts.text) {\n        emitStreamChunk(callbacks, streamStep, {\n          kind: 'text',\n          delta: completionParts.text,\n          seq,\n          lane: 'main',\n        })\n      }\n      logLlmRawOutput({\n        userId,\n        projectId,\n        provider: providerKey,\n        modelId: resolvedModelId,\n        modelKey: selection.modelKey,\n        stream: true,\n        action: options.action,\n        text: completionParts.text,\n        reasoning: completionParts.reasoning,\n        usage: completionUsageSummary(completion),\n      })\n      recordCompletionUsage(resolvedModelId, completion)\n      emitStreamStage(callbacks, streamStep, 'completed', providerKey)\n      callbacks?.onComplete?.(completionParts.text, streamStep)\n      return completion\n    }\n\n\n    if (providerKey === 'ark') {\n      const { arkResponsesStream, convertChatMessagesToArkInput, buildArkThinkingParam } = await import('@/lib/ark-llm')\n      const useReasoning = options.reasoning ?? true\n      const arkThinkingParams = buildArkThinkingParam(resolvedModelId, useReasoning)\n\n      const { stream: arkStream, result: getResult } = arkResponsesStream({\n        apiKey: providerConfig.apiKey,\n        model: resolvedModelId,\n        input: convertChatMessagesToArkInput(messages),\n        temperature: options.temperature ?? 0.7,\n        thinking: arkThinkingParams.thinking,\n      })\n\n      emitStreamStage(callbacks, streamStep, 'streaming', provider)\n      let seq = 1\n      for await (const chunk of withStreamChunkTimeout(arkStream as AsyncIterable<unknown>)) {\n        const arkChunk = chunk as { kind: 'reasoning' | 'text'; delta: string }\n        if (arkChunk.kind === 'reasoning' && arkChunk.delta) {\n          emitStreamChunk(callbacks, streamStep, {\n            kind: 'reasoning',\n            delta: arkChunk.delta,\n            seq,\n            lane: 'reasoning',\n          })\n          seq += 1\n        }\n        if (arkChunk.kind === 'text' && arkChunk.delta) {\n          emitStreamChunk(callbacks, streamStep, {\n            kind: 'text',\n            delta: arkChunk.delta,\n            seq,\n            lane: 'main',\n          })\n          seq += 1\n        }\n      }\n\n      const arkResult = await getResult()\n      const completion = buildOpenAIChatCompletion(\n        resolvedModelId,\n        buildReasoningAwareContent(arkResult.text, arkResult.reasoning),\n        arkResult.usage,\n      )\n      logLlmRawOutput({\n        userId,\n        projectId,\n        provider,\n        modelId: resolvedModelId,\n        modelKey: selection.modelKey,\n        stream: true,\n        action: options.action,\n        text: arkResult.text,\n        reasoning: arkResult.reasoning,\n        usage: arkResult.usage,\n      })\n      recordCompletionUsage(resolvedModelId, completion)\n      emitStreamStage(callbacks, streamStep, 'completed', provider)\n      callbacks?.onComplete?.(arkResult.text, streamStep)\n      return completion\n    }\n\n    if (providerKey !== 'ark') {\n      if (!providerConfig.baseUrl) {\n        throw new Error(`PROVIDER_BASE_URL_MISSING: ${provider} (llm)`)\n      }\n\n      const isOpenRouter = !!providerConfig.baseUrl?.includes('openrouter')\n      const providerName = isOpenRouter ? 'openrouter' : provider\n      const shouldUseAiSdk = !isOpenRouter\n      if (shouldUseAiSdk) {\n        const aiOpenAI = createOpenAI({\n          baseURL: providerConfig.baseUrl,\n          apiKey: providerConfig.apiKey,\n          name: providerName,\n        })\n        // 只有确定是支持 OpenAI 推理参数的提供商（如 OpenAI 官方、deepseek-r1 等）才传 reasoning provider options\n        // gemini-compatible / 其他 OAI-compat 提供商不支持 forceReasoning/reasoningEffort，会导致空响应\n        const isNativeOpenAIReasoning = shouldUseOpenAIReasoningProviderOptions({\n          providerKey,\n          providerApiMode: providerConfig.apiMode,\n          modelId: resolvedModelId,\n        })\n        const aiSdkProviderOptions = (options.reasoning ?? true) && isNativeOpenAIReasoning\n          ? {\n            openai: {\n              reasoningEffort: mapReasoningEffort(options.reasoningEffort || 'high'),\n              forceReasoning: true,\n            },\n          }\n          : undefined\n        const useReasoning = options.reasoning ?? true\n        const aiStreamResult = streamText({\n          model: aiOpenAI.chat(resolvedModelId),\n          system: getSystemPrompt(messages),\n          messages: getConversationMessages(messages),\n          // 推理模型不支持 temperature，仅在非推理模式下传递\n          ...(useReasoning ? {} : { temperature: options.temperature ?? 0.7 }),\n          maxRetries: options.maxRetries ?? 2,\n          ...(aiSdkProviderOptions ? { providerOptions: aiSdkProviderOptions } : {}),\n        })\n\n\n        emitStreamStage(callbacks, streamStep, 'streaming', providerName)\n        let text = ''\n        let reasoning = ''\n        let seq = 1\n        // 用于诊断：记录每种 chunk type 的出现次数\n        const chunkTypeCounts: Record<string, number> = {}\n        // 记录 API 返回的原始错误（如有）\n        const streamErrorChunks: unknown[] = []\n        // 记录 finishReason\n        let streamFinishReason: string | undefined\n        // 记录所有未知类型 chunk 的原始内容（诊断 AI SDK 未解析的响应）\n        const unknownChunkSamples: unknown[] = []\n        for await (const chunk of withStreamChunkTimeout(aiStreamResult.fullStream as AsyncIterable<AISdkStreamChunk>)) {\n          const chunkType = chunk?.type || 'unknown'\n          chunkTypeCounts[chunkType] = (chunkTypeCounts[chunkType] || 0) + 1\n          if (chunkType === 'reasoning-delta' && typeof chunk.text === 'string' && chunk.text) {\n            reasoning += chunk.text\n            emitStreamChunk(callbacks, streamStep, {\n              kind: 'reasoning',\n              delta: chunk.text,\n              seq,\n              lane: 'reasoning',\n            })\n            seq += 1\n          }\n          if (chunkType === 'text-delta' && typeof chunk.text === 'string' && chunk.text) {\n            text += chunk.text\n            emitStreamChunk(callbacks, streamStep, {\n              kind: 'text',\n              delta: chunk.text,\n              seq,\n              lane: 'main',\n            })\n            seq += 1\n          }\n          // 捕获 error 类型 chunk（API 返回的原始错误）\n          if (chunkType === 'error') {\n            streamErrorChunks.push((chunk as Record<string, unknown>).error ?? chunk)\n          }\n          // 捕获 finish-step 的 finishReason\n          if (chunkType === 'finish-step' || chunkType === 'finish') {\n            const reason = (chunk as Record<string, unknown>).finishReason as string | undefined\n            if (reason) streamFinishReason = reason\n          }\n          // 记录所有非标准 chunk 的原始内容（排除纯生命周期 chunk）\n          const lifecycleTypes = new Set(['text-delta', 'reasoning-delta', 'start', 'start-step', 'finish-step', 'finish', 'error'])\n          if (!lifecycleTypes.has(chunkType) && unknownChunkSamples.length < 5) {\n            unknownChunkSamples.push(chunk)\n          }\n        }\n\n        // 读取 AI SDK warnings（如 temperature 不支持等）和最终 finishReason\n        let sdkWarnings: unknown[] = []\n        let sdkFinishReason: string | undefined\n        let sdkProviderMetadata: unknown = undefined\n        let sdkResponseStatus: number | undefined\n        let sdkResponseHeaders: Record<string, string> | undefined\n        try {\n          const warnResult = await Promise.resolve(aiStreamResult.warnings).catch(() => null)\n          sdkWarnings = Array.isArray(warnResult) ? warnResult : []\n        } catch { }\n        try {\n          sdkFinishReason = await Promise.resolve(aiStreamResult.finishReason).catch(() => undefined) as string | undefined\n        } catch { }\n        // 读取 providerMetadata（Gemini safetyRatings 等关键诊断信息）\n        try {\n          sdkProviderMetadata = await Promise.resolve((aiStreamResult as unknown as { experimental_providerMetadata?: unknown }).experimental_providerMetadata).catch(() => undefined)\n        } catch { }\n        // 读取 HTTP response 状态（诊断 API 层面是否正常）\n        try {\n          const resp = await Promise.resolve(aiStreamResult.response).catch(() => null)\n          if (resp) {\n            sdkResponseStatus = (resp as { status?: number }).status\n            const hdrs = (resp as { headers?: Record<string, string> }).headers\n            if (hdrs && typeof hdrs === 'object') {\n              sdkResponseHeaders = Object.fromEntries(\n                Object.entries(hdrs).filter(([k]) => ['content-type', 'x-ratelimit-remaining-requests', 'x-request-id'].includes(k))\n              ) as Record<string, string>\n            }\n          }\n        } catch { }\n\n        let finalReasoning = reasoning\n        let finalText = text\n        try {\n          const resolvedReasoning = await aiStreamResult.reasoningText\n          if (resolvedReasoning && resolvedReasoning !== finalReasoning) {\n            const delta = resolvedReasoning.startsWith(finalReasoning)\n              ? resolvedReasoning.slice(finalReasoning.length)\n              : resolvedReasoning\n            if (delta) {\n              emitStreamChunk(callbacks, streamStep, {\n                kind: 'reasoning',\n                delta,\n                seq,\n                lane: 'reasoning',\n              })\n              seq += 1\n            }\n            finalReasoning = resolvedReasoning\n          }\n        } catch { }\n        try {\n          const resolvedText = await aiStreamResult.text\n          if (resolvedText && resolvedText !== finalText) {\n            const delta = resolvedText.startsWith(finalText)\n              ? resolvedText.slice(finalText.length)\n              : resolvedText\n            if (delta) {\n              emitStreamChunk(callbacks, streamStep, {\n                kind: 'text',\n                delta,\n                seq,\n                lane: 'main',\n              })\n              seq += 1\n            }\n            finalText = resolvedText\n          }\n        } catch { }\n\n        let usage = await Promise.resolve(aiStreamResult.usage).catch(() => null)\n\n        // 显式回退：仅当“强制推理参数”模式返回空文本时，重试一次无推理 provider options 请求。\n        if (!finalText && aiSdkProviderOptions) {\n          llmLogger.warn({\n            audit: false,\n            action: 'llm.stream.reasoning_fallback',\n            message: '[LLM] empty stream with reasoning options, retrying once without provider reasoning options',\n            userId,\n            projectId,\n            provider: providerName,\n            details: {\n              model: { id: resolvedModelId, key: selection.modelKey },\n              action: options.action ?? null,\n              finishReason: sdkFinishReason ?? streamFinishReason ?? 'unknown',\n            },\n          })\n\n          try {\n            const fallbackResult = await generateText({\n              model: aiOpenAI.chat(resolvedModelId),\n              system: getSystemPrompt(messages),\n              messages: getConversationMessages(messages) as ModelMessage[],\n              temperature: options.temperature ?? 0.7,\n              maxRetries: options.maxRetries ?? 2,\n            })\n            const fallbackReasoning = fallbackResult.reasoningText || ''\n            const fallbackText = fallbackResult.text || ''\n            const fallbackUsage = fallbackResult.usage || fallbackResult.totalUsage\n\n            if (fallbackReasoning) {\n              emitStreamChunk(callbacks, streamStep, {\n                kind: 'reasoning',\n                delta: fallbackReasoning,\n                seq,\n                lane: 'reasoning',\n              })\n              seq += 1\n            }\n            if (fallbackText) {\n              emitStreamChunk(callbacks, streamStep, {\n                kind: 'text',\n                delta: fallbackText,\n                seq,\n                lane: 'main',\n              })\n              seq += 1\n            }\n\n            if (fallbackReasoning) finalReasoning = fallbackReasoning\n            if (fallbackText) finalText = fallbackText\n            if (fallbackUsage) usage = fallbackUsage\n          } catch (fallbackError) {\n            llmLogger.warn({\n              audit: false,\n              action: 'llm.stream.reasoning_fallback_failed',\n              message: '[LLM] fallback without reasoning options failed',\n              userId,\n              projectId,\n              provider: providerName,\n              details: {\n                model: { id: resolvedModelId, key: selection.modelKey },\n                action: options.action ?? null,\n                error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError),\n              },\n            })\n          }\n        }\n\n        // 空响应诊断日志：当文本为空时记录详细信息并抛出可重试错误\n        if (!finalText) {\n          // 同步写日志，确保不因竞态丢失，包含完整的原始 API 错误\n          llmLogger.warn({\n            audit: false,\n            action: 'llm.stream.empty_response',\n            message: '[LLM] AI SDK 流式返回空内容',\n            userId,\n            projectId,\n            provider: providerName,\n            details: {\n              model: { id: resolvedModelId, key: selection.modelKey },\n              action: options.action ?? null,\n              reasoningEnabled: useReasoning,\n              isNativeOpenAIReasoning,\n              reasoningEffort: options.reasoningEffort ?? 'high',\n              chunkTypeCounts,\n              sdkWarnings,\n              // 原始 API 错误 chunk\n              streamErrors: streamErrorChunks.length > 0 ? streamErrorChunks : undefined,\n              // finish reason（如 error / content-filter / stop / other 等）\n              finishReason: sdkFinishReason ?? streamFinishReason ?? 'unknown',\n              // providerMetadata：Gemini safetyRatings、blockReason 等原始信息\n              providerMetadata: sdkProviderMetadata,\n              // HTTP 响应状态（诊断 API 层面是否正常返回）\n              httpStatus: sdkResponseStatus,\n              httpHeaders: sdkResponseHeaders,\n              // 未被 AI SDK 识别的 chunk 原始内容（可能是模型返回了特殊格式）\n              unknownChunks: unknownChunkSamples.length > 0 ? unknownChunkSamples : undefined,\n              streamedReasoningLength: finalReasoning.length,\n            },\n          })\n          const finishInfo = sdkFinishReason ?? streamFinishReason ?? 'unknown'\n          const errDetail = streamErrorChunks.length > 0\n            ? ` [apiError: ${JSON.stringify(streamErrorChunks[0])}]`\n            : sdkWarnings.length > 0 ? ` [warnings: ${JSON.stringify(sdkWarnings)}]` : ''\n          throw new Error(\n            `LLM_EMPTY_RESPONSE: ${providerName}::${resolvedModelId} 返回空内容` +\n            ` [finishReason: ${finishInfo}]` +\n            ` [httpStatus: ${sdkResponseStatus ?? 'unknown'}]` +\n            errDetail +\n            ` [chunks: ${JSON.stringify(chunkTypeCounts)}]`,\n          )\n        }\n\n\n\n\n\n        const completion = buildOpenAIChatCompletion(\n          resolvedModelId,\n          buildReasoningAwareContent(finalText, finalReasoning),\n          {\n            promptTokens: usage?.inputTokens ?? 0,\n            completionTokens: usage?.outputTokens ?? 0,\n          },\n        )\n        logLlmRawOutput({\n          userId,\n          projectId,\n          provider: providerName,\n          modelId: resolvedModelId,\n          modelKey: selection.modelKey,\n          stream: true,\n          action: options.action,\n          text: finalText,\n          reasoning: finalReasoning,\n          usage: {\n            promptTokens: usage?.inputTokens ?? 0,\n            completionTokens: usage?.outputTokens ?? 0,\n          },\n        })\n        recordCompletionUsage(resolvedModelId, completion)\n        emitStreamStage(callbacks, streamStep, 'completed', providerName)\n        callbacks?.onComplete?.(finalText, streamStep)\n        return completion\n      }\n\n      const client = new OpenAI({\n        baseURL: providerConfig.baseUrl,\n        apiKey: providerConfig.apiKey,\n      })\n\n      const extraParams: Record<string, unknown> = {}\n      if (isOpenRouter && (options.reasoning ?? true)) {\n        extraParams.reasoning = { effort: options.reasoningEffort || 'high' }\n      }\n\n      emitStreamStage(callbacks, streamStep, 'streaming', providerName)\n      const isOpenRouterReasoning = isOpenRouter && (options.reasoning ?? true)\n      const stream = await client.chat.completions.create({\n        model: resolvedModelId,\n        messages,\n        // OpenRouter 推理模型不支持 temperature\n        ...(isOpenRouterReasoning ? {} : { temperature: options.temperature ?? 0.7 }),\n        stream: true,\n        ...extraParams,\n      } as unknown as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming)\n\n      let text = ''\n      let reasoning = ''\n      let seq = 1\n      let finalCompletion: OpenAI.Chat.Completions.ChatCompletion | null = null\n      for await (const part of withStreamChunkTimeout(stream as AsyncIterable<unknown>)) {\n        const { textDelta, reasoningDelta } = extractStreamDeltaParts(part)\n        if (reasoningDelta) {\n          reasoning += reasoningDelta\n          emitStreamChunk(callbacks, streamStep, {\n            kind: 'reasoning',\n            delta: reasoningDelta,\n            seq,\n            lane: 'reasoning',\n          })\n          seq += 1\n        }\n        if (textDelta) {\n          text += textDelta\n          emitStreamChunk(callbacks, streamStep, {\n            kind: 'text',\n            delta: textDelta,\n            seq,\n            lane: 'main',\n          })\n          seq += 1\n        }\n      }\n\n      const finalChatCompletionFn = (stream as OpenAIStreamWithFinal)?.finalChatCompletion\n      if (typeof finalChatCompletionFn === 'function') {\n        try {\n          finalCompletion = await finalChatCompletionFn.call(stream)\n          const finalParts = getCompletionParts(finalCompletion)\n          if (finalParts.reasoning && finalParts.reasoning !== reasoning) {\n            const reasoningDelta = finalParts.reasoning.startsWith(reasoning)\n              ? finalParts.reasoning.slice(reasoning.length)\n              : finalParts.reasoning\n            if (reasoningDelta) {\n              emitStreamChunk(callbacks, streamStep, {\n                kind: 'reasoning',\n                delta: reasoningDelta,\n                seq,\n                lane: 'reasoning',\n              })\n              seq += 1\n            }\n            reasoning = finalParts.reasoning\n          }\n          if (finalParts.text && finalParts.text !== text) {\n            const textDelta = finalParts.text.startsWith(text)\n              ? finalParts.text.slice(text.length)\n              : finalParts.text\n            if (textDelta) {\n              emitStreamChunk(callbacks, streamStep, {\n                kind: 'text',\n                delta: textDelta,\n                seq,\n                lane: 'main',\n              })\n              seq += 1\n            }\n            text = finalParts.text\n          }\n        } catch {\n          // Ignore final aggregation errors and keep streamed content.\n        }\n      }\n\n      const completion = buildOpenAIChatCompletion(\n        resolvedModelId,\n        buildReasoningAwareContent(text, reasoning),\n        finalCompletion\n          ? {\n            promptTokens: Number(finalCompletion.usage?.prompt_tokens ?? 0),\n            completionTokens: Number(finalCompletion.usage?.completion_tokens ?? 0),\n          }\n          : undefined,\n      )\n      logLlmRawOutput({\n        userId,\n        projectId,\n        provider: providerName,\n        modelId: resolvedModelId,\n        modelKey: selection.modelKey,\n        stream: true,\n        action: options.action,\n        text,\n        reasoning,\n        usage: completionUsageSummary(finalCompletion),\n      })\n      recordCompletionUsage(resolvedModelId, completion)\n      emitStreamStage(callbacks, streamStep, 'completed', providerName)\n      callbacks?.onComplete?.(text, streamStep)\n      return completion\n    }\n    throw new Error(`UNSUPPORTED_STREAM_PROVIDER: ${providerKey}`)\n  } catch (error) {\n    // Detect PROHIBITED_CONTENT from Gemini and normalize to SENSITIVE_CONTENT\n    // (consistent with chat-completion.ts)\n    const errMsg = error instanceof Error ? error.message : String(error)\n    if (errMsg.includes('PROHIBITED_CONTENT') || errMsg.includes('request_body_blocked')) {\n      const sensitiveError = new Error('SENSITIVE_CONTENT: 内容包含敏感信息,无法处理。请修改内容后重试')\n      callbacks?.onError?.(sensitiveError, streamStep)\n      throw sensitiveError\n    }\n    callbacks?.onError?.(error, streamStep)\n    throw error\n  }\n}\n"
  },
  {
    "path": "src/lib/llm/completion-parts.ts",
    "content": "import OpenAI from 'openai'\nimport { extractCompletionPartsFromContent } from './utils'\nimport { _ulogError } from './runtime-shared'\n\nexport function getCompletionContent(completion: OpenAI.Chat.Completions.ChatCompletion): string {\n  return getCompletionParts(completion).text\n}\n\nexport function getCompletionParts(completion: OpenAI.Chat.Completions.ChatCompletion): {\n  text: string\n  reasoning: string\n} {\n  if (!completion || !completion.choices || completion.choices.length === 0) {\n    _ulogError(\n      '[LLM] ❌ 返回无效响应 - 完整对象:',\n      JSON.stringify(completion, null, 2).substring(0, 2000),\n    )\n    throw new Error('LLM 返回无效响应')\n  }\n\n  const message = completion.choices[0]?.message\n  if (!message) {\n    _ulogError(\n      '[LLM] ❌ 响应中没有消息内容 - choices[0]:',\n      JSON.stringify(completion.choices[0], null, 2).substring(0, 1000),\n    )\n    throw new Error('LLM 响应中没有消息内容')\n  }\n\n  const content = message.content\n  return extractCompletionPartsFromContent(content)\n}\n"
  },
  {
    "path": "src/lib/llm/index.ts",
    "content": "export type {\n    ChatCompletionOptions,\n    ChatCompletionStreamCallbacks,\n    ChatMessage,\n} from './types'\nexport {\n    buildReasoningAwareContent,\n    collectTextValue,\n    extractCompletionPartsFromContent,\n    extractStreamDeltaParts,\n    getConversationMessages,\n    getSystemPrompt,\n    mapReasoningEffort,\n} from './utils'\nexport {\n    emitChunkedText,\n    emitStreamChunk,\n    emitStreamStage,\n    resolveStreamStepMeta,\n} from './stream-helpers'\nexport { arkResponsesCompletion } from './providers/ark'\nexport { extractGoogleText, extractGoogleUsage } from './providers/google'\nexport { buildOpenAIChatCompletion } from './providers/openai-compat'\n"
  },
  {
    "path": "src/lib/llm/providers/ark.ts",
    "content": "export { arkResponsesCompletion } from '@/lib/ark-llm'\n"
  },
  {
    "path": "src/lib/llm/providers/google.ts",
    "content": "interface GoogleTextPart {\n    text?: unknown\n    thought?: unknown\n    type?: unknown\n}\n\ninterface GoogleUsageLike {\n    promptTokenCount?: unknown\n    prompt_tokens?: unknown\n    input_tokens?: unknown\n    totalTokenCount?: unknown\n    total_tokens?: unknown\n    candidatesTokenCount?: unknown\n    completion_tokens?: unknown\n    output_tokens?: unknown\n}\n\ninterface GoogleResponseLike {\n    candidates?: Array<{ content?: { parts?: GoogleTextPart[] }; finishReason?: unknown }>\n    response?: { candidates?: Array<{ content?: { parts?: GoogleTextPart[] }; finishReason?: unknown }> }\n    usageMetadata?: GoogleUsageLike\n    usage?: GoogleUsageLike\n}\n\nfunction toNumber(value: unknown): number | null {\n    return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\nfunction isThoughtPart(part: GoogleTextPart): boolean {\n    if (part.thought === true) return true\n    if (typeof part.type === 'string') {\n        const normalized = part.type.toLowerCase()\n        if (normalized.includes('thought') || normalized.includes('reason')) return true\n    }\n    return false\n}\n\n/**\n * Google Gemini API 返回空响应错误（但不是内容安全拒绝）\n * 通常是模型内部超时或某些边缘输入触发的问题，可以重试\n */\nexport class GoogleEmptyResponseError extends Error {\n    constructor(finishReason?: unknown) {\n        const reason = finishReason ? ` (finishReason: ${String(finishReason)})` : ''\n        super(`Google Gemini 返回了空文本响应${reason}，请重试`)\n        this.name = 'GoogleEmptyResponseError'\n    }\n}\n\nexport function extractGoogleParts(response: unknown, throwOnEmpty = false): { text: string; reasoning: string } {\n    if (!response || typeof response !== 'object') {\n        return { text: '', reasoning: '' }\n    }\n    const safe = response as GoogleResponseLike\n    const candidates = safe.candidates || safe.response?.candidates || []\n    const firstCandidate = candidates?.[0]\n    const parts = firstCandidate?.content?.parts || []\n    let text = ''\n    let reasoning = ''\n    for (const part of parts) {\n        const value = typeof part.text === 'string' ? part.text : ''\n        if (!value) continue\n        if (isThoughtPart(part)) {\n            reasoning += value\n        } else {\n            text += value\n        }\n    }\n\n    // 如果有 candidates 但 text 为空，说明模型返回了空响应\n    // 只在 throwOnEmpty=true 时检查（用于非流式的最终响应），避免在流式 chunk 间误抛\n    if (throwOnEmpty && candidates.length > 0 && !text) {\n        const finishReason = firstCandidate?.finishReason\n        // SAFETY 表示内容安全拒绝，不重试；其他情况抛出可重试错误\n        if (finishReason !== 'SAFETY' && finishReason !== 'PROHIBITED_CONTENT') {\n            throw new GoogleEmptyResponseError(finishReason)\n        }\n    }\n\n    return {\n        text,\n        reasoning,\n    }\n}\n\nexport function extractGoogleText(response: unknown): string {\n    return extractGoogleParts(response).text\n}\n\nexport function extractGoogleReasoning(response: unknown): string {\n    return extractGoogleParts(response).reasoning\n}\n\nexport function extractGoogleUsage(response: unknown): { promptTokens: number; completionTokens: number } {\n    const safe = response && typeof response === 'object' ? (response as GoogleResponseLike) : null\n    const usage = safe?.usageMetadata || safe?.usage\n    const promptTokens =\n        toNumber(usage?.promptTokenCount) ??\n        toNumber(usage?.prompt_tokens) ??\n        toNumber(usage?.input_tokens) ??\n        0\n    const totalTokens = toNumber(usage?.totalTokenCount) ?? toNumber(usage?.total_tokens)\n    const completionTokens =\n        toNumber(usage?.candidatesTokenCount) ??\n        toNumber(usage?.completion_tokens) ??\n        toNumber(usage?.output_tokens) ??\n        (typeof totalTokens === 'number' ? Math.max(totalTokens - promptTokens, 0) : 0)\n    return { promptTokens, completionTokens }\n}\n"
  },
  {
    "path": "src/lib/llm/providers/openai-compat.ts",
    "content": "import OpenAI from 'openai'\n\nexport function buildOpenAIChatCompletion(\n    modelId: string,\n    content: unknown,\n    usage?: { promptTokens?: number; completionTokens?: number }\n): OpenAI.Chat.Completions.ChatCompletion {\n    const messageContent: OpenAI.Chat.Completions.ChatCompletionMessage['content'] =\n        typeof content === 'string' || Array.isArray(content)\n            ? (content as OpenAI.Chat.Completions.ChatCompletionMessage['content'])\n            : String(content ?? '')\n    const promptTokens = usage?.promptTokens ?? 0\n    const completionTokens = usage?.completionTokens ?? 0\n    return {\n        id: `chatcmpl_${Date.now()}`,\n        object: 'chat.completion',\n        created: Math.floor(Date.now() / 1000),\n        model: modelId,\n        choices: [\n            {\n                index: 0,\n                message: { role: 'assistant', content: messageContent },\n                finish_reason: 'stop'\n            }\n        ],\n        usage: {\n            prompt_tokens: promptTokens,\n            completion_tokens: completionTokens,\n            total_tokens: promptTokens + completionTokens\n        }\n    } as OpenAI.Chat.Completions.ChatCompletion\n}\n"
  },
  {
    "path": "src/lib/llm/reasoning-capability.ts",
    "content": "type ProviderApiMode = 'gemini-sdk' | 'openai-official' | undefined\n\nfunction normalizeModelId(modelId: string): string {\n  return modelId.trim().toLowerCase()\n}\n\nexport function isLikelyOpenAIReasoningModel(modelId: string): boolean {\n  const normalized = normalizeModelId(modelId)\n  if (!normalized) return false\n\n  return normalized.startsWith('o1')\n    || normalized.startsWith('o3')\n    || normalized.startsWith('o4')\n    || normalized.startsWith('gpt-5')\n}\n\nexport function shouldUseOpenAIReasoningProviderOptions(input: {\n  providerKey: string\n  providerApiMode?: ProviderApiMode\n  modelId: string\n}): boolean {\n  if (!isLikelyOpenAIReasoningModel(input.modelId)) return false\n\n  const normalizedProviderKey = input.providerKey.trim().toLowerCase()\n  if (normalizedProviderKey === 'openai') return true\n\n  if (normalizedProviderKey === 'openai-compatible' && input.providerApiMode === 'openai-official') {\n    return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "src/lib/llm/runtime-shared.ts",
    "content": "import OpenAI from 'openai'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport { resolveModelSelection } from '../api-config'\nimport { recordTextUsage as recordBillingTextUsage } from '@/lib/billing/runtime-usage'\n\nexport const llmLogger = createScopedLogger({\n  module: 'llm.client',\n  action: 'llm.call',\n})\n\nexport const _ulogInfo = (...args: unknown[]) => llmLogger.info(...args)\nexport const _ulogWarn = (...args: unknown[]) => llmLogger.warn(...args)\nexport const _ulogError = (...args: unknown[]) => llmLogger.error(...args)\n\nexport type LlmRawMessage = {\n  role: 'user' | 'assistant' | 'system'\n  content: string\n}\n\ntype LlmUsage = {\n  promptTokens: number\n  completionTokens: number\n}\n\nexport function completionUsageSummary(\n  completion: OpenAI.Chat.Completions.ChatCompletion | null | undefined,\n): LlmUsage | null {\n  const usage = completion?.usage as { prompt_tokens?: number; completion_tokens?: number } | undefined\n  if (!usage) return null\n  const promptTokens = Number(usage.prompt_tokens ?? 0)\n  const completionTokens = Number(usage.completion_tokens ?? 0)\n  if (!Number.isFinite(promptTokens) || !Number.isFinite(completionTokens)) return null\n  return {\n    promptTokens,\n    completionTokens,\n  }\n}\n\nexport function logLlmRawInput(params: {\n  userId: string\n  projectId?: string\n  provider: string\n  modelId: string\n  modelKey: string\n  stream: boolean\n  reasoning: boolean\n  reasoningEffort: 'minimal' | 'low' | 'medium' | 'high'\n  temperature: number\n  action?: string\n  messages: LlmRawMessage[]\n}) {\n  llmLogger.info({\n    audit: true,\n    action: 'llm.raw.input',\n    message: 'llm raw input',\n    userId: params.userId,\n    projectId: params.projectId,\n    provider: params.provider,\n    details: {\n      action: params.action || null,\n      stream: params.stream,\n      model: {\n        id: params.modelId,\n        key: params.modelKey,\n      },\n      options: {\n        temperature: params.temperature,\n        reasoning: params.reasoning,\n        reasoningEffort: params.reasoningEffort,\n      },\n      messages: params.messages,\n    },\n  })\n}\n\nexport function logLlmRawOutput(params: {\n  userId: string\n  projectId?: string\n  provider: string\n  modelId: string\n  modelKey: string\n  stream: boolean\n  action?: string\n  text: string\n  reasoning: string\n  usage?: LlmUsage | null\n}) {\n  const isEmpty = !params.text\n  const logPayload = {\n    audit: true,\n    action: 'llm.raw.output',\n    message: isEmpty ? 'llm raw output [EMPTY]' : 'llm raw output',\n    userId: params.userId,\n    projectId: params.projectId,\n    provider: params.provider,\n    details: {\n      action: params.action || null,\n      stream: params.stream,\n      model: {\n        id: params.modelId,\n        key: params.modelKey,\n      },\n      output: {\n        reasoning: params.reasoning,\n        text: params.text,\n        // 空响应时显式标记，方便 grep\n        empty: isEmpty || undefined,\n      },\n      usage: params.usage || null,\n    },\n  }\n  if (isEmpty) {\n    llmLogger.warn(logPayload)\n  } else {\n    llmLogger.info(logPayload)\n  }\n}\n\nexport function isRetryableError(error: unknown): boolean {\n  if (!error || typeof error !== 'object') return false\n  const errorRecord = error as { code?: unknown; status?: unknown }\n  if (errorRecord.code === 'ECONNRESET' || errorRecord.code === 'ETIMEDOUT') return true\n  if (typeof errorRecord.status === 'number' && (errorRecord.status === 429 || (errorRecord.status >= 500 && errorRecord.status < 600))) {\n    return true\n  }\n  return false\n}\n\nexport function recordCompletionUsage(model: string, completion: OpenAI.Chat.Completions.ChatCompletion) {\n  const summary = completionUsageSummary(completion)\n  if (!summary) return\n\n  recordBillingTextUsage({\n    model,\n    inputTokens: summary.promptTokens,\n    outputTokens: summary.completionTokens,\n  })\n}\n\nexport interface ResolvedLlmRuntimeModel {\n  provider: string\n  modelId: string\n  modelKey: string\n  llmProtocol?: 'responses' | 'chat-completions'\n}\n\nexport async function resolveLlmRuntimeModel(\n  userId: string,\n  model: string,\n): Promise<ResolvedLlmRuntimeModel> {\n  const selection = await resolveModelSelection(userId, model, 'llm')\n  return {\n    provider: selection.provider,\n    modelId: selection.modelId,\n    modelKey: selection.modelKey,\n    llmProtocol: selection.llmProtocol,\n  }\n}\n"
  },
  {
    "path": "src/lib/llm/runtime.ts",
    "content": "export { chatCompletion } from './chat-completion'\nexport { chatCompletionStream } from './chat-stream'\nexport {\n  chatCompletionWithVision,\n  chatCompletionWithVisionStream,\n} from './vision'\nexport {\n  getCompletionContent,\n  getCompletionParts,\n} from './completion-parts'\n"
  },
  {
    "path": "src/lib/llm/stream-helpers.ts",
    "content": "import type { LLMStreamKind } from '@/lib/llm-observe/types'\nimport type { InternalLLMStreamStepMeta } from '@/lib/llm-observe/internal-stream-context'\nimport type { ChatCompletionOptions, ChatCompletionStreamCallbacks } from './types'\n\nexport function resolveStreamStepMeta(options: ChatCompletionOptions): InternalLLMStreamStepMeta | undefined {\n    const id = typeof options.streamStepId === 'string' ? options.streamStepId.trim() : ''\n    const attempt = typeof options.streamStepAttempt === 'number' && Number.isFinite(options.streamStepAttempt)\n        ? Math.max(1, Math.floor(options.streamStepAttempt))\n        : null\n    const title = typeof options.streamStepTitle === 'string' ? options.streamStepTitle.trim() : ''\n    const index = typeof options.streamStepIndex === 'number' && Number.isFinite(options.streamStepIndex)\n        ? Math.max(1, Math.floor(options.streamStepIndex))\n        : null\n    const total = typeof options.streamStepTotal === 'number' && Number.isFinite(options.streamStepTotal)\n        ? Math.max(1, Math.floor(options.streamStepTotal))\n        : null\n\n    if (!id && !attempt && !title && !index && !total) return undefined\n    return {\n        ...(id ? { id } : {}),\n        ...(attempt ? { attempt } : {}),\n        ...(title ? { title } : {}),\n        ...(index ? { index } : {}),\n        ...(total ? { total: Math.max(index || 1, total) } : {}),\n    }\n}\n\nexport function emitStreamStage(\n    callbacks: ChatCompletionStreamCallbacks | undefined,\n    step: InternalLLMStreamStepMeta | undefined,\n    stage: 'submit' | 'streaming' | 'fallback' | 'completed',\n    provider?: string | null,\n) {\n    callbacks?.onStage?.({ stage, provider, ...(step ? { step } : {}) })\n}\n\nexport function emitStreamChunk(\n    callbacks: ChatCompletionStreamCallbacks | undefined,\n    step: InternalLLMStreamStepMeta | undefined,\n    chunk: {\n        kind: LLMStreamKind\n        delta: string\n        seq: number\n        lane?: string | null\n    },\n) {\n    callbacks?.onChunk?.({\n        ...chunk,\n        ...(step ? { step } : {}),\n    })\n}\n\nexport function emitChunkedText(\n    text: string,\n    callbacks?: ChatCompletionStreamCallbacks,\n    kind: LLMStreamKind = 'text',\n    seqStart = 1,\n    step?: InternalLLMStreamStepMeta,\n) {\n    if (!text) return seqStart\n    let seq = seqStart\n    const chunkSize = 320\n    for (let i = 0; i < text.length; i += chunkSize) {\n        emitStreamChunk(callbacks, step, {\n            kind,\n            delta: text.slice(i, i + chunkSize),\n            seq,\n            lane: 'main',\n        })\n        seq += 1\n    }\n    return seq\n}\n"
  },
  {
    "path": "src/lib/llm/stream-timeout.ts",
    "content": "/**\n * Per-chunk timeout wrapper for async iterables.\n *\n * Wraps an `AsyncIterable` so that if no chunk arrives within\n * `perChunkTimeoutMs`, a timeout error is thrown.  The timer resets\n * after every received chunk, so long-running but *active* streams\n * are never interrupted.\n *\n * Default timeout: 3 minutes (180 000 ms).\n */\n\nconst DEFAULT_STREAM_CHUNK_TIMEOUT_MS = 3 * 60 * 1000\n\nexport class StreamChunkTimeoutError extends Error {\n    constructor(timeoutMs: number) {\n        super(`LLM_STREAM_TIMEOUT: No stream chunk received within ${Math.round(timeoutMs / 1000)}s`)\n        this.name = 'StreamChunkTimeoutError'\n    }\n}\n\nexport async function* withStreamChunkTimeout<T>(\n    source: AsyncIterable<T>,\n    timeoutMs: number = DEFAULT_STREAM_CHUNK_TIMEOUT_MS,\n): AsyncGenerator<T> {\n    const iterator = source[Symbol.asyncIterator]()\n    while (true) {\n        const result = await Promise.race([\n            iterator.next(),\n            new Promise<never>((_, reject) => {\n                const timer = setTimeout(\n                    () => reject(new StreamChunkTimeoutError(timeoutMs)),\n                    timeoutMs,\n                )\n                // Prevent the timer from keeping Node alive if the iterator\n                // completes before the timeout fires.\n                if (typeof timer === 'object' && 'unref' in timer) {\n                    timer.unref()\n                }\n            }),\n        ])\n        if (result.done) return\n        yield result.value\n    }\n}\n"
  },
  {
    "path": "src/lib/llm/types.ts",
    "content": "import type { LLMStreamKind } from '@/lib/llm-observe/types'\nimport type { InternalLLMStreamStepMeta } from '@/lib/llm-observe/internal-stream-context'\n\nexport interface ChatCompletionOptions {\n    temperature?: number\n    reasoning?: boolean\n    reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'\n    maxRetries?: number\n    // 💰 计费相关\n    projectId?: string   // 用于计费（如果不传，使用 'system' 作为默认值）\n    action?: string      // 计费操作名称\n    // 流式步骤元信息（用于任务控制台按步骤展示）\n    streamStepId?: string\n    streamStepAttempt?: number\n    streamStepTitle?: string\n    streamStepIndex?: number\n    streamStepTotal?: number\n    // 内部保护位：避免 chatCompletion 与 chatCompletionStream 互相递归\n    __skipAutoStream?: boolean\n}\n\nexport interface ChatCompletionStreamCallbacks {\n    onStage?: (stage: {\n        stage: 'submit' | 'streaming' | 'fallback' | 'completed'\n        provider?: string | null\n        step?: InternalLLMStreamStepMeta\n    }) => void\n    onChunk?: (chunk: {\n        kind: LLMStreamKind\n        delta: string\n        seq: number\n        lane?: string | null\n        step?: InternalLLMStreamStepMeta\n    }) => void\n    onComplete?: (text: string, step?: InternalLLMStreamStepMeta) => void\n    onError?: (error: unknown, step?: InternalLLMStreamStepMeta) => void\n}\n\nexport type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string }\n"
  },
  {
    "path": "src/lib/llm/utils.ts",
    "content": "import type { ChatMessage } from './types'\n\nfunction splitThinkTaggedContent(input: string): { text: string; reasoning: string } {\n    const thinkTagPattern = /<(think|thinking)\\b[^>]*>([\\s\\S]*?)<\\/\\1>/gi\n    const reasoningParts: string[] = []\n    let matched = false\n\n    const stripped = input.replace(thinkTagPattern, (_fullMatch, _tagName: string, inner: string) => {\n        matched = true\n        const trimmed = inner.trim()\n        if (trimmed) reasoningParts.push(trimmed)\n        return ''\n    })\n\n    if (!matched) {\n        return {\n            text: input,\n            reasoning: '',\n        }\n    }\n\n    return {\n        text: stripped.trim(),\n        reasoning: reasoningParts.join('\\n\\n').trim(),\n    }\n}\n\nexport function collectTextValue(value: unknown): string {\n    if (!value) return ''\n    if (typeof value === 'string') return value\n    if (Array.isArray(value)) {\n        return value.map((item) => collectTextValue(item)).join('')\n    }\n    if (typeof value === 'object') {\n        const obj = value as Record<string, unknown>\n        if (typeof obj.text === 'string') return obj.text\n        if (typeof obj.content === 'string') return obj.content\n        if (typeof obj.delta === 'string') return obj.delta\n        if (Array.isArray(obj.parts)) {\n            return obj.parts.map((part) => collectTextValue(part)).join('')\n        }\n    }\n    return ''\n}\n\nexport function extractCompletionPartsFromContent(content: unknown): { text: string; reasoning: string } {\n    if (typeof content === 'string') {\n        return splitThinkTaggedContent(content)\n    }\n    if (!Array.isArray(content)) {\n        return splitThinkTaggedContent(collectTextValue(content))\n    }\n\n    let text = ''\n    let reasoning = ''\n    for (const part of content) {\n        if (typeof part === 'string') {\n            text += part\n            continue\n        }\n        if (!part || typeof part !== 'object') continue\n        const obj = part as Record<string, unknown>\n        const kind = typeof obj.type === 'string' ? obj.type.toLowerCase() : ''\n        const value =\n            (typeof obj.text === 'string' && obj.text) ||\n            (typeof obj.content === 'string' && obj.content) ||\n            collectTextValue(obj.delta) ||\n            collectTextValue(obj.output_text) ||\n            ''\n        if (!value) continue\n        if (kind.includes('reason') || kind.includes('think')) {\n            reasoning += value\n        } else {\n            const parsed = splitThinkTaggedContent(value)\n            text += parsed.text\n            if (parsed.reasoning) reasoning += parsed.reasoning\n        }\n    }\n\n    return { text, reasoning }\n}\n\nexport function extractStreamDeltaParts(part: unknown): { textDelta: string; reasoningDelta: string } {\n    const partObject =\n        typeof part === 'object' && part !== null\n            ? (part as {\n                choices?: Array<{ delta?: Record<string, unknown> }>\n                response?: {\n                    output_text?: { delta?: unknown }\n                    reasoning?: { delta?: unknown }\n                }\n            })\n            : {}\n    const delta = partObject.choices?.[0]?.delta || {}\n    const contentParts = extractCompletionPartsFromContent(delta.content)\n    const responseDelta = partObject.response?.output_text?.delta || ''\n    const responseReasoning = partObject.response?.reasoning?.delta || ''\n    const explicitReasoning =\n        collectTextValue(delta.reasoning) ||\n        collectTextValue(delta.reasoning_content) ||\n        collectTextValue(delta.reasoningContent) ||\n        collectTextValue(delta.thinking) ||\n        collectTextValue(delta.reasoning_details) ||\n        collectTextValue(responseReasoning)\n    const textDelta =\n        contentParts.text ||\n        collectTextValue(delta.output_text) ||\n        collectTextValue(delta.text) ||\n        collectTextValue(responseDelta) ||\n        ''\n    const reasoningDelta = contentParts.reasoning || explicitReasoning || ''\n    return {\n        textDelta,\n        reasoningDelta,\n    }\n}\n\nexport function getSystemPrompt(messages: ChatMessage[]) {\n    const systemParts = messages\n        .filter((m) => m.role === 'system')\n        .map((m) => m.content)\n        .filter(Boolean)\n    if (systemParts.length === 0) return undefined\n    return systemParts.join('\\n')\n}\n\nexport function getConversationMessages(messages: ChatMessage[]) {\n    return messages\n        .filter((m) => m.role !== 'system')\n        .map((m) => ({\n            role: m.role,\n            content: m.content,\n        }))\n}\n\nexport function mapReasoningEffort(effort: 'minimal' | 'low' | 'medium' | 'high' | undefined) {\n    if (effort === 'low' || effort === 'medium' || effort === 'high') return effort\n    if (effort === 'minimal') return 'low'\n    return 'high'\n}\n\nexport function buildReasoningAwareContent(text: string, reasoning: string) {\n    if (!reasoning) return text\n    return [\n        { type: 'reasoning', text: reasoning },\n        { type: 'text', text },\n    ]\n}\n"
  },
  {
    "path": "src/lib/llm/vision.ts",
    "content": "import OpenAI from 'openai'\nimport { GoogleGenAI } from '@google/genai'\nimport { getInternalBaseUrl } from '@/lib/env'\nimport {\n  getProviderConfig,\n  getProviderKey,\n} from '../api-config'\nimport { getInternalLLMStreamCallbacks } from '../llm-observe/internal-stream-context'\nimport type { ChatCompletionOptions, ChatCompletionStreamCallbacks } from './types'\nimport { arkResponsesCompletion } from './providers/ark'\nimport { extractGoogleText, extractGoogleUsage } from './providers/google'\nimport { buildOpenAIChatCompletion } from './providers/openai-compat'\nimport { emitChunkedText } from './stream-helpers'\nimport { getCompletionParts } from './completion-parts'\nimport {\n  _ulogError,\n  _ulogInfo,\n  _ulogWarn,\n  isRetryableError,\n  llmLogger,\n  recordCompletionUsage,\n  resolveLlmRuntimeModel,\n} from './runtime-shared'\nimport { completeBailianLlm } from '@/lib/providers/bailian'\nimport { completeSiliconFlowLlm } from '@/lib/providers/siliconflow'\n\ntype GoogleVisionPart = { inlineData: { mimeType: string; data: string } } | { text: string }\ntype ArkVisionContentItem = { type: 'input_image'; image_url: string } | { type: 'input_text'; text: string }\ntype OpenAiVisionContentItem = { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }\n\nfunction getErrorMessage(error: unknown): string {\n  if (error instanceof Error && typeof error.message === 'string') return error.message\n  if (typeof error === 'object' && error !== null) {\n    const candidate = (error as { message?: unknown }).message\n    if (typeof candidate === 'string') return candidate\n  }\n  return 'unknown error'\n}\n\nfunction getErrorBody(error: unknown): { message?: unknown; code?: unknown } {\n  if (typeof error !== 'object' || error === null) return {}\n  const root = error as { error?: unknown; message?: unknown; code?: unknown }\n  if (typeof root.error === 'object' && root.error !== null) {\n    return root.error as { message?: unknown; code?: unknown }\n  }\n  return root\n}\n\nexport async function chatCompletionWithVision(\n  userId: string,\n  model: string | null | undefined,\n  textPrompt: string,\n  imageUrls: string[] = [],\n  options: ChatCompletionOptions = {},\n): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  const internalCallbacks = getInternalLLMStreamCallbacks()\n  if (internalCallbacks && !options.__skipAutoStream) {\n    return await chatCompletionWithVisionStream(\n      userId,\n      model,\n      textPrompt,\n      imageUrls,\n      { ...options, __skipAutoStream: true },\n      internalCallbacks,\n    )\n  }\n\n  if (!model) {\n    _ulogError('[LLM Vision] 模型未配置，调用栈:', new Error().stack)\n    throw new Error('ANALYSIS_MODEL_NOT_CONFIGURED: 请先在设置页面配置分析模型')\n  }\n\n  const selection = await resolveLlmRuntimeModel(userId, model)\n  const resolvedModelId = selection.modelId\n  const provider = selection.provider\n  const providerKey = getProviderKey(provider).toLowerCase()\n\n  const { temperature = 0.7, maxRetries = 2, reasoning = true } = options\n\n  let lastError: Error | null = null\n\n  for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {\n    const attemptStartedAt = Date.now()\n    try {\n      const providerConfig = await getProviderConfig(userId, provider)\n      if (providerKey === 'google' || providerKey === 'gemini-compatible') {\n        const ai = new GoogleGenAI({ apiKey: providerConfig.apiKey })\n        const { normalizeToBase64ForGeneration } = await import('@/lib/media/outbound-image')\n\n        const parts: GoogleVisionPart[] = []\n        for (const url of imageUrls) {\n          try {\n            const dataUrl = url.startsWith('data:') ? url : await normalizeToBase64ForGeneration(url)\n            const base64Start = dataUrl.indexOf(';base64,')\n            if (base64Start !== -1) {\n              const mimeType = dataUrl.substring(5, base64Start)\n              const data = dataUrl.substring(base64Start + 8)\n              parts.push({ inlineData: { mimeType, data } })\n            }\n          } catch (e) {\n            _ulogError('[LLM Vision] Google 图片转换失败:', e)\n          }\n        }\n        if (textPrompt) parts.push({ text: textPrompt })\n\n        const response = await ai.models.generateContent({\n          model: resolvedModelId,\n          contents: [{ role: 'user', parts }],\n          config: { temperature },\n        })\n\n        const text = extractGoogleText(response)\n        const usage = extractGoogleUsage(response)\n        llmLogger.info({\n          action: 'llm.vision.success',\n          message: 'llm vision call succeeded',\n          provider: 'google',\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n            imageCount: imageUrls.length,\n          },\n        })\n        const completion = buildOpenAIChatCompletion(resolvedModelId, text, usage)\n        recordCompletionUsage(resolvedModelId, completion)\n        return completion\n      }\n\n      if (providerKey === 'ark') {\n        const apiKey = providerConfig.apiKey\n        const { normalizeToBase64ForGeneration } = await import('@/lib/media/outbound-image')\n\n        const content: ArkVisionContentItem[] = []\n        for (const url of imageUrls) {\n          let finalUrl = url\n          try {\n            if (!url.startsWith('http') && !url.startsWith('data:')) {\n              finalUrl = await normalizeToBase64ForGeneration(url)\n            } else if (url.startsWith('/')) {\n              finalUrl = await normalizeToBase64ForGeneration(url)\n            }\n          } catch (e) {\n            _ulogError('[LLM Vision] Ark 图片转换失败:', e)\n          }\n          content.push({ type: 'input_image', image_url: finalUrl })\n        }\n        if (textPrompt) {\n          content.push({ type: 'input_text', text: textPrompt })\n        }\n\n        const thinkingType = reasoning ? 'enabled' : 'disabled'\n        const { text, usage } = await arkResponsesCompletion({\n          apiKey,\n          model: resolvedModelId,\n          input: [{ role: 'user', content }],\n          thinking: { type: thinkingType },\n        })\n\n        llmLogger.info({\n          action: 'llm.vision.success',\n          message: 'llm vision call succeeded',\n          provider: 'ark',\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n            imageCount: imageUrls.length,\n          },\n        })\n        const completion = buildOpenAIChatCompletion(resolvedModelId, text, usage)\n        recordCompletionUsage(resolvedModelId, completion)\n        return completion\n      }\n\n      if (providerKey === 'bailian') {\n        const prompt = textPrompt || 'analyze vision content'\n        const completion = await completeBailianLlm({\n          modelId: resolvedModelId,\n          apiKey: providerConfig.apiKey,\n          baseUrl: providerConfig.baseUrl,\n          messages: [{ role: 'user', content: prompt }],\n          temperature,\n        })\n        recordCompletionUsage(resolvedModelId, completion)\n        llmLogger.info({\n          action: 'llm.vision.success',\n          message: 'llm vision call succeeded',\n          provider: providerKey,\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n            imageCount: imageUrls.length,\n          },\n        })\n        return completion\n      }\n\n      if (providerKey === 'siliconflow') {\n        const prompt = textPrompt || 'analyze vision content'\n        const completion = await completeSiliconFlowLlm({\n          modelId: resolvedModelId,\n          apiKey: providerConfig.apiKey,\n          baseUrl: providerConfig.baseUrl,\n          messages: [{ role: 'user', content: prompt }],\n          temperature,\n        })\n        recordCompletionUsage(resolvedModelId, completion)\n        llmLogger.info({\n          action: 'llm.vision.success',\n          message: 'llm vision call succeeded',\n          provider: providerKey,\n          durationMs: Date.now() - attemptStartedAt,\n          details: {\n            model: resolvedModelId,\n            attempt,\n            maxRetries,\n            imageCount: imageUrls.length,\n          },\n        })\n        return completion\n      }\n\n      const config = providerConfig\n      if (!config.baseUrl) {\n        throw new Error(`PROVIDER_BASE_URL_MISSING: ${provider} (llm)`)\n      }\n\n      const client = new OpenAI({\n        baseURL: config.baseUrl,\n        apiKey: config.apiKey,\n      })\n\n      const content: OpenAiVisionContentItem[] = []\n      if (textPrompt) content.push({ type: 'text', text: textPrompt })\n\n      for (const url of imageUrls) {\n        let finalUrl = url\n        if (url.startsWith('/api/files/') || url.startsWith('/')) {\n          try {\n            const { normalizeToBase64ForGeneration } = await import('@/lib/media/outbound-image')\n            finalUrl = await normalizeToBase64ForGeneration(url)\n            _ulogInfo('[LLM Vision] 转换本地图片为 Base64')\n          } catch (e) {\n            _ulogError('[LLM Vision] 转换本地图片失败:', e)\n            const baseUrl = getInternalBaseUrl()\n            finalUrl = `${baseUrl}${url}`\n          }\n        }\n        content.push({ type: 'image_url', image_url: { url: finalUrl } })\n      }\n\n      const completion = await client.chat.completions.create({\n        model: resolvedModelId,\n        messages: [{ role: 'user', content }],\n        temperature,\n      })\n      recordCompletionUsage(resolvedModelId, completion as OpenAI.Chat.Completions.ChatCompletion)\n      llmLogger.info({\n        action: 'llm.vision.success',\n        message: 'llm vision call succeeded',\n        provider,\n        durationMs: Date.now() - attemptStartedAt,\n        details: {\n          model: resolvedModelId,\n          attempt,\n          maxRetries,\n          imageCount: imageUrls.length,\n        },\n      })\n      return completion\n    } catch (error: unknown) {\n      lastError = error instanceof Error ? error : new Error(getErrorMessage(error))\n      const errorMessage = getErrorMessage(error)\n      llmLogger.warn({\n        action: 'llm.vision.attempt_failed',\n        message: errorMessage || 'llm vision attempt failed',\n        provider,\n        durationMs: Date.now() - attemptStartedAt,\n        details: {\n          model: resolvedModelId,\n          attempt,\n          maxRetries,\n          imageCount: imageUrls.length,\n        },\n      })\n      const errorBody = getErrorBody(error)\n      if (errorBody?.message === 'PROHIBITED_CONTENT' || errorBody?.code === 502) {\n        _ulogError('[LLM Vision] ❌ 内容安全检测失败 - Google AI Studio 拒绝处理此内容')\n        throw new Error('SENSITIVE_CONTENT: 图片或提示词包含敏感信息,无法处理')\n      }\n\n      _ulogWarn(`[LLM Vision] 调用失败 (${attempt}/${maxRetries + 1}): ${errorMessage}`)\n      if (!isRetryableError(error) || attempt > maxRetries) break\n      const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 5000)\n      await new Promise((resolve) => setTimeout(resolve, delayMs))\n    }\n  }\n  throw lastError || new Error('LLM Vision 调用失败')\n}\n\nexport async function chatCompletionWithVisionStream(\n  userId: string,\n  model: string | null | undefined,\n  textPrompt: string,\n  imageUrls: string[] = [],\n  options: ChatCompletionOptions = {},\n  callbacks?: ChatCompletionStreamCallbacks,\n): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  callbacks?.onStage?.({ stage: 'submit' })\n  try {\n    callbacks?.onStage?.({ stage: 'fallback' })\n    const completion = await chatCompletionWithVision(userId, model, textPrompt, imageUrls, {\n      ...options,\n      __skipAutoStream: true,\n    })\n    const completionParts = getCompletionParts(completion)\n    let seq = 1\n    if (completionParts.reasoning) {\n      seq = emitChunkedText(completionParts.reasoning, callbacks, 'reasoning', seq)\n    }\n    emitChunkedText(completionParts.text, callbacks, 'text', seq)\n    callbacks?.onStage?.({ stage: 'completed' })\n    callbacks?.onComplete?.(completionParts.text)\n    return completion\n  } catch (error) {\n    callbacks?.onError?.(error, undefined)\n    throw error\n  }\n}\n"
  },
  {
    "path": "src/lib/llm-client.ts",
    "content": "export type { ChatCompletionOptions, ChatCompletionStreamCallbacks } from './llm/types'\nexport {\n  chatCompletion,\n  chatCompletionStream,\n  chatCompletionWithVision,\n  chatCompletionWithVisionStream,\n  getCompletionContent,\n  getCompletionParts,\n} from './llm/runtime'\n"
  },
  {
    "path": "src/lib/llm-observe/config.ts",
    "content": "export type LLMObserveDisplayMode = 'loading' | 'detail'\n\nfunction parseBoolean(value: string | undefined, fallback: boolean) {\n  if (value == null) return fallback\n  const v = value.trim().toLowerCase()\n  if (v === '1' || v === 'true' || v === 'yes' || v === 'on') return true\n  if (v === '0' || v === 'false' || v === 'no' || v === 'off') return false\n  return fallback\n}\n\nfunction parseNumber(value: string | undefined, fallback: number) {\n  if (value == null) return fallback\n  const n = Number.parseInt(value, 10)\n  return Number.isFinite(n) ? n : fallback\n}\n\nfunction parseMode(value: string | undefined, fallback: LLMObserveDisplayMode): LLMObserveDisplayMode {\n  if (value === 'detail' || value === 'loading') return value\n  return fallback\n}\n\nexport const LLM_OBSERVE_ENABLED = parseBoolean(\n  process.env.LLM_OBSERVE_ENABLED ?? process.env.NEXT_PUBLIC_LLM_OBSERVE_ENABLED,\n  true,\n)\nexport const LLM_OBSERVE_DEFAULT_MODE = parseMode(\n  process.env.LLM_OBSERVE_DEFAULT_MODE ?? process.env.NEXT_PUBLIC_LLM_OBSERVE_DEFAULT_MODE,\n  'loading',\n)\nexport const LLM_OBSERVE_LONG_TASK_THRESHOLD_MS = parseNumber(\n  process.env.LLM_OBSERVE_LONG_TASK_THRESHOLD_MS ?? process.env.NEXT_PUBLIC_LLM_OBSERVE_LONG_TASK_THRESHOLD_MS,\n  8000,\n)\nexport const LLM_OBSERVE_REASONING_VISIBLE = parseBoolean(\n  process.env.LLM_OBSERVE_REASONING_VISIBLE ?? process.env.NEXT_PUBLIC_LLM_OBSERVE_REASONING_VISIBLE,\n  true,\n)\nexport const INTERNAL_TASK_TOKEN = process.env.INTERNAL_TASK_TOKEN || ''\nexport const INTERNAL_TASK_API_BASE_URL =\n  process.env.INTERNAL_TASK_API_BASE_URL\n  || process.env.INTERNAL_APP_URL\n  || process.env.NEXTAUTH_URL\n  || 'http://127.0.0.1:3000'\n"
  },
  {
    "path": "src/lib/llm-observe/internal-stream-context.ts",
    "content": "import { AsyncLocalStorage } from 'node:async_hooks'\nimport type { LLMStreamKind } from './types'\n\nexport type InternalLLMStreamStepMeta = {\n  id?: string | null\n  attempt?: number | null\n  title?: string | null\n  index?: number | null\n  total?: number | null\n}\n\nexport type InternalLLMStreamCallbacks = {\n  onStage?: (stage: {\n    stage: 'submit' | 'streaming' | 'fallback' | 'completed'\n    provider?: string | null\n    step?: InternalLLMStreamStepMeta\n  }) => void\n  onChunk?: (chunk: {\n    kind: LLMStreamKind\n    delta: string\n    seq: number\n    lane?: string | null\n    step?: InternalLLMStreamStepMeta\n  }) => void\n  onComplete?: (text: string, step?: InternalLLMStreamStepMeta) => void\n  onError?: (error: unknown, step?: InternalLLMStreamStepMeta) => void\n  flush?: () => Promise<void>\n}\n\nconst llmStreamCallbackStore = new AsyncLocalStorage<InternalLLMStreamCallbacks | null>()\n\nexport async function withInternalLLMStreamCallbacks<T>(\n  callbacks: InternalLLMStreamCallbacks | null,\n  fn: () => Promise<T>,\n) {\n  return await llmStreamCallbackStore.run(callbacks, fn)\n}\n\nexport function getInternalLLMStreamCallbacks(): InternalLLMStreamCallbacks | null {\n  return llmStreamCallbackStore.getStore() || null\n}\n"
  },
  {
    "path": "src/lib/llm-observe/internal-task.ts",
    "content": "import type { NextRequest } from 'next/server'\n\n/**\n * Whether current request is executed by internal task worker.\n * Keep consistent with internal auth rules in `api-auth`.\n */\nexport function isInternalTaskExecution(request: NextRequest): boolean {\n  const userId = request.headers.get('x-internal-user-id') || ''\n  if (!userId) return false\n\n  const expectedToken = process.env.INTERNAL_TASK_TOKEN || ''\n  const token = request.headers.get('x-internal-task-token') || ''\n  if (expectedToken) {\n    return token === expectedToken\n  }\n\n  return process.env.NODE_ENV !== 'production'\n}\n"
  },
  {
    "path": "src/lib/llm-observe/route-task.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\nimport { getRequestId } from '@/lib/api-errors'\nimport { submitTask } from '@/lib/task/submitter'\nimport { TASK_TYPE, type TaskType } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo, isBillableTaskType } from '@/lib/billing'\nimport { LLM_OBSERVE_DEFAULT_MODE, LLM_OBSERVE_ENABLED } from './config'\nimport type { LLMObserveDisplayMode } from './config'\nimport { getLLMTaskPolicy } from './task-policy'\nimport { getTaskFlowMeta } from './stage-pipeline'\nimport { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale'\nimport { getProjectModelConfig, getUserModelConfig } from '@/lib/config-service'\n\nexport function toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nexport function parseSyncFlag(value: unknown): boolean {\n  if (value === true || value === 1 || value === '1') return true\n  if (typeof value !== 'string') return false\n  const normalized = value.trim().toLowerCase()\n  return normalized === 'true' || normalized === 'yes' || normalized === 'on'\n}\n\nexport function resolveDisplayMode(value: unknown, fallback: LLMObserveDisplayMode): LLMObserveDisplayMode {\n  if (value === 'detail' || value === 'loading') return value\n  return fallback\n}\n\nexport function resolvePositiveInteger(value: unknown, fallback: number): number {\n  if (typeof value === 'number' && Number.isFinite(value) && value > 0) {\n    return Math.floor(value)\n  }\n  if (typeof value === 'string' && value.trim()) {\n    const parsed = Number.parseInt(value, 10)\n    if (Number.isFinite(parsed) && parsed > 0) {\n      return parsed\n    }\n  }\n  return fallback\n}\n\nexport function shouldRunSyncTask(request: NextRequest, body?: unknown) {\n  if (request.headers.get('x-internal-task-id')) return true\n  const payload = toObject(body)\n  if (parseSyncFlag(payload.sync)) return true\n  if (parseSyncFlag(request.nextUrl.searchParams.get('sync'))) return true\n  return false\n}\n\nfunction shouldRunAsyncTask(request: NextRequest, body?: unknown) {\n  const payload = toObject(body)\n  if (parseSyncFlag(payload.async)) return true\n  if (parseSyncFlag(request.nextUrl.searchParams.get('async'))) return true\n  return false\n}\n\nexport async function maybeSubmitLLMTask(params: {\n  request: NextRequest\n  userId: string\n  projectId: string\n  episodeId?: string | null\n  type: TaskType\n  targetType: string\n  targetId: string\n  routePath: string\n  body?: unknown\n  dedupeKey?: string | null\n  priority?: number\n}) {\n  const policy = getLLMTaskPolicy(params.type)\n  const observeEnabled = LLM_OBSERVE_ENABLED || policy.consoleEnabled\n  if (!observeEnabled) return null\n  if (!policy.consoleEnabled && !shouldRunAsyncTask(params.request, params.body)) return null\n  if (shouldRunSyncTask(params.request, params.body)) return null\n\n  const payload = toObject(params.body)\n  const displayMode = resolveDisplayMode(\n    payload.displayMode,\n    policy.displayMode || LLM_OBSERVE_DEFAULT_MODE,\n  )\n  const payloadMeta = toObject(payload.meta)\n  const locale = resolveRequiredTaskLocale(params.request, payload)\n  const userTierFromPayload = typeof payloadMeta.userTier === 'string' ? payloadMeta.userTier : null\n  const priority = params.priority ?? policy.priority ?? 0\n  const defaultFlowMeta = getTaskFlowMeta(params.type)\n  const flowId =\n    typeof payload.flowId === 'string' && payload.flowId.trim()\n      ? payload.flowId.trim()\n      : defaultFlowMeta.flowId\n  const flowStageIndex = resolvePositiveInteger(payload.flowStageIndex, defaultFlowMeta.flowStageIndex)\n  const flowStageTotal = resolvePositiveInteger(payload.flowStageTotal, defaultFlowMeta.flowStageTotal)\n  const flowStageTitle =\n    typeof payload.flowStageTitle === 'string' && payload.flowStageTitle.trim()\n      ? payload.flowStageTitle.trim()\n      : defaultFlowMeta.flowStageTitle\n\n  // 确保 payload 中包含真实的 analysisModel，用于精确计费\n  // 根据 worker 实际使用的 model 来源选择对应的配置\n  const hasModel = typeof payload.analysisModel === 'string' && payload.analysisModel.trim()\n    || typeof payload.model === 'string' && payload.model.trim()\n  if (!hasModel && isBillableTaskType(params.type)) {\n    const useUserLevelConfig = params.type === TASK_TYPE.EPISODE_SPLIT_LLM\n      || params.type === TASK_TYPE.REFERENCE_TO_CHARACTER\n    if (useUserLevelConfig) {\n      const userConfig = await getUserModelConfig(params.userId)\n      if (userConfig.analysisModel) {\n        payload.analysisModel = userConfig.analysisModel\n      }\n    } else {\n      const modelConfig = await getProjectModelConfig(params.projectId, params.userId)\n      if (modelConfig.analysisModel) {\n        payload.analysisModel = modelConfig.analysisModel\n      }\n    }\n  }\n\n  const billingInfo = isBillableTaskType(params.type)\n    ? buildDefaultTaskBillingInfo(params.type, payload)\n    : null\n\n  const taskResult = await submitTask({\n    userId: params.userId,\n    locale,\n    requestId: getRequestId(params.request),\n    projectId: params.projectId,\n    episodeId: params.episodeId || null,\n    type: params.type,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    payload: {\n      ...payload,\n      sync: 1,\n      displayMode,\n      flowId,\n      flowStageIndex,\n      flowStageTotal,\n      flowStageTitle,\n      meta: {\n        ...payloadMeta,\n        route: params.routePath,\n        locale,\n        userTier: userTierFromPayload,\n        flowId,\n        flowStageIndex,\n        flowStageTotal,\n        flowStageTitle,\n      },\n    },\n    dedupeKey: params.dedupeKey || null,\n    priority,\n    billingInfo,\n  })\n\n  return NextResponse.json(taskResult)\n}\n"
  },
  {
    "path": "src/lib/llm-observe/stage-pipeline.ts",
    "content": "import { getTaskTypeLabel } from '@/lib/task/progress-message'\nimport { TASK_TYPE } from '@/lib/task/types'\n\nexport type LLMTaskPipelineStage = {\n  id: string\n  taskType: string\n  title: string\n}\n\nexport type LLMTaskPipeline = {\n  id: string\n  stages: LLMTaskPipelineStage[]\n}\n\nexport type LLMTaskFlowMeta = {\n  flowId: string\n  flowStageIndex: number\n  flowStageTotal: number\n  flowStageTitle: string\n}\n\ntype LLMTaskFlowDefinition = {\n  id: string\n  stages: LLMTaskPipelineStage[]\n}\n\nconst FLOW_DEFINITIONS: ReadonlyArray<LLMTaskFlowDefinition> = [\n  {\n    id: 'novel_promotion_generation',\n    stages: [\n      {\n        id: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n        taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n        title: getTaskTypeLabel(TASK_TYPE.STORY_TO_SCRIPT_RUN),\n      },\n      {\n        id: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n        taskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n        title: getTaskTypeLabel(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN),\n      },\n    ],\n  },\n  {\n    id: 'novel_promotion_ai_create_character',\n    stages: [\n      {\n        id: TASK_TYPE.AI_CREATE_CHARACTER,\n        taskType: TASK_TYPE.AI_CREATE_CHARACTER,\n        title: getTaskTypeLabel(TASK_TYPE.AI_CREATE_CHARACTER),\n      },\n    ],\n  },\n  {\n    id: 'novel_promotion_ai_create_location',\n    stages: [\n      {\n        id: TASK_TYPE.AI_CREATE_LOCATION,\n        taskType: TASK_TYPE.AI_CREATE_LOCATION,\n        title: getTaskTypeLabel(TASK_TYPE.AI_CREATE_LOCATION),\n      },\n    ],\n  },\n  {\n    id: 'asset_hub_ai_design_character',\n    stages: [\n      {\n        id: TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER,\n        taskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER,\n        title: getTaskTypeLabel(TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER),\n      },\n    ],\n  },\n  {\n    id: 'asset_hub_ai_design_location',\n    stages: [\n      {\n        id: TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,\n        taskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,\n        title: getTaskTypeLabel(TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION),\n      },\n    ],\n  },\n]\n\nconst FLOW_BY_ID: Record<string, LLMTaskFlowDefinition> = FLOW_DEFINITIONS.reduce(\n  (acc, flow) => {\n    acc[flow.id] = flow\n    return acc\n  },\n  {} as Record<string, LLMTaskFlowDefinition>,\n)\n\nconst FLOW_META_BY_TASK_TYPE: Record<string, LLMTaskFlowMeta> = FLOW_DEFINITIONS.reduce(\n  (acc, flow) => {\n    flow.stages.forEach((stage, index) => {\n      acc[stage.taskType] = {\n        flowId: flow.id,\n        flowStageIndex: index + 1,\n        flowStageTotal: flow.stages.length,\n        flowStageTitle: stage.title,\n      }\n    })\n    return acc\n  },\n  {} as Record<string, LLMTaskFlowMeta>,\n)\n\nfunction createSingleStageMeta(taskType: string): LLMTaskFlowMeta {\n  return {\n    flowId: `single:${taskType}`,\n    flowStageIndex: 1,\n    flowStageTotal: 1,\n    flowStageTitle: getTaskTypeLabel(taskType),\n  }\n}\n\nfunction createSingleStagePipeline(taskType: string): LLMTaskPipeline {\n  const meta = createSingleStageMeta(taskType)\n  return {\n    id: meta.flowId,\n    stages: [\n      {\n        id: taskType,\n        taskType,\n        title: meta.flowStageTitle,\n      },\n    ],\n  }\n}\n\nfunction clonePipeline(pipeline: LLMTaskPipeline): LLMTaskPipeline {\n  return {\n    id: pipeline.id,\n    stages: pipeline.stages.map((stage) => ({ ...stage })),\n  }\n}\n\nexport function getTaskFlowMeta(taskType: string | null | undefined): LLMTaskFlowMeta {\n  if (!taskType) return createSingleStageMeta('llm_task')\n  return FLOW_META_BY_TASK_TYPE[taskType] || createSingleStageMeta(taskType)\n}\n\nexport function getTaskPipelineByFlowId(\n  flowId: string | null | undefined,\n  fallbackTaskType: string | null | undefined,\n): LLMTaskPipeline {\n  if (!flowId) return createSingleStagePipeline(fallbackTaskType || 'llm_task')\n  const pipeline = FLOW_BY_ID[flowId]\n  if (!pipeline) {\n    return createSingleStagePipeline(fallbackTaskType || flowId)\n  }\n  return clonePipeline(pipeline)\n}\n\nexport function getTaskPipeline(taskType: string | null | undefined): LLMTaskPipeline {\n  const meta = getTaskFlowMeta(taskType)\n  return getTaskPipelineByFlowId(meta.flowId, taskType)\n}\n"
  },
  {
    "path": "src/lib/llm-observe/task-policy.ts",
    "content": "import { TASK_TYPE, type TaskType } from '@/lib/task/types'\nimport type { LLMObserveDisplayMode } from './config'\n\nexport type LLMTaskPolicy = {\n  consoleEnabled: boolean\n  displayMode: LLMObserveDisplayMode\n  fullscreen: boolean\n  priority: number\n  captureReasoning: boolean\n}\n\nconst DEFAULT_POLICY: LLMTaskPolicy = {\n  consoleEnabled: false,\n  displayMode: 'loading',\n  fullscreen: false,\n  priority: 0,\n  captureReasoning: false,\n}\n\nconst LONG_FLOW_POLICY: LLMTaskPolicy = {\n  consoleEnabled: true,\n  displayMode: 'detail',\n  fullscreen: true,\n  priority: 1,\n  captureReasoning: true,\n}\n\nconst LONG_FLOW_HIGH_POLICY: LLMTaskPolicy = {\n  ...LONG_FLOW_POLICY,\n  priority: 2,\n}\n\nconst LLM_STANDARD_POLICY: LLMTaskPolicy = {\n  consoleEnabled: true,\n  displayMode: 'loading',\n  fullscreen: false,\n  priority: 0,\n  captureReasoning: true,\n}\n\nconst POLICY_BY_TASK_TYPE: Partial<Record<TaskType, LLMTaskPolicy>> = {\n  [TASK_TYPE.REGENERATE_STORYBOARD_TEXT]: LONG_FLOW_HIGH_POLICY,\n  [TASK_TYPE.INSERT_PANEL]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.ANALYZE_NOVEL]: LONG_FLOW_POLICY,\n  [TASK_TYPE.STORY_TO_SCRIPT_RUN]: LONG_FLOW_HIGH_POLICY,\n  [TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN]: LONG_FLOW_HIGH_POLICY,\n  [TASK_TYPE.CLIPS_BUILD]: LONG_FLOW_POLICY,\n  [TASK_TYPE.SCREENPLAY_CONVERT]: LONG_FLOW_HIGH_POLICY,\n  [TASK_TYPE.VOICE_ANALYZE]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.ANALYZE_GLOBAL]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.AI_MODIFY_APPEARANCE]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.AI_MODIFY_LOCATION]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.ANALYZE_SHOT_VARIANTS]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.AI_CREATE_CHARACTER]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.AI_CREATE_LOCATION]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.REFERENCE_TO_CHARACTER]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.CHARACTER_PROFILE_CONFIRM]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.EPISODE_SPLIT_LLM]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: LLM_STANDARD_POLICY,\n  [TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: LLM_STANDARD_POLICY,\n}\n\nexport function getLLMTaskPolicy(taskType: string | null | undefined): LLMTaskPolicy {\n  if (!taskType) return DEFAULT_POLICY\n  return POLICY_BY_TASK_TYPE[taskType as TaskType] || DEFAULT_POLICY\n}\n"
  },
  {
    "path": "src/lib/llm-observe/types.ts",
    "content": "import type { LLMObserveDisplayMode } from './config'\n\nexport type LLMStreamKind = 'text' | 'reasoning'\n\nexport type LLMStreamChunk = {\n  kind: LLMStreamKind\n  delta: string\n  seq: number\n  lane?: string | null\n}\n\nexport type LLMObserveMeta = {\n  route?: string | null\n  provider?: string | null\n  episodeId?: string | null\n  clipId?: string | null\n}\n\nexport type LLMObservePayload = {\n  displayMode?: LLMObserveDisplayMode\n  message?: string\n  stage?: string\n  stageLabel?: string\n  flowId?: string\n  flowStageIndex?: number\n  flowStageTotal?: number\n  flowStageTitle?: string\n  streamRunId?: string\n  progress?: number\n  stream?: LLMStreamChunk\n  meta?: LLMObserveMeta\n  done?: boolean\n  [key: string]: unknown\n}\n"
  },
  {
    "path": "src/lib/logging/config.ts",
    "content": "import type { LogLevel } from './types'\n\nconst LEVEL_WEIGHT: Record<LogLevel, number> = {\n  DEBUG: 10,\n  INFO: 20,\n  WARN: 30,\n  ERROR: 40,\n}\n\nfunction parseLevel(value?: string | null): LogLevel {\n  const upper = (value || 'ERROR').trim().toUpperCase()\n  if (upper === 'DEBUG' || upper === 'INFO' || upper === 'WARN' || upper === 'ERROR') {\n    return upper\n  }\n  return 'ERROR'\n}\n\nfunction parseBoolean(value?: string | null, fallback = false): boolean {\n  if (value == null) return fallback\n  const normalized = value.trim().toLowerCase()\n  return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'\n}\n\nexport const LOG_CONFIG = {\n  enabled: parseBoolean(process.env.LOG_UNIFIED_ENABLED, true),\n  level: parseLevel(process.env.LOG_LEVEL),\n  debugEnabled: parseBoolean(process.env.LOG_DEBUG_ENABLED, false),\n  auditEnabled: parseBoolean(process.env.LOG_AUDIT_ENABLED, true),\n  format: (process.env.LOG_FORMAT || 'json').trim().toLowerCase(),\n  service: (process.env.LOG_SERVICE || 'waoowaoo').trim(),\n  redactKeys: (process.env.LOG_REDACT_KEYS || 'password,token,apiKey,apikey,authorization,cookie,secret,access_token,refresh_token')\n    .split(',')\n    .map((k) => k.trim().toLowerCase())\n    .filter(Boolean),\n} as const\n\nexport function shouldLogLevel(level: LogLevel): boolean {\n  if (!LOG_CONFIG.enabled) return false\n  if (level === 'DEBUG' && !LOG_CONFIG.debugEnabled) return false\n  return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[LOG_CONFIG.level]\n}\n"
  },
  {
    "path": "src/lib/logging/context.ts",
    "content": "import type { LogContext } from './types'\ntype AsyncStorageLike<T> = {\n  run<R>(store: T, callback: () => R): R\n  getStore(): T | undefined\n  enterWith(store: T): void\n}\n\nfunction createLogContextStorage(): AsyncStorageLike<LogContext> | null {\n  if (typeof window !== 'undefined') return null\n  try {\n    const runtime = globalThis as typeof globalThis & {\n      __non_webpack_require__?: (id: string) => unknown\n    }\n    const fallbackRequire = new Function('return typeof require !== \"undefined\" ? require : null')() as\n      | ((id: string) => unknown)\n      | null\n    const requireFn = runtime.__non_webpack_require__ || fallbackRequire\n    if (!requireFn) return null\n\n    const asyncHooks = requireFn('async' + '_hooks') as {\n      AsyncLocalStorage: new <T>() => AsyncStorageLike<T>\n    }\n    return new asyncHooks.AsyncLocalStorage<LogContext>()\n  } catch {\n    return null\n  }\n}\n\nconst logContextStorage = createLogContextStorage()\nlet fallbackContext: LogContext = {}\n\nfunction getCurrentContext(): LogContext {\n  return logContextStorage?.getStore() || fallbackContext\n}\n\nexport function withLogContext<T>(context: LogContext, fn: () => Promise<T>): Promise<T>\nexport function withLogContext<T>(context: LogContext, fn: () => T): T\nexport function withLogContext<T>(context: LogContext, fn: () => T | Promise<T>): T | Promise<T> {\n  const merged = { ...getCurrentContext(), ...context }\n  if (logContextStorage) {\n    return logContextStorage.run(merged, fn)\n  }\n\n  const previous = fallbackContext\n  fallbackContext = merged\n  const result = fn()\n  if (result && typeof (result as PromiseLike<T>).then === 'function') {\n    return (result as Promise<T>).finally(() => {\n      fallbackContext = previous\n    })\n  }\n  fallbackContext = previous\n  return result\n}\n\nexport function getLogContext(): LogContext {\n  return getCurrentContext()\n}\n\nexport function setLogContext(context: Partial<LogContext>): void {\n  const merged = {\n    ...getCurrentContext(),\n    ...context,\n  }\n\n  if (logContextStorage) {\n    logContextStorage.enterWith(merged)\n    return\n  }\n\n  fallbackContext = merged\n}\n"
  },
  {
    "path": "src/lib/logging/core.ts",
    "content": "import { LOG_CONFIG, shouldLogLevel } from './config'\nimport { getLogContext } from './context'\nimport { redactValue } from './redact'\nimport type { ErrorFields, LogContext, LogEvent, LogLevel, SemanticContext } from './types'\n\ntype FileWriterModule = typeof import('./file-writer')\n\nlet fileWriterModulePromise: Promise<FileWriterModule> | null = null\n\nconst SUPPRESSED_LOG_ACTIONS = new Set<string>(['worker.progress.stream'])\n\nfunction shouldSuppressLogEvent(event: Pick<LogEvent, 'action'>): boolean {\n  if (!event.action) return false\n  return SUPPRESSED_LOG_ACTIONS.has(event.action)\n}\n\nfunction writeProjectLogLine(line: string, projectId: string | undefined, moduleName: string | undefined): void {\n  if (typeof window !== 'undefined') return\n  if (!fileWriterModulePromise) {\n    fileWriterModulePromise = import('./file-writer')\n  }\n  // 全局日志（所有日志都写入 app.log）\n  void fileWriterModulePromise\n    .then((mod) => mod.writeGlobalLogLine(line))\n    .catch(() => undefined)\n  // 项目日志\n  if (projectId) {\n    void fileWriterModulePromise\n      .then((mod) => mod.writeLogToProjectFile(line, projectId, moduleName))\n      .catch(() => undefined)\n  }\n}\n\nfunction serializeError(error: unknown): ErrorFields | undefined {\n  if (!error) return undefined\n  if (error instanceof Error) {\n    const maybeCode = (error as Error & { code?: unknown }).code\n    return {\n      name: error.name,\n      message: error.message,\n      stack: error.stack,\n      code: typeof maybeCode === 'string' ? maybeCode : undefined,\n    }\n  }\n  if (typeof error === 'object') {\n    const obj = error as Record<string, unknown>\n    return {\n      name: typeof obj.name === 'string' ? obj.name : 'Error',\n      message: typeof obj.message === 'string' ? obj.message : JSON.stringify(obj),\n      code: typeof obj.code === 'string' ? obj.code : undefined,\n    }\n  }\n  return { message: String(error) }\n}\n\nfunction normalizeDetails(args: unknown[]): Record<string, unknown> | unknown[] | null {\n  if (args.length === 0) return null\n  if (args.length === 1) {\n    return (args[0] as Record<string, unknown> | unknown[] | null | undefined) ?? null\n  }\n  return args\n}\n\nfunction parseLogArgs(args: unknown[]): { message: string; details: Record<string, unknown> | unknown[] | null; error?: ErrorFields } {\n  if (args.length === 0) {\n    return { message: '', details: null }\n  }\n\n  const [first, ...rest] = args\n  const maybeError = [...args].find((item) => item instanceof Error)\n  const error = maybeError ? serializeError(maybeError) : undefined\n\n  if (typeof first === 'string') {\n    return {\n      message: first,\n      details: normalizeDetails(rest),\n      error,\n    }\n  }\n\n  return {\n    message: 'log',\n    details: normalizeDetails(args),\n    error,\n  }\n}\n\nfunction nowChinaISOString(): string {\n  const now = new Date()\n  // UTC+8 偏移量（毫秒）\n  const offsetMs = 8 * 60 * 60 * 1000\n  const cstTime = new Date(now.getTime() + offsetMs)\n  // toISOString() 返回 UTC 格式，替换 Z 为 +08:00 即为北京时间\n  return cstTime.toISOString().replace('Z', '+08:00')\n}\n\nfunction write(level: LogLevel, event: Omit<LogEvent, 'ts' | 'level' | 'service'>): void {\n  const shouldEmitByLevel = shouldLogLevel(level)\n  const shouldEmitAudit = Boolean(event.audit) && LOG_CONFIG.auditEnabled\n  if (!shouldEmitByLevel && !shouldEmitAudit) return\n\n  const context = getLogContext()\n  const merged: LogEvent = {\n    ts: nowChinaISOString(),\n    level,\n    service: LOG_CONFIG.service,\n    audit: event.audit ?? false,\n    module: event.module || context.module,\n    action: event.action || context.action,\n    message: event.message,\n    requestId: event.requestId || context.requestId,\n    taskId: event.taskId || context.taskId,\n    projectId: event.projectId || context.projectId,\n    userId: event.userId || context.userId,\n    errorCode: event.errorCode,\n    retryable: event.retryable,\n    durationMs: event.durationMs,\n    provider: event.provider || context.provider,\n    details: event.details ?? null,\n    error: event.error,\n  }\n\n  const safeEvent = redactValue(merged, [...LOG_CONFIG.redactKeys]) as LogEvent\n  if (shouldSuppressLogEvent(safeEvent)) return\n  const line = JSON.stringify(safeEvent)\n  if (level === 'ERROR') {\n    console.error(line)\n  } else {\n    console.log(line)\n  }\n  writeProjectLogLine(line, safeEvent.projectId || undefined, safeEvent.module || undefined)\n}\n\nexport function logEvent(event: Partial<LogEvent> & { level: LogLevel; message: string }): void {\n  const { level, ...rest } = event\n  write(level, {\n    audit: rest.audit ?? false,\n    message: rest.message,\n    module: rest.module,\n    action: rest.action,\n    requestId: rest.requestId,\n    taskId: rest.taskId,\n    projectId: rest.projectId,\n    userId: rest.userId,\n    errorCode: rest.errorCode,\n    retryable: rest.retryable,\n    durationMs: rest.durationMs,\n    provider: rest.provider,\n    details: rest.details ?? null,\n    error: rest.error,\n  })\n}\n\nfunction logWithLevel(level: LogLevel, context: Partial<LogContext> | undefined, args: unknown[]): void {\n  const { message, details, error } = parseLogArgs(args)\n  write(level, {\n    audit: false,\n    message,\n    module: context?.module,\n    action: context?.action,\n    requestId: context?.requestId,\n    taskId: context?.taskId,\n    projectId: context?.projectId,\n    userId: context?.userId,\n    provider: context?.provider,\n    details,\n    error,\n  })\n}\n\ntype ScopedLogInput = {\n  audit?: boolean\n  message: string\n  action?: string\n  module?: string\n  requestId?: string\n  taskId?: string\n  projectId?: string\n  userId?: string\n  provider?: string\n  errorCode?: string\n  retryable?: boolean\n  durationMs?: number\n  details?: Record<string, unknown> | unknown[] | null\n  error?: ErrorFields\n}\n\ntype ScopedLogFn = (...args: unknown[]) => void\n\nfunction isScopedLogInput(value: unknown): value is ScopedLogInput {\n  return Boolean(value) && typeof value === 'object' && typeof (value as { message?: unknown }).message === 'string'\n}\n\nfunction logScoped(level: LogLevel, baseContext: Partial<SemanticContext>, args: unknown[]): void {\n  if (args.length === 1 && isScopedLogInput(args[0])) {\n    const input = args[0]\n    write(level, {\n      audit: input.audit ?? false,\n      message: input.message,\n      module: input.module || baseContext.module,\n      action: input.action || baseContext.action,\n      requestId: input.requestId || baseContext.requestId,\n      taskId: input.taskId || baseContext.taskId,\n      projectId: input.projectId || baseContext.projectId,\n      userId: input.userId || baseContext.userId,\n      provider: input.provider || baseContext.provider,\n      errorCode: input.errorCode || baseContext.errorCode,\n      retryable: input.retryable ?? baseContext.retryable,\n      durationMs: input.durationMs ?? baseContext.durationMs,\n      details: input.details ?? null,\n      error: input.error,\n    })\n    return\n  }\n\n  const { message, details, error } = parseLogArgs(args)\n  write(level, {\n    audit: false,\n    message,\n    module: baseContext.module,\n    action: baseContext.action,\n    requestId: baseContext.requestId,\n    taskId: baseContext.taskId,\n    projectId: baseContext.projectId,\n    userId: baseContext.userId,\n    provider: baseContext.provider,\n    errorCode: baseContext.errorCode,\n    retryable: baseContext.retryable,\n    durationMs: baseContext.durationMs,\n    details,\n    error,\n  })\n}\n\nexport function logDebug(...args: unknown[]): void {\n  logWithLevel('DEBUG', undefined, args)\n}\n\nexport function logInfo(...args: unknown[]): void {\n  logWithLevel('INFO', undefined, args)\n}\n\nexport function logWarn(...args: unknown[]): void {\n  logWithLevel('WARN', undefined, args)\n}\n\nexport function logError(...args: unknown[]): void {\n  logWithLevel('ERROR', undefined, args)\n}\n\nexport function logDebugCtx(context: Partial<LogContext>, ...args: unknown[]): void {\n  logWithLevel('DEBUG', context, args)\n}\n\nexport function logInfoCtx(context: Partial<LogContext>, ...args: unknown[]): void {\n  logWithLevel('INFO', context, args)\n}\n\nexport function logWarnCtx(context: Partial<LogContext>, ...args: unknown[]): void {\n  logWithLevel('WARN', context, args)\n}\n\nexport function logErrorCtx(context: Partial<LogContext>, ...args: unknown[]): void {\n  logWithLevel('ERROR', context, args)\n}\n\nexport type ScopedLogger = {\n  debug: ScopedLogFn\n  info: ScopedLogFn\n  warn: ScopedLogFn\n  error: ScopedLogFn\n  event: (event: ScopedLogInput & { level: LogLevel }) => void\n  child: (context: Partial<SemanticContext>) => ScopedLogger\n}\n\nexport function createScopedLogger(baseContext: Partial<SemanticContext>): ScopedLogger {\n  return {\n    debug: (...args: unknown[]) => logScoped('DEBUG', baseContext, args),\n    info: (...args: unknown[]) => logScoped('INFO', baseContext, args),\n    warn: (...args: unknown[]) => logScoped('WARN', baseContext, args),\n    error: (...args: unknown[]) => logScoped('ERROR', baseContext, args),\n    event: (event) => {\n      write(event.level, {\n        audit: event.audit ?? false,\n        message: event.message,\n        module: event.module || baseContext.module,\n        action: event.action || baseContext.action,\n        requestId: event.requestId || baseContext.requestId,\n        taskId: event.taskId || baseContext.taskId,\n        projectId: event.projectId || baseContext.projectId,\n        userId: event.userId || baseContext.userId,\n        provider: event.provider || baseContext.provider,\n        errorCode: event.errorCode || baseContext.errorCode,\n        retryable: event.retryable ?? baseContext.retryable,\n        durationMs: event.durationMs ?? baseContext.durationMs,\n        details: event.details ?? null,\n        error: event.error,\n      })\n    },\n    child: (context: Partial<SemanticContext>) => createScopedLogger({ ...baseContext, ...context }),\n  }\n}\n"
  },
  {
    "path": "src/lib/logging/file-writer.ts",
    "content": "/**\n * Server-side log file writer.\n *\n * Routes log events to per-project log files following the naming convention:\n *   - `admin_{projectName}.log`    – API / user-facing operations\n *   - `Internal_{projectName}.log` – worker / internal operations\n *\n * This module is Edge-safe at import-time: all Node.js APIs are accessed via\n * async dynamic `import('node:fs')` calls that only run at write-time.\n *\n * The writer is intentionally fire-and-forget: callers should never await it\n * and logging failures should never crash the application.\n */\n\n// ─── environment guard ────────────────────────────────────────────────\n\nfunction isEdgeOrBrowser(): boolean {\n    if (typeof window !== 'undefined') return true\n    const g = globalThis as { EdgeRuntime?: unknown }\n    return typeof g.EdgeRuntime === 'string'\n}\n\n// ─── node module cache ────────────────────────────────────────────────\n// We cache lazily so the module stays Edge-safe at import time.\n\ntype NodeModules = {\n    fs: typeof import('node:fs')\n    path: typeof import('node:path')\n    cwd: string\n}\n\nlet nodeModulesCache: NodeModules | null | 'pending' | undefined\n\nasync function getNodeModules(): Promise<NodeModules | null> {\n    if (nodeModulesCache === null) return null\n    if (nodeModulesCache && nodeModulesCache !== 'pending') return nodeModulesCache\n    if (isEdgeOrBrowser()) {\n        nodeModulesCache = null\n        return null\n    }\n\n    // Only one concurrent initialisation\n    if (nodeModulesCache === 'pending') {\n        // Another call is already initialising – yield and retry\n        await new Promise((r) => setTimeout(r, 0))\n        return getNodeModules()\n    }\n    nodeModulesCache = 'pending'\n\n    try {\n        // 使用 new Function() 间接导入，绕过 Next.js 静态分析器的 Edge Runtime 检查。\n        // 运行时行为与直接 import() 完全一致，但打包器不会静态追踪这些模块。\n        const dynamicImport = new Function('m', 'return import(m)') as (m: string) => Promise<unknown>\n        const [fs, path] = await Promise.all([\n            dynamicImport('node:fs'),\n            dynamicImport('node:path'),\n        ]) as [typeof import('node:fs'), typeof import('node:path')]\n        // process.cwd() 同理，用 new Function 包裹避免静态分析追踪\n        const getCwd = new Function('return process.cwd()') as () => string\n        const resolved: NodeModules = { fs, path, cwd: getCwd() }\n        nodeModulesCache = resolved\n        return resolved\n    } catch {\n        nodeModulesCache = null\n        return null\n    }\n}\n\n// ─── project-name cache ───────────────────────────────────────────────\nconst projectNameCache = new Map<string, string>()\nconst pendingLookups = new Set<string>()\n\n/** Register a known projectId → projectName mapping. */\nexport function registerProjectName(projectId: string, projectName: string): void {\n    if (projectId && projectName) {\n        projectNameCache.set(projectId, projectName)\n    }\n}\n\n/**\n * Resolve projectName from cache or DB.\n * Returns `null` if the name cannot be resolved right now.\n */\nasync function resolveProjectName(projectId: string): Promise<string | null> {\n    const cached = projectNameCache.get(projectId)\n    if (cached) return cached\n\n    // Avoid duplicate concurrent lookups for the same projectId.\n    if (pendingLookups.has(projectId)) return null\n    pendingLookups.add(projectId)\n\n    try {\n        const { prisma } = await import('@/lib/prisma')\n        const project = await prisma.project.findUnique({\n            where: { id: projectId },\n            select: { name: true },\n        })\n        if (project?.name) {\n            projectNameCache.set(projectId, project.name)\n            return project.name\n        }\n    } catch {\n        // Swallow lookup errors – better to lose a log line than crash.\n    } finally {\n        pendingLookups.delete(projectId)\n    }\n\n    return null\n}\n\n// ─── file helpers ─────────────────────────────────────────────────────\n\n/**\n * Sanitize a project name so it can be safely used as part of a file name.\n * Replaces characters that are invalid on macOS/Linux/Windows with '_'.\n */\nfunction sanitizeProjectName(name: string): string {\n    return name.replace(/[/\\\\:\\0*?\"<>|]/g, '_').trim() || 'unknown'\n}\n\nasync function appendLineAsync(filePath: string, line: string): Promise<void> {\n    const modules = await getNodeModules()\n    if (!modules) return\n\n    try {\n        // Ensure the logs directory exists\n        const dir = modules.path.dirname(filePath)\n        modules.fs.mkdirSync(dir, { recursive: true })\n        modules.fs.appendFileSync(filePath, line + '\\n')\n        // 写入后异步检查是否需要清理（fire-and-forget）\n        void maybeCleanupProjectLog(filePath)\n    } catch (err) {\n        // Do not propagate, but surface so file-write failures are visible.\n        console.error('[file-writer] Failed to write log line to', filePath, err)\n    }\n}\n\nfunction buildLogFilePath(modules: NodeModules, prefix: string, projectName: string): string {\n    const fileName = `${prefix}_${sanitizeProjectName(projectName)}.log`\n    return modules.path.join(modules.cwd, 'logs', fileName)\n}\n\n// ─── 24h cleanup helpers ─────────────────────────────────────────────\n\nconst PROJECT_LOG_MAX_BYTES = 2 * 1024 * 1024 // 2 MB 触发清理\nconst LOG_RETENTION_MS = 24 * 60 * 60 * 1000   // 保留 24 小时\n\n/**\n * 从日志内容中过滤掉 24 小时前的行。\n * 每行是 JSON，通过 \"ts\" 字段判断时间。\n */\nfunction filterRecentLines(content: string): string {\n    const cutoff = Date.now() - LOG_RETENTION_MS\n    const lines = content.split('\\n')\n    const kept = lines.filter((line) => {\n        if (!line.trim()) return false\n        try {\n            const parsed = JSON.parse(line) as { ts?: string }\n            if (parsed.ts) {\n                return new Date(parsed.ts).getTime() >= cutoff\n            }\n        } catch {\n            // 非 JSON 行（如分隔符）保留\n        }\n        return true\n    })\n    return kept.join('\\n')\n}\n\n/**\n * 若项目日志文件超过阈值，清理 24 小时前的内容。\n */\nasync function maybeCleanupProjectLog(filePath: string): Promise<void> {\n    const modules = await getNodeModules()\n    if (!modules) return\n    try {\n        const stat = modules.fs.statSync(filePath)\n        if (stat.size <= PROJECT_LOG_MAX_BYTES) return\n        const content = modules.fs.readFileSync(filePath, 'utf-8')\n        const cleaned = filterRecentLines(content)\n        modules.fs.writeFileSync(filePath, cleaned + '\\n')\n    } catch {\n        // 文件不存在或读写失败，忽略\n    }\n}\n\n// ─── prefix mapping ──────────────────────────────────────────────────\n\nfunction getPrefix(module?: string): string {\n    if (module && module.startsWith('worker')) return 'Internal'\n    return 'admin'\n}\n\n// ─── buffered events ─────────────────────────────────────────────────\n// When a log event arrives before the project name is resolved we buffer\n// it so it can be flushed once the name becomes available.\nconst bufferedLines = new Map<string, string[]>()\n\nasync function flushBuffer(projectId: string, projectName: string): Promise<void> {\n    const lines = bufferedLines.get(projectId)\n    if (!lines || lines.length === 0) return\n    bufferedLines.delete(projectId)\n\n    const modules = await getNodeModules()\n    if (!modules) return\n\n    for (const entry of lines) {\n        // The prefix was stored as a \"|\" delimited header: \"prefix|json\"\n        const sepIdx = entry.indexOf('|')\n        if (sepIdx === -1) continue\n        const prefix = entry.slice(0, sepIdx)\n        const json = entry.slice(sepIdx + 1)\n        const filePath = buildLogFilePath(modules, prefix, projectName)\n        void appendLineAsync(filePath, json)\n    }\n}\n\n// ─── public API ──────────────────────────────────────────────────────\n\n/**\n * Write a log line to the appropriate project log file.\n *\n * This function is fire-and-forget – the returned promise should be\n * `void`-ed by the caller.\n */\nexport async function writeLogToProjectFile(\n    line: string,\n    projectId: string | undefined,\n    module: string | undefined,\n): Promise<void> {\n    if (isEdgeOrBrowser()) return\n    if (!projectId) return\n\n    const prefix = getPrefix(module)\n\n    // Fast path – projectName already cached\n    const cachedName = projectNameCache.get(projectId)\n    if (cachedName) {\n        const modules = await getNodeModules()\n        if (!modules) return\n        const filePath = buildLogFilePath(modules, prefix, cachedName)\n        void appendLineAsync(filePath, line)\n        return\n    }\n\n    // Slow path – resolve asynchronously\n    const projectName = await resolveProjectName(projectId)\n    if (projectName) {\n        // Flush anything that was buffered while we were resolving\n        void flushBuffer(projectId, projectName)\n        const modules = await getNodeModules()\n        if (!modules) return\n        const filePath = buildLogFilePath(modules, prefix, projectName)\n        void appendLineAsync(filePath, line)\n        return\n    }\n\n    // Name not yet available – buffer the line\n    const buf = bufferedLines.get(projectId) || []\n    buf.push(`${prefix}|${line}`)\n    bufferedLines.set(projectId, buf)\n}\n\n/**\n * Called when a project name becomes available to flush any buffered\n * log events for that project.\n */\nexport function onProjectNameAvailable(projectId: string, projectName: string): void {\n    registerProjectName(projectId, projectName)\n    void flushBuffer(projectId, projectName)\n}\n\n// ─── global log writer ──────────────────────────────────────────────\n\nconst GLOBAL_LOG_MAX_BYTES = 10 * 1024 * 1024 // 10 MB\n\n/**\n * Write a log line to the global `app.log` file.\n * Automatically rotates: when the file exceeds 10 MB, the oldest half is removed.\n */\nexport async function writeGlobalLogLine(line: string): Promise<void> {\n    if (isEdgeOrBrowser()) return\n    const modules = await getNodeModules()\n    if (!modules) return\n\n    const filePath = modules.path.join(modules.cwd, 'logs', 'app.log')\n    try {\n        modules.fs.mkdirSync(modules.path.dirname(filePath), { recursive: true })\n\n        // Auto-rotate: if file exceeds limit, keep only the last half\n        try {\n            const stat = modules.fs.statSync(filePath)\n            if (stat.size > GLOBAL_LOG_MAX_BYTES) {\n                const content = modules.fs.readFileSync(filePath, 'utf-8')\n                const lines = content.split('\\n')\n                const half = Math.floor(lines.length / 2)\n                modules.fs.writeFileSync(filePath, lines.slice(half).join('\\n'))\n            }\n        } catch {\n            // File may not exist yet, that's fine\n        }\n\n        modules.fs.appendFileSync(filePath, line + '\\n')\n    } catch (err) {\n        console.error('[file-writer] Failed to write global log line', err)\n    }\n}\n\n// ─── log file access (for download API) ─────────────────────────────\n\nexport interface LogFileInfo {\n    name: string\n    sizeBytes: number\n    modifiedAt: string\n}\n\n/**\n * List all log files in the logs directory.\n */\nexport async function getLogFilesList(): Promise<LogFileInfo[]> {\n    if (isEdgeOrBrowser()) return []\n    const modules = await getNodeModules()\n    if (!modules) return []\n\n    const logsDir = modules.path.join(modules.cwd, 'logs')\n    try {\n        const files = modules.fs.readdirSync(logsDir)\n        return files\n            .filter((f: string) => f.endsWith('.log'))\n            .map((f: string) => {\n                const stat = modules.fs.statSync(modules.path.join(logsDir, f))\n                return {\n                    name: f,\n                    sizeBytes: stat.size,\n                    modifiedAt: stat.mtime.toISOString(),\n                }\n            })\n            .sort((a: LogFileInfo, b: LogFileInfo) => b.modifiedAt.localeCompare(a.modifiedAt))\n    } catch {\n        return []\n    }\n}\n\n/**\n * Read and concatenate all log files into a single string for download.\n */\nexport async function readAllLogs(): Promise<string> {\n    if (isEdgeOrBrowser()) return ''\n    const modules = await getNodeModules()\n    if (!modules) return ''\n\n    const logsDir = modules.path.join(modules.cwd, 'logs')\n    try {\n        const files = modules.fs.readdirSync(logsDir)\n            .filter((f: string) => f.endsWith('.log'))\n            .sort()\n        const sections: string[] = []\n        for (const f of files) {\n            const content = modules.fs.readFileSync(modules.path.join(logsDir, f), 'utf-8')\n            sections.push(`\\n========== ${f} ==========\\n${content}`)\n        }\n        return sections.join('\\n')\n    } catch {\n        return ''\n    }\n}\n/**\n * 清理所有项目日志文件中 24 小时前的内容。\n * 供 watchdog 定期调用（建议每小时一次）。\n */\nexport async function cleanupAllProjectLogs(): Promise<void> {\n    if (isEdgeOrBrowser()) return\n    const modules = await getNodeModules()\n    if (!modules) return\n\n    const logsDir = modules.path.join(modules.cwd, 'logs')\n    try {\n        const files = modules.fs.readdirSync(logsDir)\n        for (const f of files) {\n            if (!f.endsWith('.log') || f === 'app.log') continue\n            const filePath = modules.path.join(logsDir, f)\n            try {\n                const content = modules.fs.readFileSync(filePath, 'utf-8')\n                const cleaned = filterRecentLines(content)\n                modules.fs.writeFileSync(filePath, cleaned + '\\n')\n            } catch {\n                // 单个文件失败不影响其他\n            }\n        }\n    } catch {\n        // logs 目录不存在等情况，忽略\n    }\n}\n"
  },
  {
    "path": "src/lib/logging/redact.ts",
    "content": "const MAX_DEPTH = 6\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n  if (typeof value !== 'object' || value == null) return false\n  const proto = Object.getPrototypeOf(value)\n  return proto === Object.prototype || proto === null\n}\n\nfunction shouldRedact(key: string, redactKeys: string[]): boolean {\n  const lower = key.toLowerCase()\n  return redactKeys.some((needle) => lower.includes(needle))\n}\n\nexport function redactValue(value: unknown, redactKeys: string[], depth = 0): unknown {\n  if (depth > MAX_DEPTH) return '[MaxDepth]'\n  if (value == null) return value\n\n  if (Array.isArray(value)) {\n    return value.map((item) => redactValue(item, redactKeys, depth + 1))\n  }\n\n  if (isPlainObject(value)) {\n    const output: Record<string, unknown> = {}\n    for (const [key, nested] of Object.entries(value)) {\n      if (shouldRedact(key, redactKeys)) {\n        output[key] = '[REDACTED]'\n      } else {\n        output[key] = redactValue(nested, redactKeys, depth + 1)\n      }\n    }\n    return output\n  }\n\n  return value\n}\n"
  },
  {
    "path": "src/lib/logging/semantic.ts",
    "content": "import { createScopedLogger } from './core'\nimport { registerProjectName } from './file-writer'\n\nfunction maybeRegisterProject(projectId?: string, projectName?: string): void {\n  if (projectId && projectName) {\n    registerProjectName(projectId, projectName)\n  }\n}\n\ntype AnyRecord = Record<string, unknown>\ntype SemanticLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'\n\nconst toOptionalString = (value: string | null | undefined): string | undefined => value ?? undefined\n\nfunction toDetails(input: unknown): AnyRecord | unknown[] | null {\n  if (input == null) return null\n  if (Array.isArray(input)) return input\n  if (typeof input === 'object') return input as AnyRecord\n  return { value: input }\n}\n\nfunction resolveMessage(value: unknown, fallback: string): string {\n  return typeof value === 'string' ? value : fallback\n}\n\nfunction resolveDetails(messageOrDetails: unknown, details: unknown): unknown {\n  if (typeof messageOrDetails === 'string') return details\n  if (details == null) return messageOrDetails\n  return {\n    messageOrDetails,\n    details,\n  }\n}\n\nfunction createSemanticLogger(module: string) {\n  return createScopedLogger({ module })\n}\n\nexport function logInternal(\n  module: string,\n  level: SemanticLevel,\n  message: string,\n  details?: unknown,\n  projectId?: string,\n): void {\n  createSemanticLogger(module).event({\n    level,\n    action: 'internal',\n    message,\n    projectId,\n    details: toDetails(details),\n  })\n}\n\nexport function logUserAction(\n  action: string,\n  userId: string,\n  username: string,\n  message: unknown,\n  details?: unknown,\n  projectId?: string,\n  projectName?: string,\n): void {\n  createSemanticLogger('user').event({\n    level: 'INFO',\n    audit: true,\n    action,\n    message: resolveMessage(message, action),\n    userId,\n    projectId,\n    details: {\n      ...(typeof details === 'object' && details != null ? (details as AnyRecord) : { details }),\n      username,\n      projectName,\n    },\n  })\n}\n\nexport function logAIAnalysis(\n  action: string,\n  message: unknown,\n  details?: unknown,\n  userId?: string | null,\n  username?: string | null,\n  projectId?: string | null,\n  projectName?: string | null,\n): void\nexport function logAIAnalysis(\n  userId: string | null | undefined,\n  username: string | null | undefined,\n  projectId: string | null | undefined,\n  projectName: string | null | undefined,\n  payload?: unknown,\n): void\nexport function logAIAnalysis(\n  ...args:\n    | [string, unknown, unknown?, (string | null)?, (string | null)?, (string | null)?, (string | null)?]\n    | [string | null | undefined, string | null | undefined, string | null | undefined, string | null | undefined, unknown?]\n): void {\n  let action = 'AI_ANALYSIS'\n  let message: unknown = 'AI_ANALYSIS'\n  let details: unknown = null\n  let userId: string | undefined\n  let username: string | undefined\n  let projectId: string | undefined\n  let projectName: string | undefined\n\n  if (args.length === 5) {\n    const [legacyUserId, legacyUsername, legacyProjectId, legacyProjectName, payload] = args\n    const payloadRecord = typeof payload === 'object' && payload != null ? (payload as AnyRecord) : null\n    userId = typeof legacyUserId === 'string' ? legacyUserId : undefined\n    username = typeof legacyUsername === 'string' ? legacyUsername : undefined\n    projectId = typeof legacyProjectId === 'string' ? legacyProjectId : undefined\n    projectName = typeof legacyProjectName === 'string' ? legacyProjectName : undefined\n    action = payloadRecord && typeof payloadRecord.action === 'string' ? payloadRecord.action : action\n    message = payloadRecord && typeof payloadRecord.message === 'string' ? payloadRecord.message : action\n    details = payload\n  } else {\n    const [nextAction, nextMessage, nextDetails, nextUserId, nextUsername, nextProjectId, nextProjectName] =\n      args as [string, unknown, unknown?, (string | null)?, (string | null)?, (string | null)?, (string | null)?]\n    action = nextAction\n    message = nextMessage\n    details = nextDetails ?? null\n    userId = toOptionalString(nextUserId)\n    username = toOptionalString(nextUsername)\n    projectId = toOptionalString(nextProjectId)\n    projectName = toOptionalString(nextProjectName)\n  }\n\n  maybeRegisterProject(projectId, projectName)\n  createSemanticLogger('ai').event({\n    level: 'INFO',\n    audit: true,\n    action,\n    message: resolveMessage(message, action),\n    userId,\n    projectId,\n    details: {\n      ...(typeof resolveDetails(message, details) === 'object' && resolveDetails(message, details) != null\n        ? (resolveDetails(message, details) as AnyRecord)\n        : { details: resolveDetails(message, details) }),\n      username,\n      projectName,\n    },\n  })\n}\n\nexport function logProjectAction(\n  action: string,\n  message: unknown,\n  details?: unknown,\n  userId?: string | null,\n  username?: string | null,\n  projectId?: string | null,\n  projectName?: string | null,\n): void\nexport function logProjectAction(\n  action: string,\n  userId: string | null | undefined,\n  username: string | null | undefined,\n  projectId: string | null | undefined,\n  projectName: string | null | undefined,\n  details?: unknown,\n): void\nexport function logProjectAction(\n  ...args:\n    | [string, unknown, unknown?, (string | null)?, (string | null)?, (string | null)?, (string | null)?]\n    | [string, string | null | undefined, string | null | undefined, string | null | undefined, string | null | undefined, unknown?]\n): void {\n  let action: string\n  let message: unknown\n  let details: unknown = null\n  let userId: string | undefined\n  let username: string | undefined\n  let projectId: string | undefined\n  let projectName: string | undefined\n\n  if (args.length >= 6) {\n    const [legacyAction, legacyUserId, legacyUsername, legacyProjectId, legacyProjectName, legacyDetails] =\n      args as [string, string | null | undefined, string | null | undefined, string | null | undefined, string | null | undefined, unknown]\n    action = legacyAction\n    message = legacyAction\n    details = legacyDetails\n    userId = toOptionalString(legacyUserId)\n    username = toOptionalString(legacyUsername)\n    projectId = toOptionalString(legacyProjectId)\n    projectName = toOptionalString(legacyProjectName)\n  } else {\n    const [nextAction, nextMessage, nextDetails, nextUserId, nextUsername, nextProjectId, nextProjectName] =\n      args as [string, unknown, unknown?, (string | null)?, (string | null)?, (string | null)?, (string | null)?]\n    action = nextAction\n    message = nextMessage\n    details = nextDetails ?? null\n    userId = toOptionalString(nextUserId)\n    username = toOptionalString(nextUsername)\n    projectId = toOptionalString(nextProjectId)\n    projectName = toOptionalString(nextProjectName)\n  }\n\n  maybeRegisterProject(projectId, projectName)\n  createSemanticLogger('project').event({\n    level: 'INFO',\n    audit: true,\n    action,\n    message: resolveMessage(message, action),\n    userId,\n    projectId,\n    details: {\n      ...(typeof resolveDetails(message, details) === 'object' && resolveDetails(message, details) != null\n        ? (resolveDetails(message, details) as AnyRecord)\n        : { details: resolveDetails(message, details) }),\n      username,\n      projectName,\n    },\n  })\n}\n\nexport function logAuthAction(\n  action: string,\n  message: unknown,\n  details?: unknown,\n  userId?: string,\n  username?: string,\n): void {\n  createSemanticLogger('auth').event({\n    level: 'INFO',\n    audit: true,\n    action,\n    message: resolveMessage(message, action),\n    userId,\n    details: {\n      ...(typeof details === 'object' && details != null ? (details as AnyRecord) : { details }),\n      username,\n    },\n  })\n}\n"
  },
  {
    "path": "src/lib/logging/types.ts",
    "content": "export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'\n\nexport interface ErrorFields {\n  name?: string\n  message?: string\n  stack?: string\n  code?: string\n  retryable?: boolean\n}\n\nexport interface LogContext {\n  requestId?: string\n  taskId?: string\n  projectId?: string\n  userId?: string\n  provider?: string\n  action?: string\n  module?: string\n}\n\nexport interface SemanticContext extends LogContext {\n  errorCode?: string\n  retryable?: boolean\n  durationMs?: number\n}\n\nexport interface LogEvent {\n  ts: string\n  level: LogLevel\n  service: string\n  audit?: boolean\n  module?: string\n  action?: string\n  message: string\n  requestId?: string\n  taskId?: string\n  projectId?: string\n  userId?: string\n  errorCode?: string\n  retryable?: boolean\n  durationMs?: number\n  provider?: string\n  details?: Record<string, unknown> | unknown[] | null\n  error?: ErrorFields\n}\n"
  },
  {
    "path": "src/lib/media/attach.ts",
    "content": "import { decodeImageUrlsFromDb } from '@/lib/contracts/image-urls-contract'\nimport { resolveMediaRef, resolveMediaRefFromLegacyValue } from './service'\nimport type { MediaRef } from './types'\n\nfunction parseStringArray(value: unknown): string[] {\n  if (!value) return []\n  if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')\n  if (typeof value !== 'string') return []\n  try {\n    const parsed = JSON.parse(value)\n    return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []\n  } catch {\n    return []\n  }\n}\n\nasync function resolveAppearanceImageArray(raw: unknown, fieldName: string): Promise<{ urls: string[]; medias: MediaRef[] }> {\n  const values = decodeImageUrlsFromDb(raw as string | null | undefined, fieldName)\n  const refs = await Promise.all(values.map((value) => resolveMediaRefFromLegacyValue(value)))\n  return {\n    urls: values.map((value, index) => refs[index]?.url || value),\n    medias: refs.filter((ref): ref is MediaRef => !!ref),\n  }\n}\n\nasync function attachMediaFieldsToAppearance<T extends Record<string, unknown>>(appearance: T) {\n  const imageMedia = await resolveMediaRef(appearance.imageMediaId, appearance.imageUrl)\n  const previousImageMedia = await resolveMediaRef(appearance.previousImageMediaId, appearance.previousImageUrl)\n  const imageResult = await resolveAppearanceImageArray(appearance.imageUrls, 'appearance.imageUrls')\n  const previousImageResult = await resolveAppearanceImageArray(appearance.previousImageUrls, 'appearance.previousImageUrls')\n\n  return {\n    ...appearance,\n    imageMedia,\n    media: imageMedia,\n    previousImageMedia,\n    imageMedias: imageResult.medias,\n    previousImageMedias: previousImageResult.medias,\n    imageUrl: imageMedia?.url || appearance.imageUrl || null,\n    previousImageUrl: previousImageMedia?.url || appearance.previousImageUrl || null,\n    imageUrls: imageResult.urls,\n    previousImageUrls: previousImageResult.urls,\n  }\n}\n\nexport async function attachMediaFieldsToGlobalCharacter<T extends Record<string, unknown>>(character: T) {\n  const customVoiceMedia = await resolveMediaRef(character.customVoiceMediaId, character.customVoiceUrl)\n  const appearances = await Promise.all(\n    ((character.appearances as Array<Record<string, unknown>>) || []).map(attachMediaFieldsToAppearance),\n  )\n\n  return {\n    ...character,\n    media: customVoiceMedia,\n    customVoiceMedia,\n    customVoiceUrl: customVoiceMedia?.url || character.customVoiceUrl || null,\n    appearances,\n  }\n}\n\nexport async function attachMediaFieldsToGlobalLocation<T extends Record<string, unknown>>(location: T) {\n  const images = await Promise.all(\n    ((location.images as Array<Record<string, unknown>>) || []).map(async (img) => {\n    const imageMedia = await resolveMediaRef(img.imageMediaId, img.imageUrl)\n    const previousImageMedia = await resolveMediaRef(img.previousImageMediaId, img.previousImageUrl)\n    return {\n      ...img,\n      media: imageMedia,\n      imageMedia,\n      previousImageMedia,\n      imageUrl: imageMedia?.url || img.imageUrl || null,\n      previousImageUrl: previousImageMedia?.url || img.previousImageUrl || null,\n    }\n    }),\n  )\n\n  return {\n    ...location,\n    images,\n  }\n}\n\nexport async function attachMediaFieldsToGlobalVoice<T extends Record<string, unknown>>(voice: T) {\n  const customVoiceMedia = await resolveMediaRef(voice.customVoiceMediaId, voice.customVoiceUrl)\n  return {\n    ...voice,\n    media: customVoiceMedia,\n    customVoiceMedia,\n    customVoiceUrl: customVoiceMedia?.url || voice.customVoiceUrl || null,\n  }\n}\n\nasync function attachMediaFieldsToPanel<T extends Record<string, unknown>>(panel: T) {\n  const imageMedia = await resolveMediaRef(panel.imageMediaId, panel.imageUrl)\n  const videoMedia = await resolveMediaRef(panel.videoMediaId, panel.videoUrl)\n  const lipSyncVideoMedia = await resolveMediaRef(panel.lipSyncVideoMediaId, panel.lipSyncVideoUrl)\n  const sketchImageMedia = await resolveMediaRef(panel.sketchImageMediaId, panel.sketchImageUrl)\n  const previousImageMedia = await resolveMediaRef(panel.previousImageMediaId, panel.previousImageUrl)\n\n  const candidateRaw = parseStringArray(panel.candidateImages)\n  const candidateMediaUrls: string[] = []\n  for (const candidate of candidateRaw) {\n    if (candidate.startsWith('PENDING:')) {\n      candidateMediaUrls.push(candidate)\n      continue\n    }\n    const media = await resolveMediaRefFromLegacyValue(candidate)\n    candidateMediaUrls.push(media?.url || candidate)\n  }\n\n  return {\n    ...panel,\n    media: imageMedia,\n    imageMedia,\n    videoMedia,\n    lipSyncVideoMedia,\n    sketchImageMedia,\n    previousImageMedia,\n    imageUrl: imageMedia?.url || panel.imageUrl || null,\n    videoUrl: videoMedia?.url || panel.videoUrl || null,\n    lipSyncVideoUrl: lipSyncVideoMedia?.url || panel.lipSyncVideoUrl || null,\n    sketchImageUrl: sketchImageMedia?.url || panel.sketchImageUrl || null,\n    previousImageUrl: previousImageMedia?.url || panel.previousImageUrl || null,\n    candidateImages: candidateRaw.length > 0 ? JSON.stringify(candidateMediaUrls) : panel.candidateImages,\n  }\n}\n\nasync function attachMediaFieldsToStoryboard<T extends Record<string, unknown>>(storyboard: T) {\n  const storyboardImageMedia = await resolveMediaRefFromLegacyValue(storyboard.storyboardImageUrl)\n  const panels = await Promise.all(\n    ((storyboard.panels as Array<Record<string, unknown>>) || []).map(attachMediaFieldsToPanel),\n  )\n\n  return {\n    ...storyboard,\n    media: storyboardImageMedia,\n    storyboardImageMedia,\n    storyboardImageUrl: storyboardImageMedia?.url || storyboard.storyboardImageUrl || null,\n    panels,\n  }\n}\n\nasync function attachMediaFieldsToProjectCharacter<T extends Record<string, unknown>>(character: T) {\n  const customVoiceMedia = await resolveMediaRef(character.customVoiceMediaId, character.customVoiceUrl)\n  const appearances = await Promise.all(\n    ((character.appearances as Array<Record<string, unknown>>) || []).map(attachMediaFieldsToAppearance),\n  )\n  return {\n    ...character,\n    media: customVoiceMedia,\n    customVoiceMedia,\n    customVoiceUrl: customVoiceMedia?.url || character.customVoiceUrl || null,\n    appearances,\n  }\n}\n\nasync function attachMediaFieldsToProjectLocation<T extends Record<string, unknown>>(location: T) {\n  const images = await Promise.all(\n    ((location.images as Array<Record<string, unknown>>) || []).map(async (img) => {\n    const imageMedia = await resolveMediaRef(img.imageMediaId, img.imageUrl)\n    const previousImageMedia = await resolveMediaRef(img.previousImageMediaId, img.previousImageUrl)\n    return {\n      ...img,\n      media: imageMedia,\n      imageMedia,\n      previousImageMedia,\n      imageUrl: imageMedia?.url || img.imageUrl || null,\n      previousImageUrl: previousImageMedia?.url || img.previousImageUrl || null,\n    }\n    }),\n  )\n\n  return {\n    ...location,\n    images,\n  }\n}\n\nasync function attachMediaFieldsToShot<T extends Record<string, unknown>>(shot: T) {\n  const imageMedia = await resolveMediaRef(shot.imageMediaId, shot.imageUrl)\n  const videoMedia = await resolveMediaRefFromLegacyValue(shot.videoUrl)\n  return {\n    ...shot,\n    media: imageMedia,\n    imageMedia,\n    videoMedia,\n    imageUrl: imageMedia?.url || shot.imageUrl || null,\n    videoUrl: videoMedia?.url || shot.videoUrl || null,\n  }\n}\n\nasync function attachMediaFieldsToVoiceLine<T extends Record<string, unknown>>(line: T) {\n  const audioMedia = await resolveMediaRef(line.audioMediaId, line.audioUrl)\n  return {\n    ...line,\n    media: audioMedia,\n    audioMedia,\n    audioUrl: audioMedia?.url || line.audioUrl || null,\n  }\n}\n\nexport async function attachMediaFieldsToProject<T extends Record<string, unknown>>(projectLike: T) {\n  const audioMedia = await resolveMediaRef(projectLike.audioMediaId, projectLike.audioUrl)\n  const characters = await Promise.all(\n    ((projectLike.characters as Array<Record<string, unknown>>) || []).map(attachMediaFieldsToProjectCharacter),\n  )\n  const locations = await Promise.all(\n    ((projectLike.locations as Array<Record<string, unknown>>) || []).map(attachMediaFieldsToProjectLocation),\n  )\n  const shots = await Promise.all(\n    ((projectLike.shots as Array<Record<string, unknown>>) || []).map(attachMediaFieldsToShot),\n  )\n  const storyboards = await Promise.all(\n    ((projectLike.storyboards as Array<Record<string, unknown>>) || []).map(attachMediaFieldsToStoryboard),\n  )\n  const voiceLines = await Promise.all(\n    ((projectLike.voiceLines as Array<Record<string, unknown>>) || []).map(attachMediaFieldsToVoiceLine),\n  )\n\n  return {\n    ...projectLike,\n    media: audioMedia,\n    audioMedia,\n    audioUrl: audioMedia?.url || projectLike.audioUrl || null,\n    characters,\n    locations,\n    shots,\n    storyboards,\n    voiceLines,\n  }\n}\n\nexport function firstMediaUrl(list: MediaRef[]): string[] {\n  return list.map((m) => m.url)\n}\n"
  },
  {
    "path": "src/lib/media/hash.ts",
    "content": "import { createHash } from 'node:crypto'\n\nexport function sha256Hex(input: string): string {\n  return createHash('sha256').update(input).digest('hex')\n}\n\nexport function stablePublicIdFromStorageKey(storageKey: string): string {\n  return `m_${sha256Hex(storageKey).slice(0, 40)}`\n}\n"
  },
  {
    "path": "src/lib/media/image-url.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { resolveOriginalImageUrl, toDisplayImageUrl, unwrapNextImageUrl } from './image-url'\n\ndescribe('image-url helpers', () => {\n  it('unwraps next/image nested url', () => {\n    const input = '/_next/image?url=%2Fapi%2Fstorage%2Fsign%3Fkey%3Dimages%252Ffoo.png&w=640&q=75'\n    expect(unwrapNextImageUrl(input)).toBe('/api/storage/sign?key=images/foo.png')\n  })\n\n  it('maps storage key to display signing route', () => {\n    expect(toDisplayImageUrl('images/a.png')).toBe('/api/storage/sign?key=images%2Fa.png')\n  })\n\n  it('resolves original url from next/image and keeps sign route normalized', () => {\n    const input = '/_next/image?url=%2Fapi%2Fstorage%2Fsign%3Fkey%3Dimages%252Fa.png&w=1080&q=75'\n    expect(resolveOriginalImageUrl(input)).toBe('/api/storage/sign?key=images%2Fa.png')\n  })\n\n  it('returns null for empty values', () => {\n    expect(toDisplayImageUrl('')).toBeNull()\n    expect(resolveOriginalImageUrl(null)).toBeNull()\n  })\n})\n"
  },
  {
    "path": "src/lib/media/image-url.ts",
    "content": "const LOCAL_ORIGIN = 'http://localhost'\nconst NEXT_IMAGE_PATH = '/_next/image'\nconst COS_SIGN_PATH = '/api/storage/sign'\nconst MAX_NEXT_UNWRAP_DEPTH = 5\nconst STORAGE_KEY_PREFIXES = ['images/', 'video/', 'voice/'] as const\n\nfunction isStorageKey(value: string): boolean {\n  return STORAGE_KEY_PREFIXES.some((prefix) => value.startsWith(prefix))\n}\n\nfunction tryParseUrl(value: string): URL | null {\n  try {\n    return new URL(value, LOCAL_ORIGIN)\n  } catch {\n    return null\n  }\n}\n\nexport function unwrapNextImageUrl(input: string): string {\n  let current = input.trim()\n  if (!current) return current\n\n  for (let i = 0; i < MAX_NEXT_UNWRAP_DEPTH; i += 1) {\n    const parsed = tryParseUrl(current)\n    if (!parsed || parsed.pathname !== NEXT_IMAGE_PATH) {\n      return current\n    }\n\n    const nestedUrl = parsed.searchParams.get('url')\n    if (!nestedUrl) {\n      return current\n    }\n\n    let decoded = nestedUrl\n    try {\n      decoded = decodeURIComponent(nestedUrl)\n    } catch {\n      decoded = nestedUrl\n    }\n\n    if (!decoded || decoded === current) {\n      return current\n    }\n\n    current = decoded\n  }\n\n  return current\n}\n\nexport function toDisplayImageUrl(input: string | null | undefined): string | null {\n  if (!input) return null\n  const raw = input.trim()\n  if (!raw) return null\n\n  const unwrapped = unwrapNextImageUrl(raw)\n  if (isStorageKey(unwrapped)) {\n    return `${COS_SIGN_PATH}?key=${encodeURIComponent(unwrapped)}`\n  }\n\n  return unwrapped\n}\n\nexport function resolveOriginalImageUrl(input: string | null | undefined): string | null {\n  if (!input) return null\n  const raw = input.trim()\n  if (!raw) return null\n\n  const unwrapped = unwrapNextImageUrl(raw)\n  if (isStorageKey(unwrapped)) {\n    return `${COS_SIGN_PATH}?key=${encodeURIComponent(unwrapped)}`\n  }\n\n  const parsed = tryParseUrl(unwrapped)\n  if (parsed?.pathname === COS_SIGN_PATH) {\n    const key = parsed.searchParams.get('key')\n    if (key) {\n      let decodedKey = key\n      try {\n        decodedKey = decodeURIComponent(key)\n      } catch {\n        decodedKey = key\n      }\n      return `${COS_SIGN_PATH}?key=${encodeURIComponent(decodedKey)}`\n    }\n  }\n\n  return unwrapped\n}\n"
  },
  {
    "path": "src/lib/media/outbound-image.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport {\n  OutboundImageNormalizeError,\n  normalizeReferenceImagesForGeneration,\n  normalizeToBase64ForGeneration,\n  normalizeToOriginalMediaUrl,\n  sanitizeImageInputsForTaskPayload,\n} from './outbound-image'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\n\nvi.mock('@/lib/storage', () => ({\n  getSignedUrl: vi.fn((key: string) => `/signed/${key}`),\n  toFetchableUrl: vi.fn((value: string) => (\n    value.startsWith('/') ? `http://localhost:3000${value}` : value\n  )),\n}))\n\nvi.mock('@/lib/media/service', () => ({\n  resolveStorageKeyFromMediaValue: vi.fn(),\n}))\n\ndescribe('outbound-image normalization', () => {\n  const fetchMock = vi.fn()\n  const resolveStorageKeyMock = vi.mocked(resolveStorageKeyFromMediaValue)\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n    vi.stubGlobal('fetch', fetchMock)\n\n    resolveStorageKeyMock.mockImplementation(async (value: unknown) => {\n      if (value === '/m/pub-1') return 'images/from-media.png'\n      return null\n    })\n\n    fetchMock.mockResolvedValue({\n      ok: true,\n      status: 200,\n      headers: new Headers({ 'content-type': 'image/png' }),\n      arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer,\n    } as unknown as Response)\n  })\n\n  it('keeps data url unchanged', async () => {\n    const dataUrl = 'data:image/png;base64,AAAA'\n    expect(await normalizeToOriginalMediaUrl(dataUrl)).toBe(dataUrl)\n  })\n\n  it('throws structured error on empty input', async () => {\n    await expect(normalizeToOriginalMediaUrl('')).rejects.toBeInstanceOf(OutboundImageNormalizeError)\n    await expect(normalizeToOriginalMediaUrl('')).rejects.toMatchObject({\n      code: 'OUTBOUND_IMAGE_EMPTY_INPUT',\n      stage: 'normalize_original',\n    })\n  })\n\n  it('unwraps next/image and resolves /m route to signed source', async () => {\n    const input = '/_next/image?url=%2Fm%2Fpub-1&w=640&q=75'\n    const normalized = await normalizeToOriginalMediaUrl(input)\n    expect(normalized).toBe('http://localhost:3000/signed/images/from-media.png')\n  })\n\n  it('fails explicitly when /m route cannot be resolved to storage key', async () => {\n    await expect(normalizeToOriginalMediaUrl('/m/missing-id')).rejects.toMatchObject({\n      code: 'OUTBOUND_IMAGE_MEDIA_ROUTE_UNRESOLVED',\n      stage: 'normalize_original',\n    })\n  })\n\n  it('signs storage key inputs', async () => {\n    const normalized = await normalizeToOriginalMediaUrl('images/direct.png')\n    expect(normalized).toBe('http://localhost:3000/signed/images/direct.png')\n  })\n\n  it('normalizes api relative path to absolute fetchable url', async () => {\n    const normalized = await normalizeToOriginalMediaUrl('/api/files/images%2Fa.png')\n    expect(normalized).toBe('http://localhost:3000/api/files/images%2Fa.png')\n  })\n\n  it('fails explicitly on unsupported root-relative input', async () => {\n    await expect(normalizeToOriginalMediaUrl('/foo/bar.png')).rejects.toMatchObject({\n      code: 'OUTBOUND_IMAGE_UNSUPPORTED_INPUT',\n      stage: 'normalize_original',\n    })\n  })\n\n  it('keeps http input as-is', async () => {\n    const input = 'https://example.com/a.png'\n    expect(await normalizeToOriginalMediaUrl(input)).toBe(input)\n  })\n\n  it('converts normalized source to data url base64 payload', async () => {\n    const dataUrl = await normalizeToBase64ForGeneration('images/direct.png')\n    expect(dataUrl).toBe('data:image/png;base64,AQID')\n  })\n\n  it('sniffs png mime when upstream returns application/octet-stream', async () => {\n    fetchMock.mockResolvedValue({\n      ok: true,\n      status: 200,\n      headers: new Headers({ 'content-type': 'application/octet-stream' }),\n      arrayBuffer: async () => Uint8Array.from([\n        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,\n        0x00, 0x00, 0x00, 0x0d,\n      ]).buffer,\n    } as Response)\n\n    const dataUrl = await normalizeToBase64ForGeneration('images/direct.png')\n    expect(dataUrl).toBe('data:image/png;base64,iVBORw0KGgoAAAAN')\n  })\n\n  it('sniffs jpeg mime when upstream returns application/octet-stream', async () => {\n    fetchMock.mockResolvedValue({\n      ok: true,\n      status: 200,\n      headers: new Headers({ 'content-type': 'application/octet-stream' }),\n      arrayBuffer: async () => Uint8Array.from([\n        0xff, 0xd8, 0xff, 0xe0,\n        0x00, 0x10, 0x4a, 0x46,\n        0x49, 0x46, 0x00, 0x01,\n      ]).buffer,\n    } as Response)\n\n    const dataUrl = await normalizeToBase64ForGeneration('images/direct.jpg')\n    expect(dataUrl).toBe('data:image/jpeg;base64,/9j/4AAQSkZJRgAB')\n  })\n\n  it('normalizes references with dedupe and failure isolation', async () => {\n    fetchMock.mockImplementation(async (url: string) => {\n      if (String(url).includes('/api/bad.png')) {\n        return {\n          ok: false,\n          status: 404,\n          headers: new Headers(),\n          arrayBuffer: async () => new ArrayBuffer(0),\n        } as Response\n      }\n      return {\n        ok: true,\n        status: 200,\n        headers: new Headers({ 'content-type': 'image/png' }),\n        arrayBuffer: async () => Uint8Array.from([7, 8, 9]).buffer,\n      } as Response\n    })\n\n    const normalized = await normalizeReferenceImagesForGeneration([\n      'images/direct.png',\n      'images/direct.png',\n      '/api/bad.png',\n    ])\n    expect(normalized).toHaveLength(1)\n    expect(normalized[0]).toBe('data:image/png;base64,BwgJ')\n  })\n\n  it('reports structured issue and fails explicitly when all references fail', async () => {\n    fetchMock.mockResolvedValue({\n      ok: false,\n      status: 500,\n      headers: new Headers(),\n      arrayBuffer: async () => new ArrayBuffer(0),\n    } as Response)\n\n    const issues: Array<{\n      code: string\n      stage: string\n      message: string\n      input: string\n      index: number\n    }> = []\n\n    await expect(\n      normalizeReferenceImagesForGeneration(['images/bad.png'], {\n        onIssue: (issue) => issues.push(issue),\n      }),\n    ).rejects.toMatchObject({\n      code: 'OUTBOUND_IMAGE_REFERENCE_ALL_FAILED',\n      stage: 'normalize_reference',\n    })\n    expect(issues).toHaveLength(1)\n    expect(issues[0]).toMatchObject({\n      code: 'OUTBOUND_IMAGE_FETCH_FAILED',\n      stage: 'normalize_base64',\n      input: 'images/bad.png',\n      index: 0,\n    })\n  })\n\n  it('sanitizes task payload urls and reports input issues', () => {\n    const result = sanitizeImageInputsForTaskPayload([\n      '/_next/image?url=images%2Fa.png&w=1080&q=75',\n      '',\n      123,\n      '/relative/path.png',\n    ])\n\n    expect(result.normalized).toEqual(['images/a.png'])\n    expect(result.issues.map((item) => item.reason)).toEqual([\n      'next_image_unwrapped',\n      'empty_value_skipped',\n      'non_string_skipped',\n      'relative_path_rejected',\n    ])\n  })\n})\n"
  },
  {
    "path": "src/lib/media/outbound-image.ts",
    "content": "import path from 'node:path'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\n\ntype StorageHelpers = Pick<typeof import('@/lib/storage'), 'getSignedUrl' | 'toFetchableUrl'>\n\ntype InputIssueReason =\n  | 'next_image_unwrapped'\n  | 'empty_value_skipped'\n  | 'relative_path_rejected'\n  | 'non_string_skipped'\n\nexport type OutboundImageInputIssue = {\n  index: number\n  input: unknown\n  normalized?: string\n  reason: InputIssueReason\n}\n\nexport type OutboundImageNormalizeStage =\n  | 'normalize_original'\n  | 'normalize_base64'\n  | 'normalize_reference'\n\nexport type OutboundImageNormalizeErrorCode =\n  | 'OUTBOUND_IMAGE_EMPTY_INPUT'\n  | 'OUTBOUND_IMAGE_UNSUPPORTED_INPUT'\n  | 'OUTBOUND_IMAGE_MEDIA_ROUTE_UNRESOLVED'\n  | 'OUTBOUND_IMAGE_FETCH_FAILED'\n  | 'OUTBOUND_IMAGE_FETCH_EXCEPTION'\n  | 'OUTBOUND_IMAGE_REFERENCE_ALL_FAILED'\n\nexport class OutboundImageNormalizeError extends Error {\n  readonly code: OutboundImageNormalizeErrorCode\n  readonly stage: OutboundImageNormalizeStage\n  readonly input: string\n\n  constructor(params: {\n    code: OutboundImageNormalizeErrorCode\n    stage: OutboundImageNormalizeStage\n    input: string\n    message: string\n  }) {\n    super(params.message)\n    this.name = 'OutboundImageNormalizeError'\n    this.code = params.code\n    this.stage = params.stage\n    this.input = params.input\n  }\n}\n\nexport type OutboundImageNormalizationIssue = {\n  index: number\n  input: string\n  code: OutboundImageNormalizeErrorCode | 'OUTBOUND_IMAGE_UNKNOWN'\n  stage: OutboundImageNormalizeStage\n  message: string\n}\n\nconst logger = createScopedLogger({\n  module: 'media.outbound-image',\n})\n\nconst NEXT_IMAGE_PATH = '/_next/image'\nconst MAX_NEXT_IMAGE_UNWRAP_DEPTH = 6\nconst SIGNED_URL_TTL_SECONDS = 3600\nconst STORAGE_KEY_PREFIXES = ['images/', 'video/', 'voice/'] as const\nconst DEFAULT_CONTENT_TYPE = 'application/octet-stream'\n\nconst MIME_BY_EXT: Record<string, string> = {\n  '.png': 'image/png',\n  '.jpg': 'image/jpeg',\n  '.jpeg': 'image/jpeg',\n  '.webp': 'image/webp',\n  '.gif': 'image/gif',\n  '.svg': 'image/svg+xml',\n  '.mp4': 'video/mp4',\n  '.webm': 'video/webm',\n  '.mov': 'video/quicktime',\n  '.mp3': 'audio/mpeg',\n  '.wav': 'audio/wav',\n  '.ogg': 'audio/ogg',\n  '.m4a': 'audio/mp4',\n}\n\nlet storageHelpersPromise: Promise<StorageHelpers> | null = null\n\nasync function getStorageHelpers(): Promise<StorageHelpers> {\n  if (!storageHelpersPromise) {\n    storageHelpersPromise = import('@/lib/storage').then((mod) => ({\n      getSignedUrl: mod.getSignedUrl,\n      toFetchableUrl: mod.toFetchableUrl,\n    }))\n  }\n  return await storageHelpersPromise\n}\n\nfunction normalizeInput(input: string): string {\n  const value = typeof input === 'string' ? input.trim() : ''\n  if (!value) {\n    throw new OutboundImageNormalizeError({\n      code: 'OUTBOUND_IMAGE_EMPTY_INPUT',\n      stage: 'normalize_original',\n      input: String(input ?? ''),\n      message: 'outbound image input is empty',\n    })\n  }\n  return value\n}\n\nfunction isDataUrl(value: string): boolean {\n  return value.startsWith('data:')\n}\n\nfunction isHttpUrl(value: string): boolean {\n  return value.startsWith('http://') || value.startsWith('https://')\n}\n\nfunction isAbsoluteOrRootPath(value: string): boolean {\n  return isHttpUrl(value) || value.startsWith('/')\n}\n\nfunction isStorageKey(value: string): boolean {\n  return STORAGE_KEY_PREFIXES.some((prefix) => value.startsWith(prefix))\n}\n\nfunction isNextImagePath(pathname: string): boolean {\n  return pathname === NEXT_IMAGE_PATH || pathname.endsWith(NEXT_IMAGE_PATH)\n}\n\nfunction decodeRepeatedly(raw: string): string {\n  let value = raw\n  for (let i = 0; i < MAX_NEXT_IMAGE_UNWRAP_DEPTH; i += 1) {\n    try {\n      const decoded = decodeURIComponent(value)\n      if (decoded === value) {\n        break\n      }\n      value = decoded\n    } catch {\n      break\n    }\n  }\n  return value\n}\n\nfunction normalizeUnwrappedTarget(raw: string): string {\n  const value = decodeRepeatedly(raw).trim()\n  if (!value) return value\n  if (isAbsoluteOrRootPath(value) || isDataUrl(value) || isStorageKey(value)) return value\n  if (value.startsWith('m/')) return `/${value}`\n  if (value.startsWith('api/')) return `/${value}`\n  return value\n}\n\nfunction toUrlMaybe(value: string): URL | null {\n  try {\n    if (isHttpUrl(value)) return new URL(value)\n    if (value.startsWith('/')) return new URL(value, 'http://localhost')\n  } catch {\n    return null\n  }\n  return null\n}\n\nfunction detectMimeFromBuffer(buffer: Uint8Array): string | null {\n  if (buffer.length >= 8) {\n    const isPng =\n      buffer[0] === 0x89\n      && buffer[1] === 0x50\n      && buffer[2] === 0x4e\n      && buffer[3] === 0x47\n      && buffer[4] === 0x0d\n      && buffer[5] === 0x0a\n      && buffer[6] === 0x1a\n      && buffer[7] === 0x0a\n    if (isPng) return 'image/png'\n  }\n\n  if (buffer.length >= 3) {\n    const isJpeg = buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff\n    if (isJpeg) return 'image/jpeg'\n  }\n\n  if (buffer.length >= 6) {\n    const isGif87a =\n      buffer[0] === 0x47\n      && buffer[1] === 0x49\n      && buffer[2] === 0x46\n      && buffer[3] === 0x38\n      && buffer[4] === 0x37\n      && buffer[5] === 0x61\n    const isGif89a =\n      buffer[0] === 0x47\n      && buffer[1] === 0x49\n      && buffer[2] === 0x46\n      && buffer[3] === 0x38\n      && buffer[4] === 0x39\n      && buffer[5] === 0x61\n    if (isGif87a || isGif89a) return 'image/gif'\n  }\n\n  if (buffer.length >= 12) {\n    const isWebp =\n      buffer[0] === 0x52\n      && buffer[1] === 0x49\n      && buffer[2] === 0x46\n      && buffer[3] === 0x46\n      && buffer[8] === 0x57\n      && buffer[9] === 0x45\n      && buffer[10] === 0x42\n      && buffer[11] === 0x50\n    if (isWebp) return 'image/webp'\n  }\n\n  if (buffer.length >= 12) {\n    const isWav =\n      buffer[0] === 0x52\n      && buffer[1] === 0x49\n      && buffer[2] === 0x46\n      && buffer[3] === 0x46\n      && buffer[8] === 0x57\n      && buffer[9] === 0x41\n      && buffer[10] === 0x56\n      && buffer[11] === 0x45\n    if (isWav) return 'audio/wav'\n  }\n\n  if (buffer.length >= 4) {\n    const isOgg =\n      buffer[0] === 0x4f\n      && buffer[1] === 0x67\n      && buffer[2] === 0x67\n      && buffer[3] === 0x53\n    if (isOgg) return 'audio/ogg'\n  }\n\n  if (buffer.length >= 3) {\n    const isMp3WithId3 =\n      buffer[0] === 0x49\n      && buffer[1] === 0x44\n      && buffer[2] === 0x33\n    const isMp3FrameSync =\n      buffer[0] === 0xff\n      && (buffer[1] & 0xe0) === 0xe0\n    if (isMp3WithId3 || isMp3FrameSync) return 'audio/mpeg'\n  }\n\n  if (buffer.length >= 12) {\n    const isWebm =\n      buffer[0] === 0x1a\n      && buffer[1] === 0x45\n      && buffer[2] === 0xdf\n      && buffer[3] === 0xa3\n    if (isWebm) return 'video/webm'\n  }\n\n  if (buffer.length >= 8) {\n    const isMp4 =\n      buffer[4] === 0x66\n      && buffer[5] === 0x74\n      && buffer[6] === 0x79\n      && buffer[7] === 0x70\n    if (isMp4) return 'video/mp4'\n  }\n\n  return null\n}\n\nfunction guessContentType(input: string, contentTypeHeader: string | null, buffer: Uint8Array): string {\n  const headerType = contentTypeHeader?.split(';')[0]?.trim()\n  if (headerType && headerType !== DEFAULT_CONTENT_TYPE) return headerType\n  const sniffedType = detectMimeFromBuffer(buffer)\n  if (sniffedType) return sniffedType\n  const parsed = toUrlMaybe(input)\n  const pathname = parsed?.pathname ?? input\n  const ext = path.extname(pathname).toLowerCase()\n  return MIME_BY_EXT[ext] || DEFAULT_CONTENT_TYPE\n}\n\nasync function signStorageKey(storageKey: string): Promise<string> {\n  const { getSignedUrl, toFetchableUrl } = await getStorageHelpers()\n  return toFetchableUrl(getSignedUrl(storageKey, SIGNED_URL_TTL_SECONDS))\n}\n\nasync function toFetchableAbsoluteUrl(value: string): Promise<string> {\n  const { toFetchableUrl } = await getStorageHelpers()\n  return toFetchableUrl(value)\n}\n\nfunction unwrapNextImageInternal(input: string): string {\n  let current = input.trim()\n  for (let i = 0; i < MAX_NEXT_IMAGE_UNWRAP_DEPTH; i += 1) {\n    const parsed = toUrlMaybe(current)\n    if (!parsed || !isNextImagePath(parsed.pathname)) {\n      break\n    }\n    const wrapped = parsed.searchParams.get('url')\n    if (!wrapped) {\n      break\n    }\n    const unwrapped = normalizeUnwrappedTarget(wrapped)\n    if (!unwrapped || unwrapped === current) {\n      break\n    }\n    current = unwrapped\n  }\n  return current\n}\n\nasync function normalizeMediaRouteUrl(input: string): Promise<string | null> {\n  const parsed = toUrlMaybe(input)\n  if (!parsed || !parsed.pathname.startsWith('/m/')) {\n    return null\n  }\n\n  const mediaPath = parsed.pathname\n  const storageKey = await resolveStorageKeyFromMediaValue(mediaPath)\n  if (!storageKey) {\n    throw new OutboundImageNormalizeError({\n      code: 'OUTBOUND_IMAGE_MEDIA_ROUTE_UNRESOLVED',\n      stage: 'normalize_original',\n      input,\n      message: `failed to resolve /m route to storage key: ${mediaPath}`,\n    })\n  }\n\n  return await signStorageKey(storageKey)\n}\n\nexport function unwrapNextImageDisplayUrl(input: string): string {\n  return unwrapNextImageInternal(input)\n}\n\nexport async function normalizeToOriginalMediaUrl(input: string): Promise<string> {\n  const normalizedInput = normalizeInput(input)\n  if (isDataUrl(normalizedInput)) {\n    return normalizedInput\n  }\n\n  const unwrappedInput = unwrapNextImageInternal(normalizedInput)\n  if (unwrappedInput !== normalizedInput) {\n    return await normalizeToOriginalMediaUrl(unwrappedInput)\n  }\n\n  if (isStorageKey(unwrappedInput)) {\n    return await signStorageKey(unwrappedInput)\n  }\n\n  const mediaRouteUrl = await normalizeMediaRouteUrl(unwrappedInput)\n  if (mediaRouteUrl) {\n    return mediaRouteUrl\n  }\n\n  if (unwrappedInput.startsWith('/')) {\n    if (unwrappedInput.startsWith('/api/')) {\n      const apiPath = unwrappedInput\n      return await toFetchableAbsoluteUrl(apiPath)\n    }\n    const rootStorageKey = unwrappedInput.slice(1)\n    if (isStorageKey(rootStorageKey)) {\n      return await signStorageKey(rootStorageKey)\n    }\n    throw new OutboundImageNormalizeError({\n      code: 'OUTBOUND_IMAGE_UNSUPPORTED_INPUT',\n      stage: 'normalize_original',\n      input: unwrappedInput,\n      message: `unsupported root-relative outbound image input: ${unwrappedInput}`,\n    })\n  }\n\n  if (isHttpUrl(unwrappedInput)) {\n    return unwrappedInput\n  }\n\n  const storageKey = await resolveStorageKeyFromMediaValue(unwrappedInput)\n  if (storageKey) {\n    return await signStorageKey(storageKey)\n  }\n\n  throw new OutboundImageNormalizeError({\n    code: 'OUTBOUND_IMAGE_UNSUPPORTED_INPUT',\n    stage: 'normalize_original',\n    input: unwrappedInput,\n    message: `unsupported outbound image input: ${unwrappedInput}`,\n  })\n}\n\nexport async function normalizeToBase64ForGeneration(input: string): Promise<string> {\n  const normalizedUrl = await normalizeToOriginalMediaUrl(input)\n  if (isDataUrl(normalizedUrl)) {\n    return normalizedUrl\n  }\n\n  const fetchUrl = await toFetchableAbsoluteUrl(normalizedUrl)\n  let response: Response\n  try {\n    response = await fetch(fetchUrl)\n  } catch {\n    throw new OutboundImageNormalizeError({\n      code: 'OUTBOUND_IMAGE_FETCH_EXCEPTION',\n      stage: 'normalize_base64',\n      input: normalizedUrl,\n      message: `normalizeToBase64ForGeneration fetch exception: ${fetchUrl}`,\n    })\n  }\n\n  if (!response.ok) {\n    throw new OutboundImageNormalizeError({\n      code: 'OUTBOUND_IMAGE_FETCH_FAILED',\n      stage: 'normalize_base64',\n      input: normalizedUrl,\n      message: `normalizeToBase64ForGeneration fetch failed (${response.status}): ${fetchUrl}`,\n    })\n  }\n\n  const buffer = Buffer.from(await response.arrayBuffer())\n  const mimeType = guessContentType(normalizedUrl, response.headers.get('content-type'), buffer)\n  return `data:${mimeType};base64,${buffer.toString('base64')}`\n}\n\nfunction toNormalizationIssue(\n  error: unknown,\n  input: string,\n  index: number,\n): OutboundImageNormalizationIssue {\n  if (error instanceof OutboundImageNormalizeError) {\n    return {\n      index,\n      input,\n      code: error.code,\n      stage: error.stage,\n      message: error.message,\n    }\n  }\n  return {\n    index,\n    input,\n    code: 'OUTBOUND_IMAGE_UNKNOWN',\n    stage: 'normalize_reference',\n    message: error instanceof Error ? error.message : String(error),\n  }\n}\n\nexport async function normalizeReferenceImagesForGeneration(\n  inputs: string[],\n  options: {\n    onIssue?: (issue: OutboundImageNormalizationIssue) => void\n    context?: Record<string, unknown>\n  } = {},\n): Promise<string[]> {\n  const seen = new Set<string>()\n  const normalized: string[] = []\n  let candidateCount = 0\n\n  for (let index = 0; index < inputs.length; index += 1) {\n    const item = inputs[index]\n    if (typeof item !== 'string') continue\n    const trimmed = item.trim()\n    if (!trimmed || seen.has(trimmed)) continue\n    seen.add(trimmed)\n    candidateCount += 1\n\n    try {\n      normalized.push(await normalizeToBase64ForGeneration(trimmed))\n    } catch (error) {\n      const issue = toNormalizationIssue(error, trimmed, index)\n      options.onIssue?.(issue)\n      logger.warn({\n        message: 'reference image normalize failed',\n        details: {\n          ...issue,\n          context: options.context || null,\n        },\n      })\n    }\n  }\n\n  if (candidateCount > 0 && normalized.length === 0) {\n    throw new OutboundImageNormalizeError({\n      code: 'OUTBOUND_IMAGE_REFERENCE_ALL_FAILED',\n      stage: 'normalize_reference',\n      input: `candidates=${candidateCount}`,\n      message: 'all reference images failed to normalize',\n    })\n  }\n\n  return normalized\n}\n\nexport function sanitizeImageInputsForTaskPayload(inputs: unknown[]): {\n  normalized: string[]\n  issues: OutboundImageInputIssue[]\n} {\n  const issues: OutboundImageInputIssue[] = []\n  const normalized: string[] = []\n  const seen = new Set<string>()\n\n  for (let i = 0; i < inputs.length; i += 1) {\n    const raw = inputs[i]\n    if (typeof raw !== 'string') {\n      issues.push({ index: i, input: raw, reason: 'non_string_skipped' })\n      continue\n    }\n\n    const trimmed = raw.trim()\n    if (!trimmed) {\n      issues.push({ index: i, input: raw, reason: 'empty_value_skipped' })\n      continue\n    }\n\n    const unwrapped = unwrapNextImageInternal(trimmed)\n    if (unwrapped !== trimmed) {\n      issues.push({ index: i, input: raw, normalized: unwrapped, reason: 'next_image_unwrapped' })\n    }\n\n    if (unwrapped.startsWith('/') && !unwrapped.startsWith('/m/') && !unwrapped.startsWith('/api/')) {\n      issues.push({ index: i, input: raw, normalized: unwrapped, reason: 'relative_path_rejected' })\n      continue\n    }\n\n    if (seen.has(unwrapped)) continue\n    seen.add(unwrapped)\n    normalized.push(unwrapped)\n  }\n\n  return { normalized, issues }\n}\n"
  },
  {
    "path": "src/lib/media/service.ts",
    "content": "import path from 'node:path'\nimport { prisma } from '@/lib/prisma'\nimport { extractStorageKey } from '@/lib/storage'\nimport { stablePublicIdFromStorageKey } from './hash'\nimport type { MediaRef } from './types'\n\ntype MediaObjectRow = {\n  id: string\n  publicId: string\n  storageKey: string\n  sha256: string | null\n  mimeType: string | null\n  sizeBytes: bigint | number | null\n  width: number | null\n  height: number | null\n  durationMs: number | null\n  updatedAt: Date | string\n}\n\ntype MediaModel = {\n  findUnique: (args: unknown) => Promise<unknown>\n  upsert: (args: unknown) => Promise<unknown>\n}\n\nconst mediaModel = (prisma as unknown as { mediaObject: MediaModel }).mediaObject\n\nconst MIME_BY_EXT: Record<string, string> = {\n  '.png': 'image/png',\n  '.jpg': 'image/jpeg',\n  '.jpeg': 'image/jpeg',\n  '.webp': 'image/webp',\n  '.gif': 'image/gif',\n  '.svg': 'image/svg+xml',\n  '.mp4': 'video/mp4',\n  '.webm': 'video/webm',\n  '.mov': 'video/quicktime',\n  '.mp3': 'audio/mpeg',\n  '.wav': 'audio/wav',\n  '.ogg': 'audio/ogg',\n  '.m4a': 'audio/mp4',\n}\n\nfunction normalizeStorageKey(value: string): string {\n  return value.replace(/^\\/+/, '')\n}\n\nfunction isLikelyExternalUrl(value: string): boolean {\n  return value.startsWith('http://') || value.startsWith('https://')\n}\n\nfunction guessMimeTypeFromStorageKey(storageKey: string): string | null {\n  const ext = path.extname(storageKey).toLowerCase()\n  return MIME_BY_EXT[ext] || null\n}\n\nfunction mediaUrl(publicId: string): string {\n  return `/m/${encodeURIComponent(publicId)}`\n}\n\nfunction extractPublicIdFromMediaRoute(value: string): string | null {\n  if (!value.startsWith('/m/')) return null\n  const routePart = value.split('?')[0]?.split('#')[0] || ''\n  const encoded = routePart.replace('/m/', '').replace(/^\\/+/, '')\n  if (!encoded) return null\n  try {\n    return decodeURIComponent(encoded)\n  } catch {\n    return encoded\n  }\n}\n\nfunction mapMediaObjectToRef(row: MediaObjectRow): MediaRef {\n  return {\n    id: row.id,\n    publicId: row.publicId,\n    url: mediaUrl(row.publicId),\n    sha256: row.sha256,\n    mimeType: row.mimeType,\n    sizeBytes: row.sizeBytes == null ? null : Number(row.sizeBytes),\n    width: row.width,\n    height: row.height,\n    durationMs: row.durationMs,\n    updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,\n    storageKey: row.storageKey,\n  }\n}\n\nexport async function ensureMediaObjectFromStorageKey(\n  rawStorageKey: string,\n  metadata?: Partial<Pick<MediaRef, 'mimeType' | 'sizeBytes' | 'width' | 'height' | 'durationMs'>>,\n): Promise<MediaRef> {\n  const storageKey = normalizeStorageKey(rawStorageKey)\n\n  const existing = (await mediaModel.findUnique({ where: { storageKey } })) as MediaObjectRow | null\n  if (existing != null) {\n    return mapMediaObjectToRef(existing)\n  }\n\n  const publicId = stablePublicIdFromStorageKey(storageKey)\n  try {\n    const created = (await mediaModel.upsert({\n      where: { publicId },\n      update: {\n        storageKey,\n        mimeType: metadata?.mimeType ?? guessMimeTypeFromStorageKey(storageKey),\n        sizeBytes: metadata?.sizeBytes == null ? undefined : BigInt(metadata.sizeBytes),\n        width: metadata?.width ?? undefined,\n        height: metadata?.height ?? undefined,\n        durationMs: metadata?.durationMs ?? undefined,\n      },\n      create: {\n        publicId,\n        storageKey,\n        mimeType: metadata?.mimeType ?? guessMimeTypeFromStorageKey(storageKey),\n        sizeBytes: metadata?.sizeBytes == null ? null : BigInt(metadata.sizeBytes),\n        width: metadata?.width ?? null,\n        height: metadata?.height ?? null,\n        durationMs: metadata?.durationMs ?? null,\n      },\n    })) as MediaObjectRow\n\n    return mapMediaObjectToRef(created)\n  } catch (error: unknown) {\n    // P2002 = unique constraint violation. Another concurrent request already\n    // created/updated the row.  Re-fetch instead of crashing.\n    const code = (error as { code?: string })?.code\n    if (code === 'P2002') {\n      const fallback = (await mediaModel.findUnique({ where: { publicId } })) as MediaObjectRow | null\n        ?? (await mediaModel.findUnique({ where: { storageKey } })) as MediaObjectRow | null\n      if (fallback) return mapMediaObjectToRef(fallback)\n    }\n    throw error\n  }\n}\n\nexport async function getMediaObjectByPublicId(publicId: string) {\n  const row = (await mediaModel.findUnique({ where: { publicId } })) as MediaObjectRow | null\n  if (!row) return null\n  return mapMediaObjectToRef(row)\n}\n\nexport async function getMediaObjectById(id: string) {\n  const row = (await mediaModel.findUnique({ where: { id } })) as MediaObjectRow | null\n  if (!row) return null\n  return mapMediaObjectToRef(row)\n}\n\n/**\n * 将任意媒体值（COS key / 签名URL / /m/publicId / 对象形态）归一化为 storageKey。\n * 这是服务端写路径（保存、比较、删除）应使用的唯一入口。\n */\nexport async function resolveStorageKeyFromMediaValue(value: unknown): Promise<string | null> {\n  if (typeof value === 'string') {\n    const publicId = extractPublicIdFromMediaRoute(value)\n    if (publicId) {\n      const media = await getMediaObjectByPublicId(publicId)\n      return media?.storageKey || null\n    }\n    const key = extractStorageKey(value)\n    return key ? normalizeStorageKey(key) : null\n  }\n\n  if (value && typeof value === 'object') {\n    const maybeValue = (value as { url?: unknown; imageUrl?: unknown; key?: unknown }).url\n      ?? (value as { imageUrl?: unknown }).imageUrl\n      ?? (value as { key?: unknown }).key\n    return resolveStorageKeyFromMediaValue(maybeValue)\n  }\n\n  return null\n}\n\nexport function extractStorageKeyFromLegacyValue(value: unknown): string | null {\n  if (typeof value !== 'string' || !value.trim()) return null\n  if (value.startsWith('/m/')) return null\n\n  // Keep external URLs that are actually COS object URLs (path -> key).\n  if (isLikelyExternalUrl(value) || value.startsWith('/api/files/') || !value.startsWith('/')) {\n    return extractStorageKey(value)\n  }\n\n  return null\n}\n\nexport async function resolveMediaRefFromLegacyValue(value: unknown): Promise<MediaRef | null> {\n  const storageKey = extractStorageKeyFromLegacyValue(value)\n  if (!storageKey) return null\n  return ensureMediaObjectFromStorageKey(storageKey)\n}\n\nexport async function resolveMediaRef(\n  mediaId: unknown,\n  legacyValue: unknown,\n): Promise<MediaRef | null> {\n  if (typeof mediaId === 'string' && mediaId.trim()) {\n    const mediaById = await getMediaObjectById(mediaId)\n    if (mediaById) return mediaById\n  }\n  return resolveMediaRefFromLegacyValue(legacyValue)\n}\n\nexport async function resolveMediaRefsFromLegacyJsonArray(jsonStr: unknown): Promise<MediaRef[]> {\n  if (typeof jsonStr !== 'string' || !jsonStr.trim()) return []\n  try {\n    const parsed = JSON.parse(jsonStr)\n    if (!Array.isArray(parsed)) return []\n\n    const refs = await Promise.all(\n      parsed\n        .filter((v): v is string => typeof v === 'string' && v.trim().length > 0)\n        .map((v) => resolveMediaRefFromLegacyValue(v)),\n    )\n\n    return refs.filter((v): v is MediaRef => !!v)\n  } catch {\n    return []\n  }\n}\n\nexport function mediaUrlFromRef(ref: MediaRef | null | undefined, fallback: string | null | undefined): string | null {\n  if (ref?.url) return ref.url\n  return fallback || null\n}\n"
  },
  {
    "path": "src/lib/media/types.ts",
    "content": "export interface MediaRef {\n  id: string\n  publicId: string\n  url: string\n  mimeType: string | null\n  sizeBytes: number | null\n  width: number | null\n  height: number | null\n  durationMs: number | null\n  sha256?: string | null\n  updatedAt?: string | null\n  storageKey?: string\n}\n"
  },
  {
    "path": "src/lib/media-process.ts",
    "content": "import { downloadAndUploadVideo, generateUniqueKey, toFetchableUrl, uploadObject } from '@/lib/storage'\n\nexport interface ProcessMediaOptions {\n  source: string | Buffer\n  type: 'image' | 'video' | 'audio'\n  keyPrefix: string\n  targetId: string\n  downloadHeaders?: Record<string, string>\n}\n\nconst MIME_BY_EXT: Record<string, string> = {\n  jpg: 'image/jpeg',\n  jpeg: 'image/jpeg',\n  png: 'image/png',\n  webp: 'image/webp',\n  gif: 'image/gif',\n  mp4: 'video/mp4',\n  webm: 'video/webm',\n  mp3: 'audio/mpeg',\n  wav: 'audio/wav',\n  ogg: 'audio/ogg',\n  m4a: 'audio/mp4',\n}\n\nfunction resolveContentType(ext: string): string {\n  return MIME_BY_EXT[ext] || 'application/octet-stream'\n}\n\n/**\n * 处理媒体结果：下载 -> 上传 COS，返回 COS key。\n */\nexport async function processMediaResult(options: ProcessMediaOptions): Promise<string> {\n  const { source, type, keyPrefix, targetId, downloadHeaders } = options\n  const ext = type === 'video' ? 'mp4' : type === 'audio' ? 'mp3' : 'jpg'\n  const key = generateUniqueKey(`${keyPrefix}-${targetId}`, ext)\n  const contentType = resolveContentType(ext)\n\n  if (typeof source === 'string') {\n    if (source.startsWith('data:')) {\n      const base64Start = source.indexOf(';base64,')\n      if (base64Start === -1) throw new Error('无法解析 data: URL')\n      const base64Data = source.substring(base64Start + 8)\n      const buffer = Buffer.from(base64Data, 'base64') as Buffer\n      return await uploadObject(buffer, key, undefined, contentType)\n    }\n\n    if (type === 'video') {\n      return await downloadAndUploadVideo(source, key, 3, downloadHeaders)\n    }\n\n    const response = await fetch(toFetchableUrl(source))\n    const buffer = Buffer.from(await response.arrayBuffer()) as Buffer\n    return await uploadObject(buffer, key, undefined, contentType)\n  }\n\n  return await uploadObject(source, key, undefined, contentType)\n}\n"
  },
  {
    "path": "src/lib/migrations/gateway-route-openai-compat.ts",
    "content": "type ProviderEntryMigrationSummary = {\n  providersScanned: number\n  providersChanged: number\n  routeLitellmToOpenaiCompat: number\n  routeForcedOfficial: number\n  geminiApiModeCorrected: number\n}\n\nexport type GatewayRoutePayloadMigrationSummary = ProviderEntryMigrationSummary & {\n  invalidPayload: boolean\n}\n\ntype ProviderMigrationResult = {\n  changed: boolean\n  next: unknown\n  summary: ProviderEntryMigrationSummary\n}\n\ntype PayloadMigrationResult = {\n  status: 'ok' | 'invalid'\n  changed: boolean\n  nextRaw: string | null | undefined\n  summary: GatewayRoutePayloadMigrationSummary\n}\n\nfunction zeroProviderSummary(): ProviderEntryMigrationSummary {\n  return {\n    providersScanned: 0,\n    providersChanged: 0,\n    routeLitellmToOpenaiCompat: 0,\n    routeForcedOfficial: 0,\n    geminiApiModeCorrected: 0,\n  }\n}\n\nfunction addProviderSummary(\n  left: ProviderEntryMigrationSummary,\n  right: ProviderEntryMigrationSummary,\n): ProviderEntryMigrationSummary {\n  return {\n    providersScanned: left.providersScanned + right.providersScanned,\n    providersChanged: left.providersChanged + right.providersChanged,\n    routeLitellmToOpenaiCompat: left.routeLitellmToOpenaiCompat + right.routeLitellmToOpenaiCompat,\n    routeForcedOfficial: left.routeForcedOfficial + right.routeForcedOfficial,\n    geminiApiModeCorrected: left.geminiApiModeCorrected + right.geminiApiModeCorrected,\n  }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction getProviderKey(providerId: string): string {\n  const index = providerId.indexOf(':')\n  return index === -1 ? providerId : providerId.slice(0, index)\n}\n\nexport function migrateProviderEntry(rawProvider: unknown): ProviderMigrationResult {\n  const summary = zeroProviderSummary()\n  if (!isRecord(rawProvider)) {\n    return { changed: false, next: rawProvider, summary }\n  }\n\n  const providerId = readTrimmedString(rawProvider.id)\n  if (!providerId) {\n    return { changed: false, next: rawProvider, summary }\n  }\n  summary.providersScanned = 1\n\n  const providerKey = getProviderKey(providerId)\n  const nextProvider: Record<string, unknown> = { ...rawProvider }\n  let changed = false\n\n  const routeRaw = readTrimmedString(rawProvider.gatewayRoute)\n  const apiModeRaw = readTrimmedString(rawProvider.apiMode)\n\n  if (providerKey === 'openai-compatible') {\n    if (routeRaw !== 'openai-compat') {\n      nextProvider.gatewayRoute = 'openai-compat'\n      changed = true\n      if (routeRaw === 'litellm') {\n        summary.routeLitellmToOpenaiCompat += 1\n      }\n    }\n  } else if (providerKey === 'gemini-compatible') {\n    if (apiModeRaw === 'openai-official') {\n      nextProvider.apiMode = 'gemini-sdk'\n      changed = true\n      summary.geminiApiModeCorrected += 1\n    }\n    if (routeRaw !== 'official') {\n      nextProvider.gatewayRoute = 'official'\n      changed = true\n      if (routeRaw === 'litellm' || routeRaw === 'openai-compat') {\n        summary.routeForcedOfficial += 1\n      }\n    }\n  } else {\n    if (routeRaw === 'litellm' || routeRaw === 'openai-compat') {\n      nextProvider.gatewayRoute = 'official'\n      changed = true\n      summary.routeForcedOfficial += 1\n    }\n  }\n\n  if (changed) {\n    summary.providersChanged = 1\n  }\n  return { changed, next: changed ? nextProvider : rawProvider, summary }\n}\n\nexport function migrateGatewayRoutePayload(rawProviders: string | null | undefined): PayloadMigrationResult {\n  const baseSummary: GatewayRoutePayloadMigrationSummary = {\n    ...zeroProviderSummary(),\n    invalidPayload: false,\n  }\n  if (!rawProviders) {\n    return {\n      status: 'ok',\n      changed: false,\n      nextRaw: rawProviders,\n      summary: baseSummary,\n    }\n  }\n\n  let parsedUnknown: unknown\n  try {\n    parsedUnknown = JSON.parse(rawProviders) as unknown\n  } catch {\n    return {\n      status: 'invalid',\n      changed: false,\n      nextRaw: rawProviders,\n      summary: { ...baseSummary, invalidPayload: true },\n    }\n  }\n\n  if (!Array.isArray(parsedUnknown)) {\n    return {\n      status: 'invalid',\n      changed: false,\n      nextRaw: rawProviders,\n      summary: { ...baseSummary, invalidPayload: true },\n    }\n  }\n\n  const nextProviders: unknown[] = []\n  let summary = zeroProviderSummary()\n  let changed = false\n  for (const provider of parsedUnknown) {\n    const result = migrateProviderEntry(provider)\n    summary = addProviderSummary(summary, result.summary)\n    changed = changed || result.changed\n    nextProviders.push(result.next)\n  }\n\n  return {\n    status: 'ok',\n    changed,\n    nextRaw: changed ? JSON.stringify(nextProviders) : rawProviders,\n    summary: {\n      ...summary,\n      invalidPayload: false,\n    },\n  }\n}\n"
  },
  {
    "path": "src/lib/model-capabilities/catalog.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\nimport {\n  composeModelKey,\n  validateModelCapabilities,\n  type ModelCapabilities,\n  type UnifiedModelType,\n} from '@/lib/model-config-contract'\n\nexport interface BuiltinCapabilityCatalogEntry {\n  modelType: UnifiedModelType\n  provider: string\n  modelId: string\n  capabilities?: ModelCapabilities\n}\n\ninterface CatalogCache {\n  signature: string\n  entries: BuiltinCapabilityCatalogEntry[]\n  exact: Map<string, BuiltinCapabilityCatalogEntry>\n  byProviderKey: Map<string, BuiltinCapabilityCatalogEntry>\n}\n\nconst CATALOG_DIR = path.resolve(process.cwd(), 'standards/capabilities')\nlet cache: CatalogCache | null = null\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isUnifiedModelType(value: unknown): value is UnifiedModelType {\n  return value === 'llm'\n    || value === 'image'\n    || value === 'video'\n    || value === 'audio'\n    || value === 'lipsync'\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction getProviderKey(providerId: string): string {\n  const marker = providerId.indexOf(':')\n  return marker === -1 ? providerId : providerId.slice(0, marker)\n}\n\nfunction cloneCapabilities(capabilities: ModelCapabilities | undefined): ModelCapabilities | undefined {\n  if (!capabilities) return undefined\n  return JSON.parse(JSON.stringify(capabilities)) as ModelCapabilities\n}\n\nfunction normalizeEntry(raw: unknown, filePath: string, index: number): BuiltinCapabilityCatalogEntry {\n  if (!isRecord(raw)) {\n    throw new Error(`CAPABILITY_CATALOG_INVALID: ${filePath}#${index} must be object`)\n  }\n\n  const modelTypeRaw = raw.modelType\n  if (!isUnifiedModelType(modelTypeRaw)) {\n    throw new Error(`CAPABILITY_CATALOG_INVALID: ${filePath}#${index} modelType invalid`)\n  }\n\n  const provider = readTrimmedString(raw.provider)\n  const modelId = readTrimmedString(raw.modelId)\n  if (!provider || !modelId) {\n    throw new Error(`CAPABILITY_CATALOG_INVALID: ${filePath}#${index} provider/modelId required`)\n  }\n\n  const capabilitiesRaw = raw.capabilities\n  const capabilityIssues = validateModelCapabilities(modelTypeRaw, capabilitiesRaw)\n  if (capabilityIssues.length > 0) {\n    const firstIssue = capabilityIssues[0]\n    throw new Error(\n      `CAPABILITY_CATALOG_INVALID: ${filePath}#${index} ${firstIssue.code} ${firstIssue.field} ${firstIssue.message}`,\n    )\n  }\n\n  return {\n    modelType: modelTypeRaw,\n    provider,\n    modelId,\n    ...(capabilitiesRaw && isRecord(capabilitiesRaw)\n      ? { capabilities: capabilitiesRaw as ModelCapabilities }\n      : {}),\n  }\n}\n\nfunction buildCache(entries: BuiltinCapabilityCatalogEntry[], signature: string): CatalogCache {\n  const exact = new Map<string, BuiltinCapabilityCatalogEntry>()\n  const byProviderKey = new Map<string, BuiltinCapabilityCatalogEntry>()\n\n  for (const entry of entries) {\n    const modelKey = composeModelKey(entry.provider, entry.modelId)\n    if (!modelKey) continue\n\n    const exactKey = `${entry.modelType}::${modelKey}`\n    if (exact.has(exactKey)) {\n      throw new Error(`CAPABILITY_CATALOG_DUPLICATE: ${exactKey}`)\n    }\n    exact.set(exactKey, entry)\n\n    const providerKey = getProviderKey(entry.provider)\n    const fallbackKey = `${entry.modelType}::${providerKey}::${entry.modelId}`\n    if (!byProviderKey.has(fallbackKey)) {\n      byProviderKey.set(fallbackKey, entry)\n    }\n  }\n\n  return { signature, entries, exact, byProviderKey }\n}\n\nfunction resolveCatalogFiles(): string[] {\n  return fs\n    .readdirSync(CATALOG_DIR, { withFileTypes: true })\n    .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))\n    .map((entry) => path.join(CATALOG_DIR, entry.name))\n    .sort((left, right) => left.localeCompare(right))\n}\n\nfunction buildCatalogSignature(files: string[]): string {\n  return files\n    .map((filePath) => {\n      const stat = fs.statSync(filePath)\n      return `${filePath}:${stat.mtimeMs}:${stat.size}`\n    })\n    .join('|')\n}\n\nfunction loadCatalog(): CatalogCache {\n  const entries: BuiltinCapabilityCatalogEntry[] = []\n  const files = resolveCatalogFiles()\n\n  if (files.length === 0) {\n    throw new Error(`CAPABILITY_CATALOG_MISSING: no json file in ${CATALOG_DIR}`)\n  }\n  const signature = buildCatalogSignature(files)\n  if (cache && cache.signature === signature) return cache\n\n  for (const filePath of files) {\n    const raw = fs.readFileSync(filePath, 'utf8')\n    const parsed = JSON.parse(raw) as unknown\n    if (!Array.isArray(parsed)) {\n      throw new Error(`CAPABILITY_CATALOG_INVALID: ${filePath} must be array`)\n    }\n    for (let index = 0; index < parsed.length; index += 1) {\n      entries.push(normalizeEntry(parsed[index], filePath, index))\n    }\n  }\n\n  cache = buildCache(entries, signature)\n  return cache\n}\n\nexport function listBuiltinCapabilityCatalog(): BuiltinCapabilityCatalogEntry[] {\n  return loadCatalog().entries.map((entry) => ({\n    ...entry,\n    capabilities: cloneCapabilities(entry.capabilities),\n  }))\n}\n\n/**\n * Provider keys that share capability catalogs with a canonical provider.\n * gemini-compatible uses the same models as google.\n */\nconst CAPABILITY_PROVIDER_ALIASES: Readonly<Record<string, string>> = {\n  'gemini-compatible': 'google',\n}\n\nexport function findBuiltinCapabilityCatalogEntry(\n  modelType: UnifiedModelType,\n  provider: string,\n  modelId: string,\n): BuiltinCapabilityCatalogEntry | null {\n  const loaded = loadCatalog()\n  const modelKey = composeModelKey(provider, modelId)\n  if (!modelKey) return null\n\n  const exactKey = `${modelType}::${modelKey}`\n  const exactMatch = loaded.exact.get(exactKey)\n  if (exactMatch) {\n    return {\n      ...exactMatch,\n      capabilities: cloneCapabilities(exactMatch.capabilities),\n    }\n  }\n\n  const providerKey = getProviderKey(provider)\n  const fallbackKey = `${modelType}::${providerKey}::${modelId}`\n  const fallback = loaded.byProviderKey.get(fallbackKey)\n  if (fallback) {\n    return {\n      ...fallback,\n      capabilities: cloneCapabilities(fallback.capabilities),\n    }\n  }\n\n  // Fallback: check canonical provider alias (e.g. gemini-compatible → google)\n  const aliasTarget = CAPABILITY_PROVIDER_ALIASES[providerKey]\n  if (aliasTarget) {\n    const aliasKey = `${modelType}::${aliasTarget}::${modelId}`\n    const aliasMatch = loaded.byProviderKey.get(aliasKey)\n    if (aliasMatch) {\n      return {\n        ...aliasMatch,\n        capabilities: cloneCapabilities(aliasMatch.capabilities),\n      }\n    }\n  }\n\n  return null\n}\n\nexport function findBuiltinCapabilities(\n  modelType: UnifiedModelType,\n  provider: string,\n  modelId: string,\n): ModelCapabilities | undefined {\n  return findBuiltinCapabilityCatalogEntry(modelType, provider, modelId)?.capabilities\n}\n\nexport function resetBuiltinCapabilityCatalogCacheForTest() {\n  cache = null\n}\n"
  },
  {
    "path": "src/lib/model-capabilities/lookup.ts",
    "content": "import {\n  parseModelKeyStrict,\n  type CapabilitySelections,\n  type CapabilityValue,\n  type CapabilityOptionValue,\n  type ModelCapabilities,\n  type UnifiedModelType,\n} from '@/lib/model-config-contract'\nimport { findBuiltinCapabilities, findBuiltinCapabilityCatalogEntry } from '@/lib/model-capabilities/catalog'\n\nexport type CapabilitySelectionValidationCode =\n  | 'CAPABILITY_SELECTION_INVALID'\n  | 'CAPABILITY_MODEL_UNSUPPORTED'\n  | 'CAPABILITY_FIELD_INVALID'\n  | 'CAPABILITY_VALUE_NOT_ALLOWED'\n  | 'CAPABILITY_REQUIRED'\n\nexport interface CapabilitySelectionValidationIssue {\n  code: CapabilitySelectionValidationCode\n  field: string\n  message: string\n  allowedValues?: readonly CapabilityOptionValue[]\n}\n\nexport interface CapabilityModelContext {\n  modelType: UnifiedModelType\n  capabilities?: ModelCapabilities\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isCapabilityValue(value: unknown): value is CapabilityValue {\n  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction getNamespaceCapabilities(\n  modelType: UnifiedModelType,\n  capabilities: ModelCapabilities | undefined,\n): Record<string, unknown> | null {\n  if (!capabilities) return null\n  const namespace = capabilities[modelType]\n  if (!namespace || !isRecord(namespace)) return null\n  return namespace\n}\n\nexport function getCapabilityOptionFields(\n  modelType: UnifiedModelType,\n  capabilities: ModelCapabilities | undefined,\n): Record<string, readonly CapabilityOptionValue[]> {\n  const namespace = getNamespaceCapabilities(modelType, capabilities)\n  if (!namespace) return {}\n\n  const fields: Record<string, readonly CapabilityOptionValue[]> = {}\n  for (const [key, rawValue] of Object.entries(namespace)) {\n    if (!key.endsWith('Options')) continue\n    if (!Array.isArray(rawValue)) continue\n    if (rawValue.length === 0) continue\n    if (!rawValue.every((item) => isCapabilityValue(item))) continue\n    const field = key.slice(0, -'Options'.length)\n    fields[field] = rawValue as CapabilityOptionValue[]\n  }\n  return fields\n}\n\nexport function hasCapabilityOptions(\n  modelType: UnifiedModelType,\n  capabilities: ModelCapabilities | undefined,\n): boolean {\n  return Object.keys(getCapabilityOptionFields(modelType, capabilities)).length > 0\n}\n\nfunction normalizeSelectionRecord(\n  value: unknown,\n  field: string,\n): { value: Record<string, CapabilityValue> | null; issues: CapabilitySelectionValidationIssue[] } {\n  const issues: CapabilitySelectionValidationIssue[] = []\n  if (value === undefined || value === null) {\n    return { value: null, issues }\n  }\n  if (!isRecord(value)) {\n    issues.push({\n      code: 'CAPABILITY_SELECTION_INVALID',\n      field,\n      message: 'selection must be an object',\n    })\n    return { value: null, issues }\n  }\n\n  const normalized: Record<string, CapabilityValue> = {}\n  for (const [key, raw] of Object.entries(value)) {\n    if (!isCapabilityValue(raw)) {\n      issues.push({\n        code: 'CAPABILITY_SELECTION_INVALID',\n        field: `${field}.${key}`,\n        message: 'selection value must be string/number/boolean',\n      })\n      continue\n    }\n    normalized[key] = raw\n  }\n  return { value: normalized, issues }\n}\n\nexport function validateCapabilitySelectionForModel(input: {\n  modelKey: string\n  modelType: UnifiedModelType\n  capabilities?: ModelCapabilities\n  selection?: Record<string, CapabilityValue> | null\n  requireAllFields: boolean\n}): CapabilitySelectionValidationIssue[] {\n  const issues: CapabilitySelectionValidationIssue[] = []\n  const optionFields = getCapabilityOptionFields(input.modelType, input.capabilities)\n  const optionFieldNames = new Set(Object.keys(optionFields))\n  const selection = input.selection || {}\n\n  if (Object.keys(optionFields).length === 0) {\n    if (Object.keys(selection).length > 0) {\n      issues.push({\n        code: 'CAPABILITY_FIELD_INVALID',\n        field: `capabilities.${input.modelKey}`,\n        message: 'model has no configurable capability options',\n      })\n    }\n    return issues\n  }\n\n  for (const [field, value] of Object.entries(selection)) {\n    if (!optionFieldNames.has(field)) {\n      issues.push({\n        code: 'CAPABILITY_FIELD_INVALID',\n        field: `capabilities.${input.modelKey}.${field}`,\n        message: `field ${field} is not supported by model ${input.modelKey}`,\n      })\n      continue\n    }\n\n    const allowedValues = optionFields[field]\n    if (!allowedValues.includes(value)) {\n      issues.push({\n        code: 'CAPABILITY_VALUE_NOT_ALLOWED',\n        field: `capabilities.${input.modelKey}.${field}`,\n        message: `value ${String(value)} is not allowed`,\n        allowedValues,\n      })\n    }\n  }\n\n  if (input.requireAllFields) {\n    for (const field of Object.keys(optionFields)) {\n      if (selection[field] === undefined) {\n        issues.push({\n          code: 'CAPABILITY_REQUIRED',\n          field: `capabilities.${input.modelKey}.${field}`,\n          message: `field ${field} is required for model ${input.modelKey}`,\n          allowedValues: optionFields[field],\n        })\n      }\n    }\n  }\n\n  return issues\n}\n\nexport function validateCapabilitySelectionsPayload(\n  selections: unknown,\n  resolveModelContext: (modelKey: string) => CapabilityModelContext | null,\n): CapabilitySelectionValidationIssue[] {\n  const issues: CapabilitySelectionValidationIssue[] = []\n\n  if (selections === undefined || selections === null) return issues\n  if (!isRecord(selections)) {\n    issues.push({\n      code: 'CAPABILITY_SELECTION_INVALID',\n      field: 'capabilitySelections',\n      message: 'capability selections must be an object',\n    })\n    return issues\n  }\n\n  for (const [modelKey, rawSelection] of Object.entries(selections)) {\n    if (!parseModelKeyStrict(modelKey)) {\n      issues.push({\n        code: 'CAPABILITY_SELECTION_INVALID',\n        field: `capabilitySelections.${modelKey}`,\n        message: 'model key must be provider::modelId',\n      })\n      continue\n    }\n\n    const context = resolveModelContext(modelKey)\n    if (!context) {\n      issues.push({\n        code: 'CAPABILITY_MODEL_UNSUPPORTED',\n        field: `capabilitySelections.${modelKey}`,\n        message: `model ${modelKey} is not supported by built-in capability catalog`,\n      })\n      continue\n    }\n\n    const normalizedSelection = normalizeSelectionRecord(\n      rawSelection,\n      `capabilitySelections.${modelKey}`,\n    )\n    issues.push(...normalizedSelection.issues)\n    if (!normalizedSelection.value) continue\n\n    issues.push(...validateCapabilitySelectionForModel({\n      modelKey,\n      modelType: context.modelType,\n      capabilities: context.capabilities,\n      selection: normalizedSelection.value,\n      requireAllFields: false,\n    }))\n  }\n\n  return issues\n}\n\nfunction mergeSelectionRecords(...records: Array<Record<string, CapabilityValue> | undefined>): Record<string, CapabilityValue> {\n  const merged: Record<string, CapabilityValue> = {}\n  for (const record of records) {\n    if (!record) continue\n    for (const [field, value] of Object.entries(record)) {\n      merged[field] = value\n    }\n  }\n  return merged\n}\n\nfunction pickSelectionForModel(\n  selections: CapabilitySelections | undefined,\n  modelKey: string,\n): Record<string, CapabilityValue> | undefined {\n  if (!selections) return undefined\n  const selected = selections[modelKey]\n  if (!selected || !isRecord(selected)) return undefined\n\n  const normalized: Record<string, CapabilityValue> = {}\n  for (const [field, rawValue] of Object.entries(selected)) {\n    if (field === 'aspectRatio') continue\n    if (!isCapabilityValue(rawValue)) continue\n    normalized[field] = rawValue\n  }\n  return normalized\n}\n\nexport function resolveGenerationOptionsForModel(input: {\n  modelType: UnifiedModelType\n  modelKey: string\n  capabilities?: ModelCapabilities\n  capabilityDefaults?: CapabilitySelections\n  capabilityOverrides?: CapabilitySelections\n  runtimeSelections?: Record<string, CapabilityValue>\n  requireAllFields?: boolean\n}): { options: Record<string, CapabilityValue>; issues: CapabilitySelectionValidationIssue[] } {\n  const defaults = pickSelectionForModel(input.capabilityDefaults, input.modelKey)\n  const overrides = pickSelectionForModel(input.capabilityOverrides, input.modelKey)\n  const runtime = input.runtimeSelections\n\n  const selection = mergeSelectionRecords(defaults, overrides, runtime)\n\n  // Custom model not in built-in catalog: skip validation, pass through selections directly\n  if (input.capabilities === undefined) {\n    return { options: { ...selection }, issues: [] }\n  }\n\n  // 对有能力选项的模型做一次预检，捕获「必填字段缺失」场景\n  const precheckIssues = validateCapabilitySelectionForModel({\n    modelKey: input.modelKey,\n    modelType: input.modelType,\n    capabilities: input.capabilities,\n    selection,\n    requireAllFields: input.requireAllFields ?? true,\n  })\n\n  let normalizedSelection = { ...selection }\n  const autofillIssues: CapabilitySelectionValidationIssue[] = []\n\n  // V7: 针对 image 模型缺少 resolution 的情况，如果 catalog 中声明了 resolutionOptions，\n  // 且用户在配置中完全未设置该字段，则自动使用第一个可选值作为默认值，提升 UI/UX。\n  if (input.modelType === 'image') {\n    const optionFields = getCapabilityOptionFields(input.modelType, input.capabilities)\n    const hasResolutionOptions = Array.isArray(optionFields.resolution) && optionFields.resolution.length > 0\n    const hasResolutionInSelection = Object.prototype.hasOwnProperty.call(normalizedSelection, 'resolution')\n\n    if (hasResolutionOptions && !hasResolutionInSelection) {\n      const firstResolution = optionFields.resolution[0]\n\n      // 只有在 capabilities 确实声明了 resolutionOptions，且 validate 阶段报告了\n      // 「resolution 必填但缺失」的情况下，才进行自动补全，避免掩盖其他问题。\n      const missingResolutionIssue = precheckIssues.find(\n        (issue) =>\n          issue.code === 'CAPABILITY_REQUIRED'\n          && issue.field === `capabilities.${input.modelKey}.resolution`,\n      )\n\n      if (missingResolutionIssue && optionFields.resolution.includes(firstResolution)) {\n        normalizedSelection = {\n          ...normalizedSelection,\n          resolution: firstResolution,\n        }\n      }\n    }\n  }\n\n  // 使用补全后的 selection 再做一次严格校验，确保不会产生非法值\n  const issues = validateCapabilitySelectionForModel({\n    modelKey: input.modelKey,\n    modelType: input.modelType,\n    capabilities: input.capabilities,\n    selection: normalizedSelection,\n    requireAllFields: input.requireAllFields ?? true,\n  })\n\n  if (issues.length > 0) {\n    return { options: {}, issues: autofillIssues.length > 0 ? [...autofillIssues, ...issues] : issues }\n  }\n\n  const optionFields = getCapabilityOptionFields(input.modelType, input.capabilities)\n  const options: Record<string, CapabilityValue> = {}\n  for (const field of Object.keys(optionFields)) {\n    const value = normalizedSelection[field]\n    if (value !== undefined) {\n      options[field] = value\n    }\n  }\n\n  return { options, issues: [] }\n}\n\nexport function resolveBuiltinModelContext(modelType: UnifiedModelType, modelKey: string): CapabilityModelContext | null {\n  const parsed = parseModelKeyStrict(modelKey)\n  if (!parsed) return null\n  const entry = findBuiltinCapabilityCatalogEntry(modelType, parsed.provider, parsed.modelId)\n  if (!entry) return null\n  return {\n    modelType: entry.modelType,\n    capabilities: entry.capabilities,\n  }\n}\n\nexport function resolveBuiltinCapabilitiesByModelKey(\n  modelType: UnifiedModelType,\n  modelKey: string,\n): ModelCapabilities | undefined {\n  const parsed = parseModelKeyStrict(modelKey)\n  if (!parsed) return undefined\n  return findBuiltinCapabilities(modelType, parsed.provider, parsed.modelId)\n}\n"
  },
  {
    "path": "src/lib/model-capabilities/video-effective.ts",
    "content": "import type {\n  CapabilityFieldI18n,\n  CapabilityValue,\n  VideoCapabilities,\n} from '@/lib/model-config-contract'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\n\nexport interface EffectiveVideoCapabilityDefinition {\n  field: string\n  options: CapabilityValue[]\n  fieldI18n: CapabilityFieldI18n | null\n}\n\nexport interface EffectiveVideoCapabilityField extends EffectiveVideoCapabilityDefinition {\n  value: CapabilityValue | undefined\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isCapabilityValue(value: unknown): value is CapabilityValue {\n  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction isCapabilityValueArray(value: unknown): value is CapabilityValue[] {\n  return Array.isArray(value) && value.every((item) => isCapabilityValue(item))\n}\n\nfunction parseFieldI18n(raw: unknown): CapabilityFieldI18n | null {\n  if (!isRecord(raw)) return null\n  const labelKey = typeof raw.labelKey === 'string' && raw.labelKey.trim() ? raw.labelKey.trim() : undefined\n  const unitKey = typeof raw.unitKey === 'string' && raw.unitKey.trim() ? raw.unitKey.trim() : undefined\n  const optionLabelKeys = isRecord(raw.optionLabelKeys)\n    ? Object.entries(raw.optionLabelKeys).reduce<Record<string, string>>((acc, [key, value]) => {\n      if (typeof value === 'string' && value.trim()) {\n        acc[key] = value.trim()\n      }\n      return acc\n    }, {})\n    : undefined\n  return {\n    ...(labelKey ? { labelKey } : {}),\n    ...(unitKey ? { unitKey } : {}),\n    ...(optionLabelKeys && Object.keys(optionLabelKeys).length > 0 ? { optionLabelKeys } : {}),\n  }\n}\n\nfunction pushUnique(target: CapabilityValue[], value: CapabilityValue) {\n  if (!target.includes(value)) {\n    target.push(value)\n  }\n}\n\nfunction collectFieldI18nMap(\n  videoCapabilities: VideoCapabilities | undefined,\n): Record<string, CapabilityFieldI18n | null> {\n  const map: Record<string, CapabilityFieldI18n | null> = {}\n  const rawMap = isRecord(videoCapabilities?.fieldI18n) ? videoCapabilities.fieldI18n : {}\n  for (const [field, raw] of Object.entries(rawMap)) {\n    map[field] = parseFieldI18n(raw)\n  }\n  return map\n}\n\nfunction buildDefinitionsFromPricingTiers(\n  tiers: VideoPricingTier[],\n  fieldI18nMap: Record<string, CapabilityFieldI18n | null>,\n): EffectiveVideoCapabilityDefinition[] {\n  const fieldOrder: string[] = []\n  const fieldValues = new Map<string, CapabilityValue[]>()\n\n  for (const tier of tiers) {\n    for (const [field, rawValue] of Object.entries(tier.when)) {\n      if (!isCapabilityValue(rawValue)) continue\n      if (!fieldValues.has(field)) {\n        fieldValues.set(field, [])\n        fieldOrder.push(field)\n      }\n      const values = fieldValues.get(field)\n      if (!values) continue\n      pushUnique(values, rawValue)\n    }\n  }\n\n  const definitions: EffectiveVideoCapabilityDefinition[] = []\n  for (const field of fieldOrder) {\n    const options = fieldValues.get(field) || []\n    if (options.length === 0) continue\n    definitions.push({\n      field,\n      options,\n      fieldI18n: fieldI18nMap[field] || null,\n    })\n  }\n  return definitions\n}\n\nfunction buildDefinitionsFromCapabilities(\n  videoCapabilities: VideoCapabilities | undefined,\n  fieldI18nMap: Record<string, CapabilityFieldI18n | null>,\n): EffectiveVideoCapabilityDefinition[] {\n  if (!isRecord(videoCapabilities)) return []\n  const definitions: EffectiveVideoCapabilityDefinition[] = []\n\n  for (const [key, rawValue] of Object.entries(videoCapabilities)) {\n    if (!key.endsWith('Options')) continue\n    if (!isCapabilityValueArray(rawValue) || rawValue.length === 0) continue\n    const field = key.slice(0, -'Options'.length)\n    definitions.push({\n      field,\n      options: rawValue,\n      fieldI18n: fieldI18nMap[field] || null,\n    })\n  }\n\n  return definitions\n}\n\nfunction hasTierMatch(\n  tiers: VideoPricingTier[],\n  selection: Record<string, CapabilityValue>,\n): boolean {\n  if (tiers.length === 0) return true\n  return tiers.some((tier) =>\n    Object.entries(selection).every(([field, value]) => {\n      const tierValue = tier.when[field]\n      if (tierValue === undefined) return true\n      return tierValue === value\n    }))\n}\n\nfunction getCompatibleOptionsForField(input: {\n  field: string\n  options: CapabilityValue[]\n  tiers: VideoPricingTier[]\n  selection: Record<string, CapabilityValue>\n}): CapabilityValue[] {\n  const { field, options, tiers, selection } = input\n  if (tiers.length === 0) return options.slice()\n  return options.filter((candidate) =>\n    hasTierMatch(tiers, {\n      ...selection,\n      [field]: candidate,\n    }))\n}\n\nfunction filterSelectionByDefinitions(\n  definitions: EffectiveVideoCapabilityDefinition[],\n  selection: Record<string, CapabilityValue> | undefined,\n): Record<string, CapabilityValue> {\n  if (!selection) return {}\n  const fields = new Set(definitions.map((definition) => definition.field))\n  const next: Record<string, CapabilityValue> = {}\n  for (const [field, value] of Object.entries(selection)) {\n    if (!fields.has(field)) continue\n    if (!isCapabilityValue(value)) continue\n    next[field] = value\n  }\n  return next\n}\n\nexport function resolveEffectiveVideoCapabilityDefinitions(input: {\n  videoCapabilities?: VideoCapabilities\n  pricingTiers?: VideoPricingTier[]\n}): EffectiveVideoCapabilityDefinition[] {\n  const tiers = input.pricingTiers || []\n  const fieldI18nMap = collectFieldI18nMap(input.videoCapabilities)\n  const capabilityDefinitions = buildDefinitionsFromCapabilities(input.videoCapabilities, fieldI18nMap)\n\n  // Capabilities 是参数字段唯一来源；pricing 只用于约束可选项范围。\n  if (capabilityDefinitions.length > 0) {\n    return capabilityDefinitions\n  }\n\n  if (tiers.length > 0) {\n    return buildDefinitionsFromPricingTiers(tiers, fieldI18nMap)\n  }\n\n  return []\n}\n\nexport function normalizeVideoGenerationSelections(input: {\n  definitions: EffectiveVideoCapabilityDefinition[]\n  pricingTiers?: VideoPricingTier[]\n  selection?: Record<string, CapabilityValue>\n  pinnedFields?: string[]\n}): Record<string, CapabilityValue> {\n  const tiers = input.pricingTiers || []\n  const normalized = filterSelectionByDefinitions(input.definitions, input.selection)\n  const pinnedFieldSet = new Set(input.pinnedFields || [])\n  const orderedDefinitions = input.definitions.slice().sort((left, right) => {\n    const leftPinned = pinnedFieldSet.has(left.field)\n    const rightPinned = pinnedFieldSet.has(right.field)\n    if (leftPinned === rightPinned) return 0\n    return leftPinned ? 1 : -1\n  })\n\n  if (input.definitions.length === 0) return {}\n\n  let changed = true\n  let attempts = 0\n  const maxAttempts = Math.max(4, input.definitions.length * 3)\n  while (changed && attempts < maxAttempts) {\n    attempts += 1\n    changed = false\n\n    for (const definition of orderedDefinitions) {\n      const compatibleOptions = getCompatibleOptionsForField({\n        field: definition.field,\n        options: definition.options,\n        tiers,\n        selection: normalized,\n      })\n\n      const current = normalized[definition.field]\n      if (compatibleOptions.length === 0) {\n        if (current !== undefined) {\n          delete normalized[definition.field]\n          changed = true\n        }\n        continue\n      }\n\n      if (current === undefined || !compatibleOptions.includes(current)) {\n        normalized[definition.field] = compatibleOptions[0]\n        changed = true\n      }\n    }\n  }\n\n  return normalized\n}\n\nexport function resolveEffectiveVideoCapabilityFields(input: {\n  definitions: EffectiveVideoCapabilityDefinition[]\n  pricingTiers?: VideoPricingTier[]\n  selection?: Record<string, CapabilityValue>\n}): EffectiveVideoCapabilityField[] {\n  const tiers = input.pricingTiers || []\n  const normalized = normalizeVideoGenerationSelections({\n    definitions: input.definitions,\n    pricingTiers: tiers,\n    selection: input.selection,\n  })\n\n  return input.definitions.map((definition) => {\n    const options = getCompatibleOptionsForField({\n      field: definition.field,\n      options: definition.options,\n      tiers,\n      selection: normalized,\n    })\n    const value = normalized[definition.field]\n    return {\n      ...definition,\n      options,\n      value: value !== undefined && options.includes(value) ? value : undefined,\n    }\n  })\n}\n"
  },
  {
    "path": "src/lib/model-capabilities/video-model-options.ts",
    "content": "import type { ModelCapabilities } from '@/lib/model-config-contract'\n\ninterface VideoModelCapabilityCarrier {\n  capabilities?: ModelCapabilities\n}\n\nfunction readGenerationModeOptions(model: VideoModelCapabilityCarrier): string[] {\n  const options = model.capabilities?.video?.generationModeOptions\n  if (!Array.isArray(options)) return []\n  return options.filter((value): value is string => typeof value === 'string')\n}\n\nexport function supportsFirstLastFrame(model: VideoModelCapabilityCarrier): boolean {\n  return model.capabilities?.video?.firstlastframe === true\n}\n\nexport function isFirstLastFrameOnlyModel(model: VideoModelCapabilityCarrier): boolean {\n  const generationModeOptions = readGenerationModeOptions(model)\n  if (generationModeOptions.length === 0) return false\n  return generationModeOptions.every((mode) => mode === 'firstlastframe')\n}\n\nexport function filterNormalVideoModelOptions<T extends VideoModelCapabilityCarrier>(models: T[]): T[] {\n  return models.filter((model) => !isFirstLastFrameOnlyModel(model))\n}\n"
  },
  {
    "path": "src/lib/model-config-contract.ts",
    "content": "export type UnifiedModelType = 'llm' | 'image' | 'video' | 'audio' | 'lipsync'\nexport type CapabilityValue = string | number | boolean\nexport type CapabilityOptionValue = CapabilityValue\nexport type CapabilitySelections = Record<string, Record<string, CapabilityValue>>\n\nexport type CapabilityValidationCode =\n  | 'CAPABILITY_SHAPE_INVALID'\n  | 'CAPABILITY_NAMESPACE_INVALID'\n  | 'CAPABILITY_FIELD_INVALID'\n  | 'CAPABILITY_VALUE_NOT_ALLOWED'\n\nexport interface CapabilityValidationIssue {\n  code: CapabilityValidationCode\n  field: string\n  message: string\n  allowedValues?: readonly CapabilityOptionValue[]\n}\n\nexport interface CapabilityFieldI18n {\n  labelKey?: string\n  unitKey?: string\n  optionLabelKeys?: Record<string, string>\n}\n\nexport type CapabilityFieldI18nMap = Record<string, CapabilityFieldI18n>\n\nexport interface LLMCapabilities {\n  reasoningEffortOptions?: string[]\n  fieldI18n?: CapabilityFieldI18nMap\n}\n\nexport interface ImageCapabilities {\n  resolutionOptions?: string[]\n  fieldI18n?: CapabilityFieldI18nMap\n}\n\nexport interface VideoCapabilities {\n  generationModeOptions?: string[]\n  generateAudioOptions?: boolean[]\n  durationOptions?: number[]\n  fpsOptions?: number[]\n  resolutionOptions?: string[]\n  firstlastframe?: boolean\n  supportGenerateAudio?: boolean\n  fieldI18n?: CapabilityFieldI18nMap\n}\n\nexport interface AudioCapabilities {\n  voiceOptions?: string[]\n  rateOptions?: string[]\n  fieldI18n?: CapabilityFieldI18nMap\n}\n\nexport interface LipSyncCapabilities {\n  modeOptions?: string[]\n  fieldI18n?: CapabilityFieldI18nMap\n}\n\nexport interface ModelCapabilities {\n  llm?: LLMCapabilities\n  image?: ImageCapabilities\n  video?: VideoCapabilities\n  audio?: AudioCapabilities\n  lipsync?: LipSyncCapabilities\n}\n\nexport interface ParsedModelKey {\n  provider: string\n  modelId: string\n  modelKey: string\n}\n\nconst CAPABILITY_NAMESPACES = new Set<keyof ModelCapabilities>([\n  'llm',\n  'image',\n  'video',\n  'audio',\n  'lipsync',\n])\n\nconst LLM_ALLOWED_FIELDS = new Set<keyof LLMCapabilities>([\n  'reasoningEffortOptions',\n  'fieldI18n',\n])\n\nconst IMAGE_ALLOWED_FIELDS = new Set<keyof ImageCapabilities>([\n  'resolutionOptions',\n  'fieldI18n',\n])\n\nconst VIDEO_ALLOWED_FIELDS = new Set<keyof VideoCapabilities>([\n  'generationModeOptions',\n  'generateAudioOptions',\n  'durationOptions',\n  'fpsOptions',\n  'resolutionOptions',\n  'firstlastframe',\n  'supportGenerateAudio',\n  'fieldI18n',\n])\n\nconst AUDIO_ALLOWED_FIELDS = new Set<keyof AudioCapabilities>([\n  'voiceOptions',\n  'rateOptions',\n  'fieldI18n',\n])\n\nconst LIPSYNC_ALLOWED_FIELDS = new Set<keyof LipSyncCapabilities>([\n  'modeOptions',\n  'fieldI18n',\n])\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n  return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction isStringArray(value: unknown): value is string[] {\n  return Array.isArray(value) && value.every((item) => typeof item === 'string' && item.trim().length > 0)\n}\n\nfunction isNumberArray(value: unknown): value is number[] {\n  return Array.isArray(value) && value.every((item) => typeof item === 'number' && Number.isFinite(item))\n}\n\nfunction isBooleanArray(value: unknown): value is boolean[] {\n  return Array.isArray(value) && value.every((item) => typeof item === 'boolean')\n}\n\nfunction makeAllowedIssue(\n  field: string,\n  value: unknown,\n  allowedValues: readonly CapabilityOptionValue[],\n): CapabilityValidationIssue {\n  return {\n    code: 'CAPABILITY_VALUE_NOT_ALLOWED',\n    field,\n    allowedValues,\n    message: `Value ${String(value)} is not allowed`,\n  }\n}\n\nfunction validateFieldI18nMap(\n  issues: CapabilityValidationIssue[],\n  namespace: keyof ModelCapabilities,\n  rawFieldI18n: unknown,\n  allowedFields: Readonly<Record<string, readonly CapabilityOptionValue[] | undefined>>,\n) {\n  if (rawFieldI18n === undefined) return\n  if (!isRecord(rawFieldI18n)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: `capabilities.${namespace}.fieldI18n`,\n      message: 'fieldI18n must be an object',\n    })\n    return\n  }\n\n  for (const [field, rawConfig] of Object.entries(rawFieldI18n)) {\n    if (!(field in allowedFields)) {\n      issues.push({\n        code: 'CAPABILITY_FIELD_INVALID',\n        field: `capabilities.${namespace}.fieldI18n.${field}`,\n        message: `Unknown i18n field: ${field}`,\n      })\n      continue\n    }\n\n    if (!isRecord(rawConfig)) {\n      issues.push({\n        code: 'CAPABILITY_FIELD_INVALID',\n        field: `capabilities.${namespace}.fieldI18n.${field}`,\n        message: 'field i18n config must be an object',\n      })\n      continue\n    }\n\n    if (rawConfig.labelKey !== undefined && !isNonEmptyString(rawConfig.labelKey)) {\n      issues.push({\n        code: 'CAPABILITY_FIELD_INVALID',\n        field: `capabilities.${namespace}.fieldI18n.${field}.labelKey`,\n        message: 'labelKey must be a non-empty string',\n      })\n    }\n\n    if (rawConfig.unitKey !== undefined && !isNonEmptyString(rawConfig.unitKey)) {\n      issues.push({\n        code: 'CAPABILITY_FIELD_INVALID',\n        field: `capabilities.${namespace}.fieldI18n.${field}.unitKey`,\n        message: 'unitKey must be a non-empty string',\n      })\n    }\n\n    if (rawConfig.optionLabelKeys !== undefined) {\n      if (!isRecord(rawConfig.optionLabelKeys)) {\n        issues.push({\n          code: 'CAPABILITY_FIELD_INVALID',\n          field: `capabilities.${namespace}.fieldI18n.${field}.optionLabelKeys`,\n          message: 'optionLabelKeys must be an object',\n        })\n        continue\n      }\n\n      const allowedOptionKeys = new Set((allowedFields[field] || []).map((value) => String(value)))\n      for (const [optionKey, optionLabel] of Object.entries(rawConfig.optionLabelKeys)) {\n        if (!isNonEmptyString(optionLabel)) {\n          issues.push({\n            code: 'CAPABILITY_FIELD_INVALID',\n            field: `capabilities.${namespace}.fieldI18n.${field}.optionLabelKeys.${optionKey}`,\n            message: 'option label must be a non-empty string',\n          })\n        }\n        if (allowedOptionKeys.size > 0 && !allowedOptionKeys.has(optionKey)) {\n          issues.push({\n            code: 'CAPABILITY_VALUE_NOT_ALLOWED',\n            field: `capabilities.${namespace}.fieldI18n.${field}.optionLabelKeys.${optionKey}`,\n            message: `Option key ${optionKey} is not defined in ${field}Options`,\n            allowedValues: Array.from(allowedOptionKeys),\n          })\n        }\n      }\n    }\n  }\n}\n\nfunction validateNamespaceShape(\n  issues: CapabilityValidationIssue[],\n  namespace: keyof ModelCapabilities,\n  value: unknown,\n) {\n  if (value === undefined) return\n  if (!isRecord(value)) {\n    issues.push({\n      code: 'CAPABILITY_SHAPE_INVALID',\n      field: `capabilities.${namespace}`,\n      message: `capabilities.${namespace} must be an object`,\n    })\n  }\n}\n\nfunction validateNamespaceAllowedFields(\n  issues: CapabilityValidationIssue[],\n  namespace: keyof ModelCapabilities,\n  value: unknown,\n  allowedFields: ReadonlySet<string>,\n) {\n  if (!isRecord(value)) return\n  for (const field of Object.keys(value)) {\n    if (allowedFields.has(field)) continue\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: `capabilities.${namespace}.${field}`,\n      message: field === 'i18n'\n        ? 'Use fieldI18n instead of i18n'\n        : `Unknown capability field: ${field}`,\n    })\n  }\n}\n\nfunction validateLLMCapabilities(issues: CapabilityValidationIssue[], raw: unknown) {\n  if (!isRecord(raw)) return\n  const options = raw.reasoningEffortOptions\n  if (options !== undefined && !isStringArray(options)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.llm.reasoningEffortOptions',\n      message: 'reasoningEffortOptions must be a non-empty string array',\n    })\n  }\n\n  validateFieldI18nMap(issues, 'llm', raw.fieldI18n, {\n    reasoningEffort: isStringArray(options) ? options : undefined,\n  })\n}\n\nfunction validateImageCapabilities(issues: CapabilityValidationIssue[], raw: unknown) {\n  if (!isRecord(raw)) return\n\n  const resolutionOptions = raw.resolutionOptions\n  if (resolutionOptions !== undefined && !isStringArray(resolutionOptions)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.image.resolutionOptions',\n      message: 'resolutionOptions must be a non-empty string array',\n    })\n  }\n\n  validateFieldI18nMap(issues, 'image', raw.fieldI18n, {\n    resolution: isStringArray(resolutionOptions) ? resolutionOptions : undefined,\n  })\n}\n\nfunction validateVideoCapabilities(issues: CapabilityValidationIssue[], raw: unknown) {\n  if (!isRecord(raw)) return\n\n  const generationModeOptions = raw.generationModeOptions\n  if (generationModeOptions !== undefined && !isStringArray(generationModeOptions)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.video.generationModeOptions',\n      message: 'generationModeOptions must be a non-empty string array',\n    })\n  }\n\n  const generateAudioOptions = raw.generateAudioOptions\n  if (generateAudioOptions !== undefined && !isBooleanArray(generateAudioOptions)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.video.generateAudioOptions',\n      message: 'generateAudioOptions must be a boolean array',\n    })\n  }\n\n  const durationOptions = raw.durationOptions\n  if (durationOptions !== undefined && !isNumberArray(durationOptions)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.video.durationOptions',\n      message: 'durationOptions must be a finite number array',\n    })\n  }\n\n  const fpsOptions = raw.fpsOptions\n  if (fpsOptions !== undefined && !isNumberArray(fpsOptions)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.video.fpsOptions',\n      message: 'fpsOptions must be a finite number array',\n    })\n  }\n\n  const resolutionOptions = raw.resolutionOptions\n  if (resolutionOptions !== undefined && !isStringArray(resolutionOptions)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.video.resolutionOptions',\n      message: 'resolutionOptions must be a non-empty string array',\n    })\n  }\n\n  if (raw.supportGenerateAudio !== undefined && typeof raw.supportGenerateAudio !== 'boolean') {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.video.supportGenerateAudio',\n      message: 'supportGenerateAudio must be boolean',\n    })\n  }\n\n  if (raw.firstlastframe !== undefined && typeof raw.firstlastframe !== 'boolean') {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.video.firstlastframe',\n      message: 'firstlastframe must be boolean',\n    })\n  }\n\n  validateFieldI18nMap(issues, 'video', raw.fieldI18n, {\n    generationMode: isStringArray(generationModeOptions) ? generationModeOptions : undefined,\n    generateAudio: isBooleanArray(generateAudioOptions) ? generateAudioOptions : undefined,\n    duration: isNumberArray(durationOptions) ? durationOptions : undefined,\n    fps: isNumberArray(fpsOptions) ? fpsOptions : undefined,\n    resolution: isStringArray(resolutionOptions) ? resolutionOptions : undefined,\n  })\n}\n\nfunction validateAudioCapabilities(issues: CapabilityValidationIssue[], raw: unknown) {\n  if (!isRecord(raw)) return\n\n  const voiceOptions = raw.voiceOptions\n  if (voiceOptions !== undefined && !isStringArray(voiceOptions)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.audio.voiceOptions',\n      message: 'voiceOptions must be a non-empty string array',\n    })\n  }\n\n  const rateOptions = raw.rateOptions\n  if (rateOptions !== undefined && !isStringArray(rateOptions)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.audio.rateOptions',\n      message: 'rateOptions must be a non-empty string array',\n    })\n  }\n\n  validateFieldI18nMap(issues, 'audio', raw.fieldI18n, {\n    voice: isStringArray(voiceOptions) ? voiceOptions : undefined,\n    rate: isStringArray(rateOptions) ? rateOptions : undefined,\n  })\n}\n\nfunction validateLipSyncCapabilities(issues: CapabilityValidationIssue[], raw: unknown) {\n  if (!isRecord(raw)) return\n  const modeOptions = raw.modeOptions\n  if (modeOptions !== undefined && !isStringArray(modeOptions)) {\n    issues.push({\n      code: 'CAPABILITY_FIELD_INVALID',\n      field: 'capabilities.lipsync.modeOptions',\n      message: 'modeOptions must be a non-empty string array',\n    })\n  }\n\n  validateFieldI18nMap(issues, 'lipsync', raw.fieldI18n, {\n    mode: isStringArray(modeOptions) ? modeOptions : undefined,\n  })\n}\n\nfunction validateOptionFieldValue(\n  fieldPath: string,\n  value: unknown,\n  allowedValues: readonly CapabilityOptionValue[],\n): CapabilityValidationIssue | null {\n  if (!allowedValues.includes(value as CapabilityOptionValue)) {\n    return makeAllowedIssue(fieldPath, value, allowedValues)\n  }\n  return null\n}\n\nexport function validateOptionValueAgainstAllowed(\n  fieldPath: string,\n  value: unknown,\n  allowedValues: readonly CapabilityOptionValue[],\n): CapabilityValidationIssue[] {\n  const issue = validateOptionFieldValue(fieldPath, value, allowedValues)\n  return issue ? [issue] : []\n}\n\nexport function composeModelKey(provider: string, modelId: string): string {\n  const providerValue = provider.trim()\n  const modelValue = modelId.trim()\n  if (!providerValue || !modelValue) return ''\n  return `${providerValue}::${modelValue}`\n}\n\nexport function parseModelKeyStrict(key: string | null | undefined): ParsedModelKey | null {\n  if (!key || typeof key !== 'string') return null\n  const raw = key.trim()\n  if (!raw) return null\n  const markerIndex = raw.indexOf('::')\n  if (markerIndex === -1) return null\n  const provider = raw.slice(0, markerIndex).trim()\n  const modelId = raw.slice(markerIndex + 2).trim()\n  if (!provider || !modelId) return null\n  return {\n    provider,\n    modelId,\n    modelKey: `${provider}::${modelId}`,\n  }\n}\n\nexport function isModelKey(value: string | null | undefined): boolean {\n  return !!parseModelKeyStrict(value)\n}\n\nexport function validateModelCapabilities(\n  modelType: UnifiedModelType,\n  capabilities: unknown,\n): CapabilityValidationIssue[] {\n  const issues: CapabilityValidationIssue[] = []\n  const expectedNamespace: keyof ModelCapabilities = modelType\n\n  if (capabilities === undefined || capabilities === null) return issues\n  if (!isRecord(capabilities)) {\n    issues.push({\n      code: 'CAPABILITY_SHAPE_INVALID',\n      field: 'capabilities',\n      message: 'capabilities must be an object',\n    })\n    return issues\n  }\n\n  for (const namespace of Object.keys(capabilities)) {\n    if (!CAPABILITY_NAMESPACES.has(namespace as keyof ModelCapabilities)) {\n      issues.push({\n        code: 'CAPABILITY_NAMESPACE_INVALID',\n        field: `capabilities.${namespace}`,\n        message: `Unknown capabilities namespace: ${namespace}`,\n      })\n      continue\n    }\n\n    if (namespace !== expectedNamespace) {\n      issues.push({\n        code: 'CAPABILITY_NAMESPACE_INVALID',\n        field: `capabilities.${namespace}`,\n        allowedValues: [expectedNamespace],\n        message: `Namespace ${namespace} is not allowed for model type ${modelType}`,\n      })\n    }\n  }\n\n  validateNamespaceShape(issues, 'llm', capabilities.llm)\n  validateNamespaceShape(issues, 'image', capabilities.image)\n  validateNamespaceShape(issues, 'video', capabilities.video)\n  validateNamespaceShape(issues, 'audio', capabilities.audio)\n  validateNamespaceShape(issues, 'lipsync', capabilities.lipsync)\n\n  validateNamespaceAllowedFields(issues, 'llm', capabilities.llm, LLM_ALLOWED_FIELDS)\n  validateNamespaceAllowedFields(issues, 'image', capabilities.image, IMAGE_ALLOWED_FIELDS)\n  validateNamespaceAllowedFields(issues, 'video', capabilities.video, VIDEO_ALLOWED_FIELDS)\n  validateNamespaceAllowedFields(issues, 'audio', capabilities.audio, AUDIO_ALLOWED_FIELDS)\n  validateNamespaceAllowedFields(issues, 'lipsync', capabilities.lipsync, LIPSYNC_ALLOWED_FIELDS)\n\n  validateLLMCapabilities(issues, capabilities.llm)\n  validateImageCapabilities(issues, capabilities.image)\n  validateVideoCapabilities(issues, capabilities.video)\n  validateAudioCapabilities(issues, capabilities.audio)\n  validateLipSyncCapabilities(issues, capabilities.lipsync)\n\n  return issues\n}\n"
  },
  {
    "path": "src/lib/model-gateway/index.ts",
    "content": "export { isCompatibleProvider, resolveModelGatewayRoute } from './router'\nexport type {\n  ModelGatewayRoute,\n  CompatibleProviderKey,\n  OpenAICompatImageProfile,\n  OpenAICompatVideoProfile,\n  OpenAICompatClientConfig,\n  OpenAICompatImageRequest,\n  OpenAICompatVideoRequest,\n  OpenAICompatChatRequest,\n} from './types'\nexport {\n  generateImageViaOpenAICompat,\n  generateVideoViaOpenAICompat,\n  generateImageViaOpenAICompatTemplate,\n  generateVideoViaOpenAICompatTemplate,\n  runOpenAICompatChatCompletion,\n  runOpenAICompatChatCompletionStream,\n  runOpenAICompatResponsesCompletion,\n} from './openai-compat'\n"
  },
  {
    "path": "src/lib/model-gateway/llm.ts",
    "content": "import type OpenAI from 'openai'\nimport { chatCompletion } from '@/lib/llm-client'\nimport type { ChatCompletionOptions } from '@/lib/llm/types'\nimport { setProxy } from '../../../lib/prompts/proxy'\n\ntype ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string }\n\nexport async function runModelGatewayTextCompletion(input: {\n  userId: string\n  model: string\n  messages: ChatMessage[]\n  options?: ChatCompletionOptions\n}): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  await setProxy()\n  return await chatCompletion(\n    input.userId,\n    input.model,\n    input.messages,\n    input.options,\n  )\n}\n\nexport async function runModelGatewayVisionCompletion(input: {\n  userId: string\n  model: string\n  prompt: string\n  imageUrls: string[]\n  options?: ChatCompletionOptions\n}): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  await setProxy()\n  const { chatCompletionWithVision } = await import('@/lib/llm-client')\n  return await chatCompletionWithVision(\n    input.userId,\n    input.model,\n    input.prompt,\n    input.imageUrls,\n    input.options,\n  )\n}\n"
  },
  {
    "path": "src/lib/model-gateway/openai-compat/chat.ts",
    "content": "import OpenAI from 'openai'\nimport type { ChatCompletionStreamCallbacks } from '@/lib/llm/types'\nimport { buildOpenAIChatCompletion } from '@/lib/llm/providers/openai-compat'\nimport { extractStreamDeltaParts } from '@/lib/llm/utils'\nimport { withStreamChunkTimeout } from '@/lib/llm/stream-timeout'\nimport { emitStreamChunk, emitStreamStage, resolveStreamStepMeta } from '@/lib/llm/stream-helpers'\nimport type { OpenAICompatChatRequest } from '../types'\nimport { createOpenAICompatClient, resolveOpenAICompatClientConfig } from './common'\n\nexport async function runOpenAICompatChatCompletion(input: OpenAICompatChatRequest): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  const config = await resolveOpenAICompatClientConfig(input.userId, input.providerId)\n  const client = createOpenAICompatClient(config)\n  return await client.chat.completions.create({\n    model: input.modelId,\n    messages: input.messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],\n    temperature: input.temperature,\n  })\n}\n\ntype OpenAIStreamWithFinal = AsyncIterable<unknown> & {\n  finalChatCompletion?: () => Promise<OpenAI.Chat.Completions.ChatCompletion>\n}\n\nexport async function runOpenAICompatChatCompletionStream(\n  input: OpenAICompatChatRequest,\n  callbacks?: ChatCompletionStreamCallbacks,\n): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  const config = await resolveOpenAICompatClientConfig(input.userId, input.providerId)\n  const client = createOpenAICompatClient(config)\n  const stepMeta = resolveStreamStepMeta({})\n\n  emitStreamStage(callbacks, stepMeta, 'streaming', 'openai-compat')\n  const stream = await client.chat.completions.create({\n    model: input.modelId,\n    messages: input.messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],\n    temperature: input.temperature,\n    stream: true,\n  } as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming)\n\n  let text = ''\n  let reasoning = ''\n  let seq = 1\n  let finalCompletion: OpenAI.Chat.Completions.ChatCompletion | null = null\n\n  for await (const part of withStreamChunkTimeout(stream as AsyncIterable<unknown>)) {\n    const { textDelta, reasoningDelta } = extractStreamDeltaParts(part)\n    if (reasoningDelta) {\n      reasoning += reasoningDelta\n      emitStreamChunk(callbacks, stepMeta, {\n        kind: 'reasoning',\n        delta: reasoningDelta,\n        seq,\n        lane: 'reasoning',\n      })\n      seq += 1\n    }\n    if (textDelta) {\n      text += textDelta\n      emitStreamChunk(callbacks, stepMeta, {\n        kind: 'text',\n        delta: textDelta,\n        seq,\n        lane: 'main',\n      })\n      seq += 1\n    }\n  }\n\n  const finalChatCompletionFn = (stream as OpenAIStreamWithFinal).finalChatCompletion\n  if (typeof finalChatCompletionFn === 'function') {\n    try {\n      finalCompletion = await finalChatCompletionFn.call(stream)\n    } catch {\n      finalCompletion = null\n    }\n  }\n\n  const completion = finalCompletion || buildOpenAIChatCompletion(\n    input.modelId,\n    text || reasoning,\n    undefined,\n  )\n\n  emitStreamStage(callbacks, stepMeta, 'completed', 'openai-compat')\n  callbacks?.onComplete?.(text, stepMeta)\n  return completion\n}\n"
  },
  {
    "path": "src/lib/model-gateway/openai-compat/common.ts",
    "content": "import OpenAI, { toFile } from 'openai'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { getInternalBaseUrl } from '@/lib/env'\nimport { getImageBase64Cached } from '@/lib/image-cache'\nimport type { OpenAICompatClientConfig } from '../types'\n\nfunction toAbsoluteUrlIfNeeded(value: string): string {\n  if (!value.startsWith('/')) return value\n  const baseUrl = getInternalBaseUrl()\n  return `${baseUrl}${value}`\n}\n\nexport function parseDataUrl(value: string): { mimeType: string; base64: string } | null {\n  const marker = ';base64,'\n  const markerIndex = value.indexOf(marker)\n  if (!value.startsWith('data:') || markerIndex === -1) return null\n  const mimeType = value.slice(5, markerIndex)\n  const base64 = value.slice(markerIndex + marker.length)\n  if (!mimeType || !base64) return null\n  return { mimeType, base64 }\n}\n\nexport function readStringOption(value: unknown, optionName: string): string | undefined {\n  if (value === undefined || value === null) return undefined\n  if (typeof value !== 'string') {\n    throw new Error(`OPENAI_COMPAT_OPTION_INVALID: ${optionName}`)\n  }\n  const trimmed = value.trim()\n  if (!trimmed) {\n    throw new Error(`OPENAI_COMPAT_OPTION_INVALID: ${optionName}`)\n  }\n  return trimmed\n}\n\nexport async function resolveOpenAICompatClientConfig(userId: string, providerId: string): Promise<OpenAICompatClientConfig> {\n  const config = await getProviderConfig(userId, providerId)\n  if (!config.baseUrl) {\n    throw new Error(`PROVIDER_BASE_URL_MISSING: ${config.id}`)\n  }\n  return {\n    providerId: config.id,\n    baseUrl: config.baseUrl,\n    apiKey: config.apiKey,\n  }\n}\n\nexport function createOpenAICompatClient(config: OpenAICompatClientConfig): OpenAI {\n  return new OpenAI({\n    apiKey: config.apiKey,\n    baseURL: config.baseUrl,\n  })\n}\n\nexport async function toUploadFile(imageSource: string, index: number): Promise<File> {\n  const parsedDataUrl = parseDataUrl(imageSource)\n  if (parsedDataUrl) {\n    const bytes = Buffer.from(parsedDataUrl.base64, 'base64')\n    return await toFile(bytes, `reference-${index}.png`, { type: parsedDataUrl.mimeType })\n  }\n\n  if (imageSource.startsWith('http://') || imageSource.startsWith('https://') || imageSource.startsWith('/')) {\n    const cachedDataUrl = await getImageBase64Cached(toAbsoluteUrlIfNeeded(imageSource))\n    const parsedCached = parseDataUrl(cachedDataUrl)\n    if (!parsedCached) {\n      throw new Error(`OPENAI_COMPAT_REFERENCE_INVALID: failed to parse image source ${index}`)\n    }\n    const bytes = Buffer.from(parsedCached.base64, 'base64')\n    return await toFile(bytes, `reference-${index}.png`, { type: parsedCached.mimeType })\n  }\n\n  const bytes = Buffer.from(imageSource, 'base64')\n  return await toFile(bytes, `reference-${index}.png`, { type: 'image/png' })\n}\n"
  },
  {
    "path": "src/lib/model-gateway/openai-compat/image.ts",
    "content": "import type { GenerateResult } from '@/lib/generators/base'\nimport type { OpenAICompatImageRequest } from '../types'\nimport {\n  createOpenAICompatClient,\n  readStringOption,\n  resolveOpenAICompatClientConfig,\n  toUploadFile,\n} from './common'\n\ntype OpenAIImageResponseFormat = 'url' | 'b64_json'\ntype OpenAIImageOutputFormat = 'png' | 'jpeg' | 'webp'\ntype OpenAIImageGenerateQuality = 'standard' | 'hd' | 'low' | 'medium' | 'high' | 'auto'\ntype OpenAIImageGenerateSize =\n  | 'auto'\n  | '1024x1024'\n  | '1536x1024'\n  | '1024x1536'\n  | '256x256'\n  | '512x512'\n  | '1792x1024'\n  | '1024x1792'\n\nconst OPENAI_IMAGE_OPTION_KEYS = new Set([\n  'provider',\n  'modelId',\n  'modelKey',\n  'size',\n  'resolution',\n  'quality',\n  'responseFormat',\n  'outputFormat',\n])\n\nfunction assertAllowedOptions(options: Record<string, unknown>) {\n  for (const [key, value] of Object.entries(options)) {\n    if (value === undefined) continue\n    if (!OPENAI_IMAGE_OPTION_KEYS.has(key)) {\n      throw new Error(`OPENAI_COMPAT_IMAGE_OPTION_UNSUPPORTED: ${key}`)\n    }\n  }\n}\n\nfunction normalizeResponseFormat(value: unknown): OpenAIImageResponseFormat {\n  const normalized = readStringOption(value, 'responseFormat')\n  if (!normalized) return 'b64_json'\n  if (normalized === 'url' || normalized === 'b64_json') return normalized\n  throw new Error(`OPENAI_COMPAT_IMAGE_OPTION_UNSUPPORTED: responseFormat=${normalized}`)\n}\n\nfunction normalizeOutputFormat(value: unknown): OpenAIImageOutputFormat | undefined {\n  const normalized = readStringOption(value, 'outputFormat')\n  if (!normalized) return undefined\n  if (normalized === 'png' || normalized === 'jpeg' || normalized === 'webp') return normalized\n  throw new Error(`OPENAI_COMPAT_IMAGE_OPTION_UNSUPPORTED: outputFormat=${normalized}`)\n}\n\nfunction normalizeGenerateQuality(value: unknown): OpenAIImageGenerateQuality | undefined {\n  const normalized = readStringOption(value, 'quality')\n  if (!normalized) return undefined\n  if (\n    normalized === 'standard'\n    || normalized === 'hd'\n    || normalized === 'low'\n    || normalized === 'medium'\n    || normalized === 'high'\n    || normalized === 'auto'\n  ) {\n    return normalized\n  }\n  throw new Error(`OPENAI_COMPAT_IMAGE_OPTION_UNSUPPORTED: quality=${normalized}`)\n}\n\nfunction normalizeOpenAIImageSize(value: string | undefined): OpenAIImageGenerateSize | undefined {\n  if (!value) return undefined\n  if (\n    value === 'auto'\n    || value === '1024x1024'\n    || value === '1536x1024'\n    || value === '1024x1536'\n    || value === '256x256'\n    || value === '512x512'\n    || value === '1792x1024'\n    || value === '1024x1792'\n  ) {\n    return value\n  }\n  throw new Error(`OPENAI_COMPAT_IMAGE_OPTION_UNSUPPORTED: size=${value}`)\n}\n\nfunction resolveRawSize(options: Record<string, unknown>): string | undefined {\n  const size = readStringOption(options.size, 'size')\n  const resolution = readStringOption(options.resolution, 'resolution')\n  if (size && resolution && size !== resolution) {\n    throw new Error('OPENAI_COMPAT_IMAGE_OPTION_CONFLICT: size and resolution must match')\n  }\n  return size || resolution\n}\n\nfunction resolveModelId(modelId: string | undefined, options: Record<string, unknown>): string {\n  const optionModelId = readStringOption(options.modelId, 'modelId')\n  const selected = (modelId || optionModelId || '').trim()\n  if (selected) return selected\n  return 'gpt-image-1'\n}\n\nfunction toMimeFromOutputFormat(outputFormat: string | undefined): string {\n  if (outputFormat === 'jpeg' || outputFormat === 'jpg') return 'image/jpeg'\n  if (outputFormat === 'webp') return 'image/webp'\n  return 'image/png'\n}\n\ninterface ImagePayloads {\n  /** 第一张图的 base64（向后兼容） */\n  b64Json: string | null\n  /** 第一张图的 URL（向后兼容） */\n  url: string | null\n  /** 所有图的 URL 列表（接口返回多张时有值） */\n  urls: string[]\n}\n\nfunction readAllImagePayloads(response: unknown): ImagePayloads {\n  if (typeof response !== 'object' || response === null) {\n    return { b64Json: null, url: null, urls: [] }\n  }\n  const data = (response as { data?: unknown }).data\n  if (!Array.isArray(data) || data.length === 0) {\n    return { b64Json: null, url: null, urls: [] }\n  }\n\n  const urls: string[] = []\n  let firstB64: string | null = null\n\n  for (const item of data) {\n    if (typeof item !== 'object' || item === null) continue\n    const rawUrl = (item as { url?: unknown }).url\n    const rawB64 = (item as { b64_json?: unknown }).b64_json\n    if (typeof rawUrl === 'string' && rawUrl.trim()) {\n      urls.push(rawUrl.trim())\n    }\n    if (firstB64 === null && typeof rawB64 === 'string' && rawB64.trim()) {\n      firstB64 = rawB64.trim()\n    }\n  }\n\n  return {\n    b64Json: firstB64,\n    url: urls[0] ?? null,\n    urls,\n  }\n}\n\nexport async function generateImageViaOpenAICompat(request: OpenAICompatImageRequest): Promise<GenerateResult> {\n  const {\n    userId,\n    providerId,\n    modelId,\n    prompt,\n    referenceImages = [],\n    options = {},\n  } = request\n\n  assertAllowedOptions(options)\n  const config = await resolveOpenAICompatClientConfig(userId, providerId)\n  const client = createOpenAICompatClient(config)\n\n  const normalizedModelId = resolveModelId(modelId, options)\n  const responseFormat = normalizeResponseFormat(options.responseFormat)\n  const outputFormat = normalizeOutputFormat(options.outputFormat)\n  const quality = normalizeGenerateQuality(options.quality)\n  const rawSize = resolveRawSize(options)\n  const size = normalizeOpenAIImageSize(rawSize)\n\n  if (referenceImages.length > 0) {\n    const response = await client.images.edit({\n      model: normalizedModelId,\n      prompt,\n      image: await Promise.all(referenceImages.map((image, index) => toUploadFile(image, index))),\n      response_format: responseFormat,\n      ...(outputFormat ? { output_format: outputFormat } : {}),\n      ...(quality ? { quality } : {}),\n      ...(size ? { size } : {}),\n    } as unknown as Parameters<typeof client.images.edit>[0])\n\n    const imagePayload = readAllImagePayloads(response)\n    const imageBase64 = imagePayload.b64Json\n    if (typeof imageBase64 === 'string' && imageBase64.trim().length > 0) {\n      const mimeType = toMimeFromOutputFormat(outputFormat)\n      return {\n        success: true,\n        imageBase64,\n        imageUrl: `data:${mimeType};base64,${imageBase64}`,\n      }\n    }\n    const imageUrl = imagePayload.url\n    if (typeof imageUrl === 'string' && imageUrl.trim().length > 0) {\n      return {\n        success: true,\n        imageUrl,\n        ...(imagePayload.urls.length > 1 ? { imageUrls: imagePayload.urls } : {}),\n      }\n    }\n    throw new Error('OPENAI_COMPAT_IMAGE_EMPTY_RESPONSE: no image data returned')\n  }\n\n  const response = await client.images.generate({\n    model: normalizedModelId,\n    prompt,\n    response_format: responseFormat,\n    ...(outputFormat ? { output_format: outputFormat } : {}),\n    ...(quality ? { quality } : {}),\n    ...(size ? { size } : {}),\n  } as unknown as Parameters<typeof client.images.generate>[0])\n\n  const imagePayload = readAllImagePayloads(response)\n  const imageBase64 = imagePayload.b64Json\n  if (typeof imageBase64 === 'string' && imageBase64.trim().length > 0) {\n    const mimeType = toMimeFromOutputFormat(outputFormat)\n    return {\n      success: true,\n      imageBase64,\n      imageUrl: `data:${mimeType};base64,${imageBase64}`,\n    }\n  }\n  const imageUrl = imagePayload.url\n  if (typeof imageUrl === 'string' && imageUrl.trim().length > 0) {\n    return {\n      success: true,\n      imageUrl,\n      ...(imagePayload.urls.length > 1 ? { imageUrls: imagePayload.urls } : {}),\n    }\n  }\n  throw new Error('OPENAI_COMPAT_IMAGE_EMPTY_RESPONSE: no image data returned')\n}\n"
  },
  {
    "path": "src/lib/model-gateway/openai-compat/index.ts",
    "content": "export { generateImageViaOpenAICompat } from './image'\nexport { generateVideoViaOpenAICompat } from './video'\nexport { generateImageViaOpenAICompatTemplate } from './template-image'\nexport { generateVideoViaOpenAICompatTemplate } from './template-video'\nexport { runOpenAICompatChatCompletion, runOpenAICompatChatCompletionStream } from './chat'\nexport { runOpenAICompatResponsesCompletion } from './responses'\n"
  },
  {
    "path": "src/lib/model-gateway/openai-compat/responses.ts",
    "content": "import { buildOpenAIChatCompletion } from '@/lib/llm/providers/openai-compat'\nimport { buildReasoningAwareContent } from '@/lib/llm/utils'\nimport type { OpenAICompatChatRequest } from '../types'\nimport { resolveOpenAICompatClientConfig } from './common'\n\ntype ResponsesUsage = {\n  promptTokens: number\n  completionTokens: number\n}\n\ntype ErrorWithStatus = Error & { status?: number }\n\nfunction asRecord(value: unknown): Record<string, unknown> | null {\n  return value && typeof value === 'object' ? (value as Record<string, unknown>) : null\n}\n\nfunction toEndpoint(baseUrl: string, path: string): string {\n  return `${baseUrl.replace(/\\/+$/, '')}/${path.replace(/^\\/+/, '')}`\n}\n\nfunction collectText(node: unknown, acc: string[]) {\n  if (typeof node === 'string') {\n    acc.push(node)\n    return\n  }\n  if (Array.isArray(node)) {\n    node.forEach((item) => collectText(item, acc))\n    return\n  }\n  const record = asRecord(node)\n  if (!record) return\n\n  const type = typeof record.type === 'string' ? record.type : ''\n  if (type.includes('reasoning')) return\n  if (typeof record.output_text === 'string') acc.push(record.output_text)\n  if (typeof record.text === 'string') acc.push(record.text)\n  if (typeof record.content === 'string') acc.push(record.content)\n  if (record.content !== undefined && typeof record.content !== 'string') collectText(record.content, acc)\n  if (record.output !== undefined) collectText(record.output, acc)\n}\n\nfunction collectReasoning(node: unknown, acc: string[]) {\n  if (Array.isArray(node)) {\n    node.forEach((item) => collectReasoning(item, acc))\n    return\n  }\n  const record = asRecord(node)\n  if (!record) return\n\n  const type = typeof record.type === 'string' ? record.type : ''\n  if (type.includes('reasoning')) {\n    if (typeof record.text === 'string') acc.push(record.text)\n    if (typeof record.content === 'string') acc.push(record.content)\n    if (record.content !== undefined && typeof record.content !== 'string') {\n      collectReasoning(record.content, acc)\n    }\n  }\n\n  if (record.reasoning !== undefined) collectReasoning(record.reasoning, acc)\n  if (record.reasoning_content !== undefined) collectReasoning(record.reasoning_content, acc)\n  if (record.output !== undefined) collectReasoning(record.output, acc)\n}\n\nfunction extractResponsesText(payload: unknown): string {\n  const root = asRecord(payload)\n  if (!root) return ''\n  if (typeof root.output_text === 'string') return root.output_text\n\n  const parts: string[] = []\n  collectText(root.output ?? root, parts)\n  return parts.join('')\n}\n\nfunction extractResponsesReasoning(payload: unknown): string {\n  const root = asRecord(payload)\n  if (!root) return ''\n\n  const parts: string[] = []\n  collectReasoning(root.output ?? root, parts)\n  return parts.join('')\n}\n\nfunction extractResponsesUsage(payload: unknown): ResponsesUsage {\n  const usage = asRecord(asRecord(payload)?.usage) || {}\n  const promptTokens = typeof usage.input_tokens === 'number'\n    ? usage.input_tokens\n    : (typeof usage.prompt_tokens === 'number' ? usage.prompt_tokens : 0)\n  const completionTokens = typeof usage.output_tokens === 'number'\n    ? usage.output_tokens\n    : (typeof usage.completion_tokens === 'number' ? usage.completion_tokens : 0)\n  return {\n    promptTokens,\n    completionTokens,\n  }\n}\n\nexport async function runOpenAICompatResponsesCompletion(input: OpenAICompatChatRequest) {\n  const config = await resolveOpenAICompatClientConfig(input.userId, input.providerId)\n  const endpoint = toEndpoint(config.baseUrl, '/responses')\n  const response = await fetch(endpoint, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${config.apiKey}`,\n    },\n    body: JSON.stringify({\n      model: input.modelId,\n      input: input.messages.map((message) => ({\n        role: message.role,\n        content: [{ type: 'input_text', text: message.content }],\n      })),\n      temperature: input.temperature,\n    }),\n  })\n\n  if (!response.ok) {\n    const errorBody = await response.text().catch(() => '')\n    const error = new Error(\n      `OPENAI_COMPAT_RESPONSES_FAILED: ${response.status} ${errorBody.slice(0, 300)}`,\n    ) as ErrorWithStatus\n    error.status = response.status\n    throw error\n  }\n\n  const payload = await response.json() as unknown\n  const text = extractResponsesText(payload)\n  const reasoning = extractResponsesReasoning(payload)\n  const usage = extractResponsesUsage(payload)\n\n  return buildOpenAIChatCompletion(\n    input.modelId,\n    buildReasoningAwareContent(text, reasoning),\n    usage,\n  )\n}\n\n"
  },
  {
    "path": "src/lib/model-gateway/openai-compat/template-image.ts",
    "content": "import type { GenerateResult } from '@/lib/generators/base'\nimport type { OpenAICompatImageRequest } from '../types'\nimport {\n  buildRenderedTemplateRequest,\n  buildTemplateVariables,\n  extractTemplateError,\n  normalizeResponseJson,\n  readJsonPath,\n} from '@/lib/openai-compat-template-runtime'\nimport { parseModelKeyStrict } from '@/lib/model-config-contract'\nimport { resolveOpenAICompatClientConfig } from './common'\n\nconst OPENAI_COMPAT_PROVIDER_PREFIX = 'openai-compatible:'\nconst PROVIDER_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nfunction encodeProviderToken(providerId: string): string {\n  const value = providerId.trim()\n  if (value.startsWith(OPENAI_COMPAT_PROVIDER_PREFIX)) {\n    const uuid = value.slice(OPENAI_COMPAT_PROVIDER_PREFIX.length).trim()\n    if (PROVIDER_UUID_PATTERN.test(uuid)) {\n      return `u_${uuid.toLowerCase()}`\n    }\n  }\n  return `b64_${Buffer.from(value, 'utf8').toString('base64url')}`\n}\n\nfunction encodeModelRef(modelRef: string): string {\n  return Buffer.from(modelRef, 'utf8').toString('base64url')\n}\n\nfunction resolveModelRef(request: OpenAICompatImageRequest): string {\n  const modelId = typeof request.modelId === 'string' ? request.modelId.trim() : ''\n  if (modelId) return modelId\n  const parsed = typeof request.modelKey === 'string' ? parseModelKeyStrict(request.modelKey) : null\n  if (parsed?.modelId) return parsed.modelId\n  throw new Error('OPENAI_COMPAT_IMAGE_MODEL_REF_REQUIRED')\n}\n\nfunction readTemplateOutputUrls(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  const urls: string[] = []\n  for (const item of value) {\n    if (typeof item === 'string' && item.trim()) {\n      urls.push(item.trim())\n      continue\n    }\n    if (!item || typeof item !== 'object' || Array.isArray(item)) continue\n    const url = (item as { url?: unknown }).url\n    if (typeof url === 'string' && url.trim()) {\n      urls.push(url.trim())\n    }\n  }\n  return urls\n}\n\nexport async function generateImageViaOpenAICompatTemplate(\n  request: OpenAICompatImageRequest,\n): Promise<GenerateResult> {\n  if (!request.template) {\n    throw new Error('OPENAI_COMPAT_IMAGE_TEMPLATE_REQUIRED')\n  }\n  if (request.template.mediaType !== 'image') {\n    throw new Error('OPENAI_COMPAT_IMAGE_TEMPLATE_MEDIA_TYPE_INVALID')\n  }\n\n  const config = await resolveOpenAICompatClientConfig(request.userId, request.providerId)\n  const firstReference = Array.isArray(request.referenceImages) && request.referenceImages.length > 0\n    ? request.referenceImages[0]\n    : ''\n  const variables = buildTemplateVariables({\n    model: request.modelId || 'gpt-image-1',\n    prompt: request.prompt,\n    image: firstReference,\n    images: request.referenceImages || [],\n    aspectRatio: typeof request.options?.aspectRatio === 'string' ? request.options.aspectRatio : undefined,\n    resolution: typeof request.options?.resolution === 'string' ? request.options.resolution : undefined,\n    size: typeof request.options?.size === 'string' ? request.options.size : undefined,\n    extra: request.options,\n  })\n\n  const createRequest = await buildRenderedTemplateRequest({\n    baseUrl: config.baseUrl,\n    endpoint: request.template.create,\n    variables,\n    defaultAuthHeader: `Bearer ${config.apiKey}`,\n  })\n  if (['POST', 'PUT', 'PATCH'].includes(createRequest.method) && !createRequest.body) {\n    throw new Error('OPENAI_COMPAT_IMAGE_TEMPLATE_CREATE_BODY_REQUIRED')\n  }\n  const response = await fetch(createRequest.endpointUrl, {\n    method: createRequest.method,\n    headers: createRequest.headers,\n    ...(createRequest.body ? { body: createRequest.body } : {}),\n  })\n  const rawText = await response.text().catch(() => '')\n  const payload = normalizeResponseJson(rawText)\n  if (!response.ok) {\n    throw new Error(extractTemplateError(request.template, payload, response.status))\n  }\n\n  if (request.template.mode === 'sync') {\n    const outputUrls = readTemplateOutputUrls(\n      readJsonPath(payload, request.template.response.outputUrlsPath),\n    )\n    if (outputUrls.length > 0) {\n      const first = outputUrls[0]\n      return {\n        success: true,\n        imageUrl: first,\n        ...(outputUrls.length > 1 ? { imageUrls: outputUrls } : {}),\n      }\n    }\n\n    const outputUrl = readJsonPath(payload, request.template.response.outputUrlPath)\n    if (typeof outputUrl === 'string' && outputUrl.trim().length > 0) {\n      return {\n        success: true,\n        imageUrl: outputUrl.trim(),\n      }\n    }\n    throw new Error('OPENAI_COMPAT_IMAGE_TEMPLATE_OUTPUT_NOT_FOUND')\n  }\n\n  const taskIdRaw = readJsonPath(payload, request.template.response.taskIdPath)\n  const taskId = typeof taskIdRaw === 'string' ? taskIdRaw.trim() : ''\n  if (!taskId) {\n    throw new Error('OPENAI_COMPAT_IMAGE_TEMPLATE_TASK_ID_NOT_FOUND')\n  }\n  const providerToken = encodeProviderToken(config.providerId)\n  const modelRefToken = encodeModelRef(resolveModelRef(request))\n  return {\n    success: true,\n    async: true,\n    requestId: taskId,\n    externalId: `OCOMPAT:IMAGE:${providerToken}:${modelRefToken}:${taskId}`,\n  }\n}\n"
  },
  {
    "path": "src/lib/model-gateway/openai-compat/template-video.ts",
    "content": "import type { GenerateResult } from '@/lib/generators/base'\nimport type { OpenAICompatVideoRequest } from '../types'\nimport {\n  buildRenderedTemplateRequest,\n  buildTemplateVariables,\n  extractTemplateError,\n  normalizeResponseJson,\n  readJsonPath,\n} from '@/lib/openai-compat-template-runtime'\nimport { parseModelKeyStrict } from '@/lib/model-config-contract'\nimport { resolveOpenAICompatClientConfig } from './common'\n\nconst OPENAI_COMPAT_PROVIDER_PREFIX = 'openai-compatible:'\nconst PROVIDER_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nfunction buildUnsupportedVideoFormatError(detail: string): Error {\n  return new Error(`VIDEO_API_FORMAT_UNSUPPORTED: ${detail}`)\n}\n\nfunction encodeProviderToken(providerId: string): string {\n  const value = providerId.trim()\n  if (value.startsWith(OPENAI_COMPAT_PROVIDER_PREFIX)) {\n    const uuid = value.slice(OPENAI_COMPAT_PROVIDER_PREFIX.length).trim()\n    if (PROVIDER_UUID_PATTERN.test(uuid)) {\n      return `u_${uuid.toLowerCase()}`\n    }\n  }\n  return `b64_${Buffer.from(value, 'utf8').toString('base64url')}`\n}\n\nfunction encodeModelRef(modelRef: string): string {\n  return Buffer.from(modelRef, 'utf8').toString('base64url')\n}\n\nfunction resolveModelRef(request: OpenAICompatVideoRequest): string {\n  const modelId = typeof request.modelId === 'string' ? request.modelId.trim() : ''\n  if (modelId) return modelId\n  const parsed = typeof request.modelKey === 'string' ? parseModelKeyStrict(request.modelKey) : null\n  if (parsed?.modelId) return parsed.modelId\n  throw new Error('OPENAI_COMPAT_VIDEO_MODEL_REF_REQUIRED')\n}\n\nexport async function generateVideoViaOpenAICompatTemplate(\n  request: OpenAICompatVideoRequest,\n): Promise<GenerateResult> {\n  if (!request.template) {\n    throw buildUnsupportedVideoFormatError('OPENAI_COMPAT_VIDEO_TEMPLATE_REQUIRED')\n  }\n  if (request.template.mediaType !== 'video') {\n    throw buildUnsupportedVideoFormatError('OPENAI_COMPAT_VIDEO_TEMPLATE_MEDIA_TYPE_INVALID')\n  }\n\n  const config = await resolveOpenAICompatClientConfig(request.userId, request.providerId)\n  const variables = buildTemplateVariables({\n    model: request.modelId || '',\n    prompt: request.prompt,\n    image: request.imageUrl,\n    images: [request.imageUrl],\n    aspectRatio: typeof request.options?.aspectRatio === 'string' ? request.options.aspectRatio : undefined,\n    resolution: typeof request.options?.resolution === 'string' ? request.options.resolution : undefined,\n    size: typeof request.options?.size === 'string' ? request.options.size : undefined,\n    duration: typeof request.options?.duration === 'number' ? request.options.duration : undefined,\n    extra: request.options,\n  })\n\n  const createRequest = await buildRenderedTemplateRequest({\n    baseUrl: config.baseUrl,\n    endpoint: request.template.create,\n    variables,\n    defaultAuthHeader: `Bearer ${config.apiKey}`,\n  })\n  if (['POST', 'PUT', 'PATCH'].includes(createRequest.method) && !createRequest.body) {\n    throw buildUnsupportedVideoFormatError('OPENAI_COMPAT_VIDEO_TEMPLATE_CREATE_BODY_REQUIRED')\n  }\n  const createResponse = await fetch(createRequest.endpointUrl, {\n    method: createRequest.method,\n    headers: createRequest.headers,\n    ...(createRequest.body ? { body: createRequest.body } : {}),\n  })\n  const rawText = await createResponse.text().catch(() => '')\n  const payload = normalizeResponseJson(rawText)\n\n  if (!createResponse.ok) {\n    const errorMessage = extractTemplateError(request.template, payload, createResponse.status)\n    if ([404, 405, 415].includes(createResponse.status)) {\n      throw buildUnsupportedVideoFormatError(errorMessage)\n    }\n    throw new Error(errorMessage)\n  }\n\n  if (request.template.mode === 'sync') {\n    const outputUrl = readJsonPath(payload, request.template.response.outputUrlPath)\n    if (typeof outputUrl === 'string' && outputUrl.trim()) {\n      return {\n        success: true,\n        videoUrl: outputUrl.trim(),\n      }\n    }\n    const outputUrls = readJsonPath(payload, request.template.response.outputUrlsPath)\n    if (Array.isArray(outputUrls) && outputUrls.length > 0 && typeof outputUrls[0] === 'string') {\n      return {\n        success: true,\n        videoUrl: String(outputUrls[0]).trim(),\n      }\n    }\n    throw buildUnsupportedVideoFormatError('OPENAI_COMPAT_VIDEO_TEMPLATE_OUTPUT_NOT_FOUND')\n  }\n\n  const taskIdRaw = readJsonPath(payload, request.template.response.taskIdPath)\n  const taskId = typeof taskIdRaw === 'string' ? taskIdRaw.trim() : ''\n  if (!taskId) {\n    throw buildUnsupportedVideoFormatError('OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND')\n  }\n\n  const providerToken = encodeProviderToken(config.providerId)\n  const modelRefToken = encodeModelRef(resolveModelRef(request))\n\n  return {\n    success: true,\n    async: true,\n    requestId: taskId,\n    externalId: `OCOMPAT:VIDEO:${providerToken}:${modelRefToken}:${taskId}`,\n  }\n}\n"
  },
  {
    "path": "src/lib/model-gateway/openai-compat/video.ts",
    "content": "import { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'\nimport type { GenerateResult } from '@/lib/generators/base'\nimport type { OpenAICompatVideoRequest } from '../types'\nimport { createOpenAICompatClient, parseDataUrl, resolveOpenAICompatClientConfig } from './common'\nimport { toFile } from 'openai'\n\ntype OpenAIVideoSize = '720x1280' | '1280x720' | '1024x1792' | '1792x1024'\ntype OpenAIVideoSeconds = '4' | '8' | '12'\ntype OpenAIVideoAspectRatio =\n  | '16:9'\n  | '9:16'\n  | '4:3'\n  | '3:4'\n  | '3:2'\n  | '2:3'\n  | '21:9'\n  | '9:21'\n  | '1:1'\n  | 'auto'\n\nconst OPENAI_COMPAT_VIDEO_OPTION_KEYS = new Set([\n  'provider',\n  'modelId',\n  'modelKey',\n  'duration',\n  'resolution',\n  'aspectRatio',\n  'aspect_ratio',\n  'size',\n  'generateAudio',\n  'generationMode',\n])\n\nfunction assertAllowedOptions(options: Record<string, unknown>) {\n  for (const [key, value] of Object.entries(options)) {\n    if (value === undefined) continue\n    if (!OPENAI_COMPAT_VIDEO_OPTION_KEYS.has(key)) {\n      throw new Error(`OPENAI_COMPAT_VIDEO_OPTION_UNSUPPORTED: ${key}`)\n    }\n  }\n}\n\nfunction normalizeDuration(value: unknown): OpenAIVideoSeconds | undefined {\n  if (value === 4 || value === '4') return '4'\n  if (value === 8 || value === '8') return '8'\n  if (value === 12 || value === '12') return '12'\n  if (value === undefined) return undefined\n  throw new Error(`OPENAI_COMPAT_VIDEO_DURATION_UNSUPPORTED: ${String(value)}`)\n}\n\nfunction normalizeAspectRatio(value: unknown): OpenAIVideoAspectRatio | undefined {\n  if (value === undefined || value === null) return undefined\n  if (typeof value !== 'string') {\n    throw new Error(`OPENAI_COMPAT_VIDEO_ASPECT_RATIO_UNSUPPORTED: ${String(value)}`)\n  }\n  const trimmed = value.trim()\n  if (!trimmed) return undefined\n  if (\n    trimmed === '16:9'\n    || trimmed === '9:16'\n    || trimmed === '4:3'\n    || trimmed === '3:4'\n    || trimmed === '3:2'\n    || trimmed === '2:3'\n    || trimmed === '21:9'\n    || trimmed === '9:21'\n    || trimmed === '1:1'\n    || trimmed === 'auto'\n  ) {\n    return trimmed\n  }\n  throw new Error(`OPENAI_COMPAT_VIDEO_ASPECT_RATIO_UNSUPPORTED: ${trimmed}`)\n}\n\nfunction resolveAspectRatio(options: Record<string, unknown>): OpenAIVideoAspectRatio | undefined {\n  const aspectRatio = normalizeAspectRatio(options.aspectRatio)\n  const aspectRatioAlt = normalizeAspectRatio(options.aspect_ratio)\n  if (aspectRatio && aspectRatioAlt && aspectRatio !== aspectRatioAlt) {\n    throw new Error('OPENAI_COMPAT_VIDEO_ASPECT_RATIO_CONFLICT: aspectRatio and aspect_ratio must match')\n  }\n  return aspectRatio || aspectRatioAlt\n}\n\nfunction normalizeModel(value: unknown): string {\n  if (value === undefined || value === null || value === '') return 'sora-2'\n  if (typeof value !== 'string') {\n    throw new Error(`OPENAI_COMPAT_VIDEO_MODEL_INVALID: ${String(value)}`)\n  }\n  const trimmed = value.trim()\n  if (!trimmed) {\n    throw new Error('OPENAI_COMPAT_VIDEO_MODEL_INVALID: empty model id')\n  }\n  return trimmed\n}\n\nfunction resolveSizeOrientation(aspectRatio: OpenAIVideoAspectRatio | undefined): 'portrait' | 'landscape' {\n  if (\n    aspectRatio === '9:16'\n    || aspectRatio === '3:4'\n    || aspectRatio === '2:3'\n    || aspectRatio === '9:21'\n  ) {\n    return 'portrait'\n  }\n  return 'landscape'\n}\n\nfunction normalizeSize(value: unknown, aspectRatio: OpenAIVideoAspectRatio | undefined): OpenAIVideoSize | undefined {\n  if (value === '720x1280' || value === '1280x720' || value === '1024x1792' || value === '1792x1024') {\n    return value\n  }\n\n  const orientation = resolveSizeOrientation(aspectRatio)\n  if (value === '720p') {\n    return orientation === 'portrait' ? '720x1280' : '1280x720'\n  }\n  if (value === '1080p') {\n    return orientation === 'portrait' ? '1024x1792' : '1792x1024'\n  }\n  if (value === undefined) return undefined\n  throw new Error(`OPENAI_COMPAT_VIDEO_SIZE_UNSUPPORTED: ${String(value)}`)\n}\n\nfunction resolveFinalSize(options: Record<string, unknown>): OpenAIVideoSize | undefined {\n  const aspectRatio = resolveAspectRatio(options)\n  const normalizedSize = options.size === undefined ? undefined : normalizeSize(options.size, aspectRatio)\n  const normalizedResolution = options.resolution === undefined ? undefined : normalizeSize(options.resolution, aspectRatio)\n  if (normalizedSize && normalizedResolution && normalizedSize !== normalizedResolution) {\n    throw new Error('OPENAI_COMPAT_VIDEO_SIZE_CONFLICT: size and resolution must match')\n  }\n  return normalizedSize || normalizedResolution\n}\n\nfunction encodeProviderId(providerId: string): string {\n  return Buffer.from(providerId, 'utf8').toString('base64url')\n}\n\nasync function toUploadFileFromImageUrl(imageUrl: string): Promise<File> {\n  const base64DataUrl = imageUrl.startsWith('data:') ? imageUrl : await normalizeToBase64ForGeneration(imageUrl)\n  const parsed = parseDataUrl(base64DataUrl)\n  if (!parsed) {\n    throw new Error('OPENAI_COMPAT_VIDEO_INPUT_REFERENCE_INVALID')\n  }\n  const bytes = Buffer.from(parsed.base64, 'base64')\n  return await toFile(bytes, 'input-reference.png', { type: parsed.mimeType })\n}\n\nexport async function generateVideoViaOpenAICompat(request: OpenAICompatVideoRequest): Promise<GenerateResult> {\n  const {\n    userId,\n    providerId,\n    modelId,\n    imageUrl,\n    prompt,\n    options = {},\n  } = request\n\n  assertAllowedOptions(options)\n  const config = await resolveOpenAICompatClientConfig(userId, providerId)\n  const client = createOpenAICompatClient(config)\n\n  const selectedModelId = normalizeModel(modelId || options.modelId)\n  const seconds = normalizeDuration(options.duration)\n  const size = resolveFinalSize(options)\n  const trimmedPrompt = prompt.trim()\n  if (!trimmedPrompt) {\n    throw new Error('OPENAI_COMPAT_VIDEO_PROMPT_REQUIRED')\n  }\n\n  const inputReference = await toUploadFileFromImageUrl(imageUrl)\n  const response = await client.videos.create({\n    prompt: trimmedPrompt,\n    model: selectedModelId,\n    ...(seconds ? { seconds } : {}),\n    ...(size ? { size } : {}),\n    input_reference: inputReference,\n  } as Parameters<typeof client.videos.create>[0])\n\n  if (!response.id || typeof response.id !== 'string') {\n    throw new Error('OPENAI_COMPAT_VIDEO_CREATE_INVALID_RESPONSE: missing video id')\n  }\n\n  const providerToken = encodeProviderId(config.providerId)\n  return {\n    success: true,\n    async: true,\n    requestId: response.id,\n    externalId: `OPENAI:VIDEO:${providerToken}:${response.id}`,\n  }\n}\n"
  },
  {
    "path": "src/lib/model-gateway/router.ts",
    "content": "import { getProviderKey } from '@/lib/api-config'\nimport type { ModelGatewayRoute } from './types'\n\nconst COMPATIBLE_PROVIDER_KEYS = new Set([\n  'openai-compatible',\n])\nconst OFFICIAL_ONLY_PROVIDER_KEYS = new Set([\n  'bailian',\n  'siliconflow',\n])\n\nexport function isCompatibleProvider(providerId: string): boolean {\n  const providerKey = getProviderKey(providerId).toLowerCase()\n  return COMPATIBLE_PROVIDER_KEYS.has(providerKey)\n}\n\nexport function resolveModelGatewayRoute(providerId: string): ModelGatewayRoute {\n  const providerKey = getProviderKey(providerId).toLowerCase()\n  if (OFFICIAL_ONLY_PROVIDER_KEYS.has(providerKey)) return 'official'\n  return isCompatibleProvider(providerId) ? 'openai-compat' : 'official'\n}\n"
  },
  {
    "path": "src/lib/model-gateway/types.ts",
    "content": "export type ModelGatewayRoute = 'official' | 'openai-compat'\n\nexport type CompatibleProviderKey = 'openai-compatible'\n\nexport type OpenAICompatImageProfile = CompatibleProviderKey\n\nexport type OpenAICompatVideoProfile = 'openai-compatible'\n\nexport interface OpenAICompatClientConfig {\n  providerId: string\n  baseUrl: string\n  apiKey: string\n}\n\nexport interface OpenAICompatImageRequest {\n  userId: string\n  providerId: string\n  modelId?: string\n  prompt: string\n  referenceImages?: string[]\n  options?: Record<string, unknown>\n  profile: OpenAICompatImageProfile\n  template?: import('@/lib/openai-compat-media-template').OpenAICompatMediaTemplate\n  modelKey?: string\n}\n\nexport interface OpenAICompatVideoRequest {\n  userId: string\n  providerId: string\n  modelId?: string\n  imageUrl: string\n  prompt: string\n  options?: Record<string, unknown>\n  profile: OpenAICompatVideoProfile\n  template?: import('@/lib/openai-compat-media-template').OpenAICompatMediaTemplate\n  modelKey?: string\n}\n\nexport interface OpenAICompatChatRequest {\n  userId: string\n  providerId: string\n  modelId: string\n  messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>\n  temperature: number\n}\n"
  },
  {
    "path": "src/lib/model-pricing/catalog.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\nimport { type CapabilityValue } from '@/lib/model-config-contract'\n\nexport type PricingApiType =\n  | 'text'\n  | 'image'\n  | 'video'\n  | 'voice'\n  | 'voice-design'\n  | 'lip-sync'\n\nexport interface BuiltinPricingTier {\n  when: Record<string, CapabilityValue>\n  amount: number\n}\n\nexport interface BuiltinPricingDefinition {\n  mode: 'flat' | 'capability'\n  flatAmount?: number\n  tiers?: BuiltinPricingTier[]\n}\n\nexport interface BuiltinPricingCatalogEntry {\n  apiType: PricingApiType\n  provider: string\n  modelId: string\n  pricing: BuiltinPricingDefinition\n}\n\ninterface PricingCatalogCache {\n  entries: BuiltinPricingCatalogEntry[]\n  exact: Map<string, BuiltinPricingCatalogEntry>\n  byModelId: Map<string, BuiltinPricingCatalogEntry[]>\n}\n\nconst PRICING_CATALOG_DIR = path.resolve(process.cwd(), 'standards/pricing')\n\nlet cache: PricingCatalogCache | null = null\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction isPricingApiType(value: unknown): value is PricingApiType {\n  return value === 'text'\n    || value === 'image'\n    || value === 'video'\n    || value === 'voice'\n    || value === 'voice-design'\n    || value === 'lip-sync'\n}\n\nfunction isCapabilityValue(value: unknown): value is CapabilityValue {\n  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction readFiniteNumber(value: unknown): number | null {\n  return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\nfunction normalizePricingTier(raw: unknown, filePath: string, index: number, tierIndex: number): BuiltinPricingTier {\n  if (!isRecord(raw)) {\n    throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.tiers[${tierIndex}] must be object`)\n  }\n\n  const whenRaw = raw.when\n  if (!isRecord(whenRaw) || Object.keys(whenRaw).length === 0) {\n    throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.tiers[${tierIndex}].when must be non-empty object`)\n  }\n\n  const when: Record<string, CapabilityValue> = {}\n  for (const [field, value] of Object.entries(whenRaw)) {\n    if (!isCapabilityValue(value)) {\n      throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.tiers[${tierIndex}].when.${field} must be string/number/boolean`)\n    }\n    when[field] = value\n  }\n\n  const amount = readFiniteNumber(raw.amount)\n  if (amount === null || amount < 0) {\n    throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.tiers[${tierIndex}].amount must be finite number >= 0`)\n  }\n\n  return {\n    when,\n    amount,\n  }\n}\n\nfunction normalizePricing(raw: unknown, filePath: string, index: number): BuiltinPricingDefinition {\n  if (!isRecord(raw)) {\n    throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.pricing must be object`)\n  }\n\n  const modeRaw = raw.mode\n  if (modeRaw !== 'flat' && modeRaw !== 'capability') {\n    throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.pricing.mode must be flat or capability`)\n  }\n\n  if (modeRaw === 'flat') {\n    const flatAmount = readFiniteNumber(raw.flatAmount)\n    if (flatAmount === null || flatAmount < 0) {\n      throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.pricing.flatAmount must be finite number >= 0`)\n    }\n\n    return {\n      mode: 'flat',\n      flatAmount,\n    }\n  }\n\n  const tiersRaw = raw.tiers\n  if (!Array.isArray(tiersRaw) || tiersRaw.length === 0) {\n    throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.pricing.tiers must be a non-empty array`)\n  }\n\n  const tiers = tiersRaw.map((tier, tierIndex) => normalizePricingTier(tier, filePath, index, tierIndex))\n\n  return {\n    mode: 'capability',\n    tiers,\n  }\n}\n\nfunction normalizePricingEntry(raw: unknown, filePath: string, index: number): BuiltinPricingCatalogEntry {\n  if (!isRecord(raw)) {\n    throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index} must be object`)\n  }\n\n  const apiTypeRaw = raw.apiType\n  if (!isPricingApiType(apiTypeRaw)) {\n    throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.apiType must be one of text/image/video/voice/voice-design/lip-sync`)\n  }\n\n  const provider = readTrimmedString(raw.provider)\n  const modelId = readTrimmedString(raw.modelId)\n  if (!provider || !modelId) {\n    throw new Error(`PRICING_CATALOG_INVALID: ${filePath}#${index}.provider/modelId are required`)\n  }\n\n  const pricing = normalizePricing(raw.pricing, filePath, index)\n\n  return {\n    apiType: apiTypeRaw,\n    provider,\n    modelId,\n    pricing,\n  }\n}\n\nfunction buildCache(entries: BuiltinPricingCatalogEntry[]): PricingCatalogCache {\n  const exact = new Map<string, BuiltinPricingCatalogEntry>()\n  const byModelId = new Map<string, BuiltinPricingCatalogEntry[]>()\n\n  for (const entry of entries) {\n    const exactKey = `${entry.apiType}::${entry.provider}::${entry.modelId}`\n    if (exact.has(exactKey)) {\n      throw new Error(`PRICING_CATALOG_DUPLICATE: ${exactKey}`)\n    }\n    exact.set(exactKey, entry)\n\n    const modelIdKey = `${entry.apiType}::${entry.modelId}`\n    const group = byModelId.get(modelIdKey) || []\n    group.push(entry)\n    byModelId.set(modelIdKey, group)\n  }\n\n  return {\n    entries,\n    exact,\n    byModelId,\n  }\n}\n\nfunction cloneEntry(entry: BuiltinPricingCatalogEntry): BuiltinPricingCatalogEntry {\n  return JSON.parse(JSON.stringify(entry)) as BuiltinPricingCatalogEntry\n}\n\nfunction loadPricingCatalog(): PricingCatalogCache {\n  if (cache) return cache\n\n  const files = fs\n    .readdirSync(PRICING_CATALOG_DIR, { withFileTypes: true })\n    .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))\n    .map((entry) => path.join(PRICING_CATALOG_DIR, entry.name))\n\n  if (files.length === 0) {\n    throw new Error(`PRICING_CATALOG_MISSING: no json file in ${PRICING_CATALOG_DIR}`)\n  }\n\n  const entries: BuiltinPricingCatalogEntry[] = []\n  for (const filePath of files) {\n    const raw = fs.readFileSync(filePath, 'utf8')\n    const parsed = JSON.parse(raw) as unknown\n    if (!Array.isArray(parsed)) {\n      throw new Error(`PRICING_CATALOG_INVALID: ${filePath} must be array`)\n    }\n\n    for (let index = 0; index < parsed.length; index += 1) {\n      entries.push(normalizePricingEntry(parsed[index], filePath, index))\n    }\n  }\n\n  cache = buildCache(entries)\n  return cache\n}\n\nexport function listBuiltinPricingCatalog(): BuiltinPricingCatalogEntry[] {\n  return loadPricingCatalog().entries.map(cloneEntry)\n}\n\n/**\n * Provider keys that share pricing catalogs with a canonical provider.\n * e.g. gemini-compatible uses the same models and pricing as google.\n * Defined here so ALL callers automatically benefit — no need to add alias logic per call site.\n */\nconst PROVIDER_ALIASES: Readonly<Record<string, string>> = {\n  'gemini-compatible': 'google',\n}\n\nexport function findBuiltinPricingCatalogEntry(\n  apiType: PricingApiType,\n  provider: string,\n  modelId: string,\n): BuiltinPricingCatalogEntry | null {\n  const loaded = loadPricingCatalog()\n\n  const exactKey = `${apiType}::${provider}::${modelId}`\n  const entry = loaded.exact.get(exactKey)\n  if (entry) return cloneEntry(entry)\n\n  // Strip composite suffix (e.g. 'gemini-compatible:uuid' → 'gemini-compatible')\n  const providerKey = provider.includes(':') ? provider.slice(0, provider.indexOf(':')) : provider\n  if (providerKey !== provider) {\n    const keyWithProviderKey = `${apiType}::${providerKey}::${modelId}`\n    const keyEntry = loaded.exact.get(keyWithProviderKey)\n    if (keyEntry) return cloneEntry(keyEntry)\n  }\n\n  // Alias fallback: look up the canonical provider\n  const aliasTarget = PROVIDER_ALIASES[providerKey]\n  if (aliasTarget) {\n    const aliasKey = `${apiType}::${aliasTarget}::${modelId}`\n    const aliasEntry = loaded.exact.get(aliasKey)\n    if (aliasEntry) return cloneEntry(aliasEntry)\n  }\n\n  return null\n}\n\nexport function findBuiltinPricingCatalogEntriesByModelId(\n  apiType: PricingApiType,\n  modelId: string,\n): BuiltinPricingCatalogEntry[] {\n  const modelIdKey = `${apiType}::${modelId}`\n  const entries = loadPricingCatalog().byModelId.get(modelIdKey) || []\n  return entries.map(cloneEntry)\n}\n\nexport function resetBuiltinPricingCatalogCacheForTest() {\n  cache = null\n}\n"
  },
  {
    "path": "src/lib/model-pricing/lookup.ts",
    "content": "import {\n  parseModelKeyStrict,\n  type CapabilityValue,\n} from '@/lib/model-config-contract'\nimport {\n  findBuiltinPricingCatalogEntriesByModelId,\n  findBuiltinPricingCatalogEntry,\n  type BuiltinPricingCatalogEntry,\n  type PricingApiType,\n} from '@/lib/model-pricing/catalog'\n\nexport interface PricingResolutionResolved {\n  status: 'resolved'\n  entry: BuiltinPricingCatalogEntry\n  amount: number\n  mode: 'flat' | 'capability'\n}\n\nexport interface PricingResolutionNotConfigured {\n  status: 'not_configured'\n}\n\nexport interface PricingResolutionAmbiguousModel {\n  status: 'ambiguous_model'\n  apiType: PricingApiType\n  modelId: string\n  candidates: BuiltinPricingCatalogEntry[]\n}\n\nexport interface PricingResolutionMissingCapabilityMatch {\n  status: 'missing_capability_match'\n  entry: BuiltinPricingCatalogEntry\n  selections: Record<string, CapabilityValue>\n}\n\nexport type PricingResolution =\n  | PricingResolutionResolved\n  | PricingResolutionNotConfigured\n  | PricingResolutionAmbiguousModel\n  | PricingResolutionMissingCapabilityMatch\n\nfunction cloneSelections(\n  raw: Record<string, CapabilityValue> | undefined,\n): Record<string, CapabilityValue> {\n  if (!raw) return {}\n  const next: Record<string, CapabilityValue> = {}\n  for (const [field, value] of Object.entries(raw)) {\n    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n      next[field] = value\n    }\n  }\n  return next\n}\n\nfunction matchTier(\n  entry: BuiltinPricingCatalogEntry,\n  selections: Record<string, CapabilityValue>,\n): number | null {\n  const tiers = entry.pricing.tiers || []\n  for (const tier of tiers) {\n    const matched = Object.entries(tier.when).every(([field, expectedValue]) => selections[field] === expectedValue)\n    if (matched) return tier.amount\n  }\n  return null\n}\n\nfunction resolveEntryByModel(apiType: PricingApiType, model: string): PricingResolution {\n  const parsed = parseModelKeyStrict(model)\n  if (parsed) {\n    // findBuiltinPricingCatalogEntry handles alias fallback internally\n    const exact = findBuiltinPricingCatalogEntry(apiType, parsed.provider, parsed.modelId)\n    if (exact) {\n      return { status: 'resolved', entry: exact, amount: 0, mode: exact.pricing.mode }\n    }\n    return { status: 'not_configured' }\n  }\n\n  const candidates = findBuiltinPricingCatalogEntriesByModelId(apiType, model)\n  if (candidates.length === 0) {\n    return { status: 'not_configured' }\n  }\n  if (candidates.length > 1) {\n    return {\n      status: 'ambiguous_model',\n      apiType,\n      modelId: model,\n      candidates,\n    }\n  }\n\n  return {\n    status: 'resolved',\n    entry: candidates[0],\n    amount: 0,\n    mode: candidates[0].pricing.mode,\n  }\n}\n\nexport function resolveBuiltinPricing(input: {\n  apiType: PricingApiType\n  model: string\n  selections?: Record<string, CapabilityValue>\n}): PricingResolution {\n  const entryResolution = resolveEntryByModel(input.apiType, input.model)\n  if (entryResolution.status !== 'resolved') return entryResolution\n\n  const { entry } = entryResolution\n  if (entry.pricing.mode === 'flat') {\n    const amount = entry.pricing.flatAmount\n    if (typeof amount !== 'number') {\n      return {\n        status: 'missing_capability_match',\n        entry,\n        selections: cloneSelections(input.selections),\n      }\n    }\n\n    return {\n      status: 'resolved',\n      entry,\n      amount,\n      mode: 'flat',\n    }\n  }\n\n  const selections = cloneSelections(input.selections)\n  const amount = matchTier(entry, selections)\n  if (amount === null) {\n    return {\n      status: 'missing_capability_match',\n      entry,\n      selections,\n    }\n  }\n\n  return {\n    status: 'resolved',\n    entry,\n    amount,\n    mode: 'capability',\n  }\n}\n"
  },
  {
    "path": "src/lib/model-pricing/version.ts",
    "content": "/**\n * Built-in pricing catalog version used for billing traceability.\n * Bump this value whenever standards/pricing catalog changes semantically.\n */\nexport const BUILTIN_PRICING_VERSION = '2026-02-19'\n"
  },
  {
    "path": "src/lib/model-pricing/video-tier.ts",
    "content": "import type { CapabilityValue } from '@/lib/model-config-contract'\n\nexport interface VideoPricingTier {\n  when: Record<string, CapabilityValue>\n}\n\nfunction matchesFixedSelections(\n  tier: VideoPricingTier,\n  fixedSelections: Record<string, CapabilityValue>,\n): boolean {\n  for (const [field, expectedValue] of Object.entries(fixedSelections)) {\n    const tierValue = tier.when[field]\n    if (tierValue !== undefined && tierValue !== expectedValue) {\n      return false\n    }\n  }\n  return true\n}\n\nexport function projectVideoPricingTiersByFixedSelections(input: {\n  tiers: VideoPricingTier[]\n  fixedSelections: Record<string, CapabilityValue>\n}): VideoPricingTier[] {\n  const { tiers, fixedSelections } = input\n  if (tiers.length === 0) return []\n  if (Object.keys(fixedSelections).length === 0) {\n    return tiers.map((tier) => ({ when: { ...tier.when } }))\n  }\n\n  const hiddenFields = new Set(Object.keys(fixedSelections))\n  const projected: VideoPricingTier[] = []\n\n  for (const tier of tiers) {\n    if (!matchesFixedSelections(tier, fixedSelections)) continue\n\n    const nextWhen: Record<string, CapabilityValue> = {}\n    for (const [field, value] of Object.entries(tier.when)) {\n      if (hiddenFields.has(field)) continue\n      nextWhen[field] = value\n    }\n    projected.push({ when: nextWhen })\n  }\n\n  return projected\n}\n"
  },
  {
    "path": "src/lib/modes.ts",
    "content": "import { ProjectMode } from '@/types/project'\n\n// 重新导出 ProjectMode 类型，方便其他文件使用\nexport type { ProjectMode }\n\nexport interface ModeConfig {\n  id: ProjectMode\n  name: string\n  description: string\n  icon: string\n  color: string\n  available: boolean\n}\n\nexport const PROJECT_MODE: ModeConfig = {\n  id: 'novel-promotion',\n  name: '小说推文',\n  description: '从小说生成推广短视频',\n  icon: 'N',\n  color: 'purple',\n  available: true\n}\n\n// 为了兼容性保留\nexport const PROJECT_MODES: ModeConfig[] = [PROJECT_MODE]\n\nexport function getModeConfig(mode: ProjectMode): ModeConfig | undefined {\n  return mode === 'novel-promotion' ? PROJECT_MODE : undefined\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/insert-panel.ts",
    "content": "const DEFAULT_INSERT_PANEL_USER_INPUT = {\n  zh: '请根据前后镜头自动分析并插入一个自然衔接的新分镜。',\n  en: 'Automatically analyze the surrounding panels and insert a naturally connected new panel.',\n} as const\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction isZhLocale(locale: string | undefined): boolean {\n  return typeof locale === 'string' && locale.toLowerCase().startsWith('zh')\n}\n\nexport function resolveInsertPanelUserInput(payload: Record<string, unknown>, locale?: string): string {\n  const explicitInput = readTrimmedString(payload.userInput)\n  if (explicitInput) return explicitInput\n\n  const promptInput = readTrimmedString(payload.prompt)\n  if (promptInput) return promptInput\n\n  return isZhLocale(locale)\n    ? DEFAULT_INSERT_PANEL_USER_INPUT.zh\n    : DEFAULT_INSERT_PANEL_USER_INPUT.en\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/panel-ai-data-sync.ts",
    "content": "export interface PanelCharacterRef {\n  name: string\n  appearance: string\n}\n\ntype JsonRecord = Record<string, unknown>\n\nfunction isJsonRecord(value: unknown): value is JsonRecord {\n  return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction assert(condition: boolean, message: string): asserts condition {\n  if (!condition) {\n    throw new Error(message)\n  }\n}\n\nfunction parseStructuredJsonFromString(raw: string, fieldName: string): unknown {\n  const trimmed = raw.trim()\n  if (!trimmed) return null\n\n  let parsed: unknown = trimmed\n  for (let depth = 0; depth < 2 && typeof parsed === 'string'; depth += 1) {\n    try {\n      parsed = JSON.parse(parsed)\n    } catch {\n      throw new Error(`${fieldName} must be valid JSON`)\n    }\n  }\n\n  if (typeof parsed === 'string') {\n    throw new Error(`${fieldName} must be JSON object/array, not a plain string`)\n  }\n  return parsed\n}\n\nfunction normalizeStructuredJsonInput(value: unknown, fieldName: string): unknown {\n  if (value === null || value === undefined || value === '') return null\n  if (typeof value === 'string') {\n    return parseStructuredJsonFromString(value, fieldName)\n  }\n  return value\n}\n\nfunction assertStructuredJsonValue(value: unknown, fieldName: string): asserts value is JsonRecord | unknown[] | null {\n  if (value === null) return\n  const isStructured = Array.isArray(value) || isJsonRecord(value)\n  assert(isStructured, `${fieldName} must be a JSON object or array`)\n}\n\nfunction assertNameRecord(value: unknown, fieldName: string): asserts value is JsonRecord & { name: string } {\n  assert(isJsonRecord(value), `${fieldName} item must be an object`)\n  assert(typeof value.name === 'string' && value.name.trim().length > 0, `${fieldName} item.name must be a non-empty string`)\n}\n\nfunction filterNamedRecordsBySet(\n  source: unknown[],\n  keepNames: ReadonlySet<string>,\n  fieldName: string,\n): JsonRecord[] {\n  return source\n    .map((item) => {\n      assertNameRecord(item, fieldName)\n      return item\n    })\n    .filter((item) => keepNames.has(item.name))\n}\n\nfunction syncActingNotesJson(\n  actingNotesJson: string | null | undefined,\n  keepNames: ReadonlySet<string>,\n): string | null | undefined {\n  if (actingNotesJson === undefined) return undefined\n  const parsed = normalizeStructuredJsonInput(actingNotesJson, 'actingNotes')\n  assertStructuredJsonValue(parsed, 'actingNotes')\n  if (parsed === null) return null\n\n  if (Array.isArray(parsed)) {\n    const filtered = filterNamedRecordsBySet(parsed, keepNames, 'actingNotes')\n    return JSON.stringify(filtered)\n  }\n\n  assert(isJsonRecord(parsed), 'actingNotes must be a JSON object or array')\n  const maybeCharacters = parsed.characters\n  if (maybeCharacters === undefined) {\n    return JSON.stringify(parsed)\n  }\n\n  assert(Array.isArray(maybeCharacters), 'actingNotes.characters must be an array')\n  const filtered = filterNamedRecordsBySet(maybeCharacters, keepNames, 'actingNotes.characters')\n  return JSON.stringify({\n    ...parsed,\n    characters: filtered,\n  })\n}\n\nfunction syncPhotographyRulesJson(\n  photographyRulesJson: string | null | undefined,\n  keepNames: ReadonlySet<string>,\n): string | null | undefined {\n  if (photographyRulesJson === undefined) return undefined\n  const parsed = normalizeStructuredJsonInput(photographyRulesJson, 'photographyRules')\n  assertStructuredJsonValue(parsed, 'photographyRules')\n  if (parsed === null) return null\n  assert(isJsonRecord(parsed), 'photographyRules must be a JSON object')\n\n  const maybeCharacters = parsed.characters\n  if (maybeCharacters === undefined) {\n    return JSON.stringify(parsed)\n  }\n\n  assert(Array.isArray(maybeCharacters), 'photographyRules.characters must be an array')\n  const filtered = filterNamedRecordsBySet(maybeCharacters, keepNames, 'photographyRules.characters')\n  return JSON.stringify({\n    ...parsed,\n    characters: filtered,\n  })\n}\n\nexport function serializeStructuredJsonField(value: unknown, fieldName: string): string | null {\n  const normalized = normalizeStructuredJsonInput(value, fieldName)\n  assertStructuredJsonValue(normalized, fieldName)\n  return normalized === null ? null : JSON.stringify(normalized)\n}\n\nexport interface SyncPanelCharacterDependentJsonInput {\n  characters: PanelCharacterRef[]\n  removeIndex: number\n  actingNotesJson?: string | null\n  photographyRulesJson?: string | null\n}\n\nexport interface SyncPanelCharacterDependentJsonResult {\n  characters: PanelCharacterRef[]\n  actingNotesJson?: string | null\n  photographyRulesJson?: string | null\n}\n\nexport function syncPanelCharacterDependentJson({\n  characters,\n  removeIndex,\n  actingNotesJson,\n  photographyRulesJson,\n}: SyncPanelCharacterDependentJsonInput): SyncPanelCharacterDependentJsonResult {\n  const nextCharacters = characters.filter((_, index) => index !== removeIndex)\n  const keepNames = new Set(nextCharacters.map((character) => character.name))\n\n  return {\n    characters: nextCharacters,\n    actingNotesJson: syncActingNotesJson(actingNotesJson, keepNames),\n    photographyRulesJson: syncPhotographyRulesJson(photographyRulesJson, keepNames),\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/run-stream/types.ts",
    "content": "export type RunStreamLane = 'text' | 'reasoning'\n\nexport type RunStreamEventType =\n  | 'run.start'\n  | 'run.complete'\n  | 'run.error'\n  | 'step.start'\n  | 'step.chunk'\n  | 'step.complete'\n  | 'step.error'\n\nexport type RunStreamStatus = 'idle' | 'running' | 'completed' | 'failed'\nexport type RunStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'blocked' | 'stale'\n\nexport type RunStreamEvent = {\n  runId: string\n  event: RunStreamEventType\n  ts: string\n  status?: RunStreamStatus | RunStepStatus\n  stepId?: string\n  stepAttempt?: number\n  stepTitle?: string\n  stepIndex?: number\n  stepTotal?: number\n  lane?: RunStreamLane\n  seq?: number\n  textDelta?: string\n  reasoningDelta?: string\n  text?: string\n  reasoning?: string\n  message?: string\n  dependsOn?: string[]\n  groupId?: string\n  parallelKey?: string\n  retryable?: boolean\n  blockedBy?: string[]\n  payload?: Record<string, unknown> | null\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/script-to-storyboard/orchestrator.ts",
    "content": "import { safeParseJsonArray } from '@/lib/json-repair'\nimport { buildCharactersIntroduction } from '@/lib/constants'\nimport { normalizeAnyError } from '@/lib/errors/normalize'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport { mapWithConcurrency } from '@/lib/async/map-with-concurrency'\nimport {\n  type ActingDirection,\n  type CharacterAsset,\n  type ClipCharacterRef,\n  type LocationAsset,\n  type PhotographyRule,\n  type StoryboardPanel,\n  formatClipId,\n  getFilteredAppearanceList,\n  getFilteredFullDescription,\n  getFilteredLocationsDescription,\n} from '@/lib/storyboard-phases'\nimport {\n  DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY,\n  normalizeWorkflowConcurrencyValue,\n} from '@/lib/workflow-concurrency'\n\ntype JsonRecord = Record<string, unknown>\nconst orchestratorLogger = createScopedLogger({ module: 'worker.orchestrator.script_to_storyboard' })\n\nexport type ScriptToStoryboardStepMeta = {\n  stepId: string\n  stepAttempt?: number\n  stepTitle: string\n  stepIndex: number\n  stepTotal: number\n  dependsOn?: string[]\n  groupId?: string\n  parallelKey?: string\n  retryable?: boolean\n  blockedBy?: string[]\n}\n\nexport type ScriptToStoryboardStepOutput = {\n  text: string\n  reasoning: string\n}\n\ntype ClipInput = {\n  id: string\n  content: string | null\n  characters: string | null\n  location: string | null\n  screenplay: string | null\n}\n\nexport type ScriptToStoryboardPromptTemplates = {\n  phase1PlanTemplate: string\n  phase2CinematographyTemplate: string\n  phase2ActingTemplate: string\n  phase3DetailTemplate: string\n}\n\nexport type ClipStoryboardPanels = {\n  clipId: string\n  clipIndex: number\n  finalPanels: StoryboardPanel[]\n}\n\nexport type ScriptToStoryboardOrchestratorInput = {\n  concurrency?: number\n  clips: ClipInput[]\n  novelPromotionData: {\n    characters: CharacterAsset[]\n    locations: LocationAsset[]\n  }\n  promptTemplates: ScriptToStoryboardPromptTemplates\n  runStep: (\n    meta: ScriptToStoryboardStepMeta,\n    prompt: string,\n    action: string,\n    maxOutputTokens: number,\n  ) => Promise<ScriptToStoryboardStepOutput>\n}\n\nexport type ScriptToStoryboardOrchestratorResult = {\n  clipPanels: ClipStoryboardPanels[]\n  phase1PanelsByClipId: Record<string, StoryboardPanel[]>\n  phase2CinematographyByClipId: Record<string, PhotographyRule[]>\n  phase2ActingByClipId: Record<string, ActingDirection[]>\n  phase3PanelsByClipId: Record<string, StoryboardPanel[]>\n  summary: {\n    clipCount: number\n    totalPanelCount: number\n    totalStepCount: number\n  }\n}\n\n\nexport class JsonParseError extends Error {\n  rawText: string\n  constructor(message: string, rawText: string) {\n    super(message)\n    this.name = 'JsonParseError'\n    this.rawText = rawText\n  }\n}\n\nfunction parseJsonArray<T extends JsonRecord>(responseText: string, label: string): T[] {\n  const rows = safeParseJsonArray(responseText)\n  if (rows.length === 0) {\n    throw new JsonParseError(`${label}: empty result`, responseText)\n  }\n  return rows as T[]\n}\n\n\nfunction parseClipCharacters(raw: string | null): ClipCharacterRef[] {\n  if (!raw) return []\n  try {\n    const parsed = JSON.parse(raw)\n    if (!Array.isArray(parsed)) {\n      throw new Error('characters field must be JSON array')\n    }\n    return parsed as ClipCharacterRef[]\n  } catch (error) {\n    throw new Error(`Invalid clip characters JSON: ${error instanceof Error ? error.message : String(error)}`)\n  }\n}\n\nfunction parseScreenplay(raw: string | null): unknown {\n  if (!raw) return null\n  try {\n    return JSON.parse(raw)\n  } catch (error) {\n    throw new Error(`Invalid clip screenplay JSON: ${error instanceof Error ? error.message : String(error)}`)\n  }\n}\n\nfunction withStepMeta(\n  stepId: string,\n  stepTitle: string,\n  stepIndex: number,\n  stepTotal: number,\n  extra?: Pick<ScriptToStoryboardStepMeta, 'dependsOn' | 'groupId' | 'parallelKey' | 'retryable' | 'blockedBy'>,\n): ScriptToStoryboardStepMeta {\n  return {\n    stepId,\n    stepTitle,\n    stepIndex,\n    stepTotal,\n    ...extra,\n  }\n}\n\nfunction mergePanelsWithRules(params: {\n  finalPanels: StoryboardPanel[]\n  photographyRules: PhotographyRule[]\n  actingDirections: ActingDirection[]\n}) {\n  const { finalPanels, photographyRules, actingDirections } = params\n  return finalPanels.map((panel, index) => {\n    const rules = photographyRules.find((rule) => rule.panel_number === panel.panel_number)\n    if (!rules) {\n      throw new Error(`Missing photography rule for panel_number=${String(panel.panel_number)} at index=${index}`)\n    }\n    const acting = actingDirections.find((item) => item.panel_number === panel.panel_number)\n    if (!acting) {\n      throw new Error(`Missing acting direction for panel_number=${String(panel.panel_number)} at index=${index}`)\n    }\n\n    return {\n      ...panel,\n      photographyPlan: {\n        composition: rules.composition,\n        lighting: rules.lighting,\n        colorPalette: rules.color_palette,\n        atmosphere: rules.atmosphere,\n        technicalNotes: rules.technical_notes,\n      },\n      actingNotes: acting.characters,\n    }\n  })\n}\n\nconst MAX_STEP_ATTEMPTS = 3\nconst MAX_RETRY_DELAY_MS = 10_000\n\nfunction wait(ms: number) {\n  return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nfunction computeRetryDelayMs(attempt: number) {\n  const base = Math.min(1_000 * Math.pow(2, Math.max(0, attempt - 1)), MAX_RETRY_DELAY_MS)\n  const jitter = Math.floor(Math.random() * 300)\n  return base + jitter\n}\n\nfunction shouldRetryStepError(error: unknown, message: string, retryable: boolean) {\n  if (error instanceof JsonParseError) return true\n  if (retryable) return true\n  const lowerMessage = message.toLowerCase()\n  if (lowerMessage.includes('ark responses 调用失败')) return false\n  if (lowerMessage.includes('invalidparameter')) return false\n  if (lowerMessage.includes('unknown field')) return false\n  return lowerMessage.includes('unexpected token')\n    || lowerMessage.includes('unexpected end of json input')\n    || lowerMessage.includes('json format invalid')\n    || lowerMessage.includes('invalid json output')\n    || lowerMessage.includes('parse')\n}\n\nasync function runStepWithRetry<T>(\n  runStep: ScriptToStoryboardOrchestratorInput['runStep'],\n  baseMeta: ScriptToStoryboardStepMeta,\n  prompt: string,\n  action: string,\n  maxOutputTokens: number,\n  parse: (text: string) => T,\n): Promise<{ output: ScriptToStoryboardStepOutput; parsed: T }> {\n  let lastError: Error | null = null\n  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {\n    const meta = attempt === 1\n      ? baseMeta\n      : {\n        ...baseMeta,\n        stepId: baseMeta.stepId,\n        stepAttempt: attempt,\n        stepTitle: baseMeta.stepTitle,\n      }\n    try {\n      const output = await runStep(meta, prompt, action, maxOutputTokens)\n      const parsed = parse(output.text)\n      return { output, parsed }\n    } catch (error) {\n      lastError = error instanceof Error ? error : new Error(String(error))\n      const normalizedError = normalizeAnyError(error, { context: 'worker' })\n      const shouldRetry = attempt < MAX_STEP_ATTEMPTS\n        && shouldRetryStepError(error, normalizedError.message, normalizedError.retryable)\n\n      orchestratorLogger.error({\n        action: 'orchestrator.step.retry',\n        message: shouldRetry ? 'step failed, retrying' : 'step failed, no more retry',\n        errorCode: normalizedError.code,\n        retryable: normalizedError.retryable,\n        details: {\n          stepId: baseMeta.stepId,\n          action,\n          attempt,\n          maxAttempts: MAX_STEP_ATTEMPTS,\n        },\n        error: {\n          name: lastError.name,\n          message: lastError.message,\n          stack: lastError.stack,\n        },\n      })\n\n      if (!shouldRetry) {\n        break\n      }\n      const retryDelayMs = computeRetryDelayMs(attempt)\n      await wait(retryDelayMs)\n    }\n  }\n  throw lastError!\n}\n\nexport async function runScriptToStoryboardOrchestrator(\n  input: ScriptToStoryboardOrchestratorInput,\n): Promise<ScriptToStoryboardOrchestratorResult> {\n  const { clips, novelPromotionData, promptTemplates, runStep, concurrency: rawConcurrency } = input\n  if (!Array.isArray(clips) || clips.length === 0) {\n    throw new Error('No clips found')\n  }\n  const concurrency = normalizeWorkflowConcurrencyValue(\n    rawConcurrency,\n    DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY,\n  )\n\n  const totalStepCount = clips.length * 4 + 2\n  const charactersLibName = (novelPromotionData.characters || []).map((c) => c.name).join(', ') || '无'\n  const locationsLibName = (novelPromotionData.locations || []).map((l) => l.name).join(', ') || '无'\n  const charactersIntroduction = buildCharactersIntroduction(novelPromotionData.characters || [])\n\n  const phase1PanelsByClipId = new Map<string, StoryboardPanel[]>()\n  const phase2CinematographyByClipId = new Map<string, PhotographyRule[]>()\n  const phase2ActingByClipId = new Map<string, ActingDirection[]>()\n  const phase3PanelsByClipId = new Map<string, StoryboardPanel[]>()\n\n  const phase1Results = await mapWithConcurrency(\n    clips,\n    concurrency,\n    async (clip, i) => {\n      const clipIndex = i + 1\n      const clipContent = typeof clip.content === 'string' ? clip.content.trim() : ''\n      if (!clipContent) {\n        throw new Error(`Clip ${formatClipId(clip)} content is empty`)\n      }\n      const clipCharacters = parseClipCharacters(clip.characters)\n      const filteredAppearanceList = getFilteredAppearanceList(novelPromotionData.characters || [], clipCharacters)\n      const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters || [], clipCharacters)\n      const clipJson = JSON.stringify(\n        {\n          id: clip.id,\n          content: clipContent,\n          characters: clipCharacters,\n          location: clip.location || null,\n        },\n        null,\n        2,\n      )\n\n      let phase1Prompt = promptTemplates.phase1PlanTemplate\n        .replace('{characters_lib_name}', charactersLibName)\n        .replace('{locations_lib_name}', locationsLibName)\n        .replace('{characters_introduction}', charactersIntroduction)\n        .replace('{characters_appearance_list}', filteredAppearanceList)\n        .replace('{characters_full_description}', filteredFullDescription)\n        .replace('{clip_json}', clipJson)\n\n      const screenplay = parseScreenplay(clip.screenplay)\n      if (screenplay) {\n        phase1Prompt = phase1Prompt.replace('{clip_content}', `【剧本格式】\\n${JSON.stringify(screenplay, null, 2)}`)\n      } else {\n        phase1Prompt = phase1Prompt.replace('{clip_content}', clipContent)\n      }\n\n      const phase1Meta = withStepMeta(\n        `clip_${clip.id}_phase1`,\n        'progress.streamStep.storyboardPlan',\n        clipIndex,\n        totalStepCount,\n        {\n          groupId: `clip_${clip.id}`,\n          parallelKey: 'phase1',\n          retryable: true,\n        },\n      )\n      const { parsed: planPanels } = await runStepWithRetry(\n        runStep, phase1Meta, phase1Prompt, 'storyboard_phase1_plan', 2600,\n        (text) => {\n          const panels = parseJsonArray<StoryboardPanel>(text, `phase1:${formatClipId(clip)}`)\n          if (panels.length === 0) {\n            throw new Error(`Phase 1 returned empty panels for clip ${formatClipId(clip)}`)\n          }\n          return panels\n        },\n      )\n\n      return {\n        clipId: clip.id,\n        planPanels,\n      }\n    },\n  )\n\n  for (const result of phase1Results) {\n    phase1PanelsByClipId.set(result.clipId, result.planPanels)\n  }\n\n  const clipPanels = await mapWithConcurrency(\n    clips,\n    concurrency,\n    async (clip, index): Promise<ClipStoryboardPanels> => {\n      const clipIndex = index + 1\n      const clipCharacters = parseClipCharacters(clip.characters)\n      const clipLocation = clip.location || null\n      const planPanels = phase1PanelsByClipId.get(clip.id) || []\n      if (planPanels.length === 0) {\n        throw new Error(`Missing phase1 result for clip ${formatClipId(clip)}`)\n      }\n\n      const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters || [], clipCharacters)\n      const filteredLocationsDescription = getFilteredLocationsDescription(\n        novelPromotionData.locations || [],\n        clipLocation,\n      )\n\n      const phase2Meta = withStepMeta(\n        `clip_${clip.id}_phase2_cinematography`,\n        'progress.streamStep.cinematographyRules',\n        clips.length + index * 3 + 1,\n        totalStepCount,\n        {\n          dependsOn: [`clip_${clip.id}_phase1`],\n          groupId: `clip_${clip.id}`,\n          parallelKey: 'phase2',\n          retryable: true,\n        },\n      )\n      const phase2ActingMeta = withStepMeta(\n        `clip_${clip.id}_phase2_acting`,\n        'progress.streamStep.actingDirection',\n        clips.length + index * 3 + 2,\n        totalStepCount,\n        {\n          dependsOn: [`clip_${clip.id}_phase1`],\n          groupId: `clip_${clip.id}`,\n          parallelKey: 'phase2',\n          retryable: true,\n        },\n      )\n      const phase3Meta = withStepMeta(\n        `clip_${clip.id}_phase3_detail`,\n        'progress.streamStep.storyboardDetailRefine',\n        clips.length + index * 3 + 3,\n        totalStepCount,\n        {\n          dependsOn: [\n            `clip_${clip.id}_phase2_cinematography`,\n            `clip_${clip.id}_phase2_acting`,\n          ],\n          groupId: `clip_${clip.id}`,\n          parallelKey: 'phase3',\n          retryable: true,\n        },\n      )\n\n      const phase2Prompt = promptTemplates.phase2CinematographyTemplate\n        .replace('{panels_json}', JSON.stringify(planPanels, null, 2))\n        .replace(/\\{panel_count\\}/g, String(planPanels.length))\n        .replace('{locations_description}', filteredLocationsDescription)\n        .replace('{characters_info}', filteredFullDescription)\n\n      const phase2ActingPrompt = promptTemplates.phase2ActingTemplate\n        .replace('{panels_json}', JSON.stringify(planPanels, null, 2))\n        .replace(/\\{panel_count\\}/g, String(planPanels.length))\n        .replace('{characters_info}', filteredFullDescription)\n\n      const phase3Prompt = promptTemplates.phase3DetailTemplate\n        .replace('{panels_json}', JSON.stringify(planPanels, null, 2))\n        .replace('{characters_age_gender}', filteredFullDescription)\n        .replace('{locations_description}', filteredLocationsDescription)\n\n      const [\n        { parsed: photographyRules },\n        { parsed: actingDirections },\n      ] = await Promise.all([\n        runStepWithRetry(\n          runStep, phase2Meta, phase2Prompt, 'storyboard_phase2_cinematography', 2400,\n          (text) => parseJsonArray<PhotographyRule>(text, `phase2:${formatClipId(clip)}`),\n        ),\n        runStepWithRetry(\n          runStep, phase2ActingMeta, phase2ActingPrompt, 'storyboard_phase2_acting', 2400,\n          (text) => parseJsonArray<ActingDirection>(text, `phase2-acting:${formatClipId(clip)}`),\n        ),\n      ])\n      const { parsed: filteredPhase3Panels } = await runStepWithRetry(\n        runStep, phase3Meta, phase3Prompt, 'storyboard_phase3_detail', 2600,\n        (text) => {\n          const panels = parseJsonArray<StoryboardPanel>(text, `phase3:${formatClipId(clip)}`)\n          const filtered = panels.filter(\n            (panel) => panel.description && panel.description !== '无' && panel.location !== '无',\n          )\n          if (filtered.length === 0) {\n            throw new Error(`Phase 3 returned empty valid panels for clip ${formatClipId(clip)}`)\n          }\n          return filtered\n        },\n      )\n\n      phase2CinematographyByClipId.set(clip.id, photographyRules)\n      phase2ActingByClipId.set(clip.id, actingDirections)\n      phase3PanelsByClipId.set(clip.id, filteredPhase3Panels)\n\n      return {\n        clipId: clip.id,\n        clipIndex,\n        finalPanels: mergePanelsWithRules({\n          finalPanels: filteredPhase3Panels,\n          photographyRules,\n          actingDirections,\n        }),\n      }\n    },\n  )\n\n  const totalPanelCount = clipPanels.reduce((sum, item) => sum + item.finalPanels.length, 0)\n\n  const mapToRecord = <T>(source: Map<string, T>): Record<string, T> => {\n    const output: Record<string, T> = {}\n    for (const [key, value] of source.entries()) {\n      output[key] = value\n    }\n    return output\n  }\n\n  return {\n    clipPanels,\n    phase1PanelsByClipId: mapToRecord(phase1PanelsByClipId),\n    phase2CinematographyByClipId: mapToRecord(phase2CinematographyByClipId),\n    phase2ActingByClipId: mapToRecord(phase2ActingByClipId),\n    phase3PanelsByClipId: mapToRecord(phase3PanelsByClipId),\n    summary: {\n      clipCount: clips.length,\n      totalPanelCount,\n      totalStepCount,\n    },\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/contracts/video-stage-contract.ts",
    "content": "import type { VideoStageShellProps } from '../video-stage-runtime-core'\n\nexport type VideoStageContract = VideoStageShellProps\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/contracts/voice-stage-contract.ts",
    "content": "import type { VoiceStageShellProps } from '../voice-stage-runtime/types'\n\nexport type VoiceStageContract = VoiceStageShellProps\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/immediate-video-submission.ts",
    "content": "const VIDEO_SUBMISSION_TIMEOUT_MS = 90_000\n\nexport interface VideoSubmissionPanelSnapshot {\n  panelId?: string\n  storyboardId: string\n  panelIndex: number\n  videoUrl?: string | null\n  videoErrorMessage?: string | null\n  videoTaskRunning?: boolean | null\n}\n\nexport interface VideoSubmissionBaseline {\n  signature: string\n  startedAt: number\n}\n\nexport function buildVideoSubmissionKey(panel: Pick<VideoSubmissionPanelSnapshot, 'panelId' | 'storyboardId' | 'panelIndex'>): string {\n  return panel.panelId?.trim() || `${panel.storyboardId}:${panel.panelIndex}`\n}\n\nexport function createVideoSubmissionBaseline(panel: VideoSubmissionPanelSnapshot): VideoSubmissionBaseline {\n  return {\n    signature: JSON.stringify({\n      videoUrl: panel.videoUrl || null,\n      videoErrorMessage: panel.videoErrorMessage || null,\n    }),\n    startedAt: Date.now(),\n  }\n}\n\nexport function shouldResolveVideoSubmissionLock(\n  panel: VideoSubmissionPanelSnapshot | undefined,\n  baseline: VideoSubmissionBaseline | undefined,\n  now: number,\n): boolean {\n  if (!panel || !baseline) return true\n  if (now - baseline.startedAt > VIDEO_SUBMISSION_TIMEOUT_MS) return true\n  if (panel.videoTaskRunning) return true\n\n  const current = createVideoSubmissionBaseline(panel)\n  return current.signature !== baseline.signature\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/task-targets.ts",
    "content": "'use client'\n\nimport type { Storyboard } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\nimport type { VoiceLine } from './types'\n\ninterface VideoTaskTarget {\n  key: string\n  targetType: string\n  targetId: string\n  types: string[]\n  resource: 'video'\n  hasOutput: boolean\n}\n\ninterface VoiceTaskTarget {\n  key: string\n  targetType: string\n  targetId: string\n  types: string[]\n  resource: 'audio'\n  hasOutput: boolean\n}\n\nexport function buildPanelVideoTargets(storyboards: Storyboard[]): VideoTaskTarget[] {\n  const targets: VideoTaskTarget[] = []\n  for (const storyboard of storyboards) {\n    for (const panel of storyboard.panels || []) {\n      if (!panel.id) continue\n      targets.push({\n        key: `panel-video:${panel.id}`,\n        targetType: 'NovelPromotionPanel',\n        targetId: panel.id,\n        types: ['video_panel'],\n        resource: 'video',\n        hasOutput: !!panel.videoUrl,\n      })\n    }\n  }\n  return targets\n}\n\nexport function buildPanelLipTargets(storyboards: Storyboard[]): VideoTaskTarget[] {\n  const targets: VideoTaskTarget[] = []\n  for (const storyboard of storyboards) {\n    for (const panel of storyboard.panels || []) {\n      if (!panel.id) continue\n      targets.push({\n        key: `panel-lip:${panel.id}`,\n        targetType: 'NovelPromotionPanel',\n        targetId: panel.id,\n        types: ['lip_sync'],\n        resource: 'video',\n        hasOutput: !!panel.lipSyncVideoUrl,\n      })\n    }\n  }\n  return targets\n}\n\nexport function buildVoiceLineTargets(voiceLines: VoiceLine[]): VoiceTaskTarget[] {\n  return voiceLines\n    .filter((line) => line.matchedStoryboardId && line.matchedPanelIndex !== null)\n    .map((line) => ({\n      key: `line:${line.id}`,\n      targetType: 'NovelPromotionVoiceLine',\n      targetId: line.id,\n      types: ['voice_line'],\n      resource: 'audio' as const,\n      hasOutput: !!line.audioUrl,\n    }))\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/types.ts",
    "content": "'use client'\n\nimport type {\n  BatchVideoGenerationParams,\n  Clip,\n  FirstLastFrameParams,\n  VideoGenerationOptions,\n  Storyboard,\n} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\nimport type { CapabilitySelections, ModelCapabilities } from '@/lib/model-config-contract'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\n\nexport interface VoiceLine {\n  id: string\n  lineIndex: number\n  speaker: string\n  content: string\n  audioUrl: string | null\n  matchedStoryboardId: string | null\n  matchedPanelIndex: number | null\n}\n\nexport interface VideoModelOption {\n  value: string\n  label: string\n  provider?: string\n  providerName?: string\n  capabilities?: ModelCapabilities\n  videoPricingTiers?: VideoPricingTier[]\n}\n\nexport interface EpisodeVideoUrlsResponse {\n  videos?: Array<{ index: number; fileName: string; videoUrl: string }>\n  projectName?: string\n}\n\nexport interface VideoStageShellProps {\n  projectId: string\n  episodeId: string\n  storyboards: Storyboard[]\n  clips: Clip[]\n  defaultVideoModel: string\n  capabilityOverrides: CapabilitySelections\n  videoRatio?: string\n  userVideoModels?: VideoModelOption[]\n  onGenerateVideo: (\n    storyboardId: string,\n    panelIndex: number,\n    videoModel?: string,\n    firstLastFrame?: FirstLastFrameParams,\n    generationOptions?: VideoGenerationOptions,\n    panelId?: string,\n  ) => Promise<void>\n  onGenerateAllVideos: (options?: BatchVideoGenerationParams) => Promise<void>\n  onBack: () => void\n  onUpdateVideoPrompt: (\n    storyboardId: string,\n    panelIndex: number,\n    value: string,\n    field?: 'videoPrompt' | 'firstLastFramePrompt',\n  ) => Promise<void>\n  onUpdatePanelVideoModel: (storyboardId: string, panelIndex: number, model: string) => Promise<void>\n  onOpenAssetLibraryForCharacter?: (characterId?: string | null) => void\n  onEnterEditor?: () => void\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/useVideoDownloadAll.ts",
    "content": "'use client'\n\nimport { useCallback, useState } from 'react'\nimport { logError as _ulogError, logInfo as _ulogInfo } from '@/lib/logging/core'\nimport type { VideoPanel } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\nimport type { EpisodeVideoUrlsResponse } from './types'\nimport { getErrorMessage } from './utils'\n\ninterface MutationLike<TInput = unknown, TOutput = unknown> {\n  mutateAsync: (input: TInput) => Promise<TOutput>\n}\n\ninterface UseVideoDownloadAllParams {\n  episodeId: string\n  t: (key: string) => string\n  allPanels: VideoPanel[]\n  panelVideoPreference: Map<string, boolean>\n  listEpisodeVideoUrlsMutation: MutationLike<{\n    episodeId: string\n    panelPreferences: Record<string, boolean>\n  }>\n  downloadRemoteBlobMutation: MutationLike<string, Blob>\n}\n\nexport function useVideoDownloadAll({\n  episodeId,\n  t,\n  allPanels,\n  panelVideoPreference,\n  listEpisodeVideoUrlsMutation,\n  downloadRemoteBlobMutation,\n}: UseVideoDownloadAllParams) {\n  const [isDownloading, setIsDownloading] = useState(false)\n  const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null)\n\n  const videosWithUrl = allPanels.filter((panel) => panel.videoUrl).length\n\n  const handleDownloadAllVideos = useCallback(async () => {\n    if (videosWithUrl === 0) return\n    setIsDownloading(true)\n    setDownloadProgress(null)\n\n    try {\n      const JSZip = (await import('jszip')).default\n      const panelPreferences: Record<string, boolean> = {}\n      allPanels.forEach((panel) => {\n        const panelKey = `${panel.storyboardId}-${panel.panelIndex}`\n        panelPreferences[panelKey] = panelVideoPreference.get(panelKey) ?? true\n      })\n\n      _ulogInfo('[下载视频] 获取视频URL列表...')\n      const data = await listEpisodeVideoUrlsMutation.mutateAsync({\n        episodeId,\n        panelPreferences,\n      })\n      const result = (data || {}) as EpisodeVideoUrlsResponse\n      const videos = result.videos || []\n      const projectName = result.projectName || 'videos'\n\n      if (videos.length === 0) {\n        throw new Error(t('stage.noVideos'))\n      }\n\n      _ulogInfo(`[下载视频] 共 ${videos.length} 个视频，开始下载...`)\n      setDownloadProgress({ current: 0, total: videos.length })\n\n      const zip = new JSZip()\n      for (let index = 0; index < videos.length; index += 1) {\n        const video = videos[index]\n        _ulogInfo(`[下载视频] 下载 ${index + 1}/${videos.length}: ${video.fileName}`)\n        setDownloadProgress({ current: index + 1, total: videos.length })\n\n        try {\n          const blob = await downloadRemoteBlobMutation.mutateAsync(video.videoUrl)\n          zip.file(video.fileName, blob)\n        } catch (error) {\n          _ulogError(`[下载视频] 下载失败: ${video.fileName}`, error)\n        }\n      }\n\n      _ulogInfo('[下载视频] 生成 ZIP 文件...')\n      const zipBlob = await zip.generateAsync({ type: 'blob' })\n      const url = window.URL.createObjectURL(zipBlob)\n      const anchor = document.createElement('a')\n      anchor.href = url\n      anchor.download = `${projectName}_videos.zip`\n      document.body.appendChild(anchor)\n      anchor.click()\n      window.URL.revokeObjectURL(url)\n      document.body.removeChild(anchor)\n      _ulogInfo('[下载视频] 完成!')\n    } catch (error: unknown) {\n      _ulogError('[下载视频] 错误:', error)\n      alert(`${t('stage.downloadFailed')}: ${getErrorMessage(error) || t('errors.unknownError')}`)\n    } finally {\n      setIsDownloading(false)\n      setDownloadProgress(null)\n    }\n  }, [\n    allPanels,\n    downloadRemoteBlobMutation,\n    episodeId,\n    listEpisodeVideoUrlsMutation,\n    panelVideoPreference,\n    t,\n    videosWithUrl,\n  ])\n\n  return {\n    isDownloading,\n    downloadProgress,\n    videosWithUrl,\n    handleDownloadAllVideos,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/useVideoFirstLastFrameFlow.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type {\n  VideoGenerationOptions,\n  VideoModelOption,\n  VideoPanel,\n} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\nimport {\n  normalizeVideoGenerationSelections,\n  resolveEffectiveVideoCapabilityDefinitions,\n  resolveEffectiveVideoCapabilityFields,\n} from '@/lib/model-capabilities/video-effective'\nimport { supportsFirstLastFrame } from '@/lib/model-capabilities/video-model-options'\nimport { projectVideoPricingTiersByFixedSelections } from '@/lib/model-pricing/video-tier'\n\ninterface FirstLastFrameCapabilityField {\n  field: string\n  label: string\n  options: VideoGenerationOptionValue[]\n  disabledOptions?: VideoGenerationOptionValue[]\n  value: VideoGenerationOptionValue | undefined\n}\n\ntype VideoGenerationOptionValue = string | number | boolean\n\nfunction parseByOptionType(\n  input: string,\n  sample: VideoGenerationOptionValue,\n): VideoGenerationOptionValue {\n  if (typeof sample === 'number') return Number(input)\n  if (typeof sample === 'boolean') return input === 'true'\n  return input\n}\n\nfunction toFieldLabel(field: string): string {\n  return field.replace(/([A-Z])/g, ' $1').replace(/^./, (char) => char.toUpperCase())\n}\n\ninterface UseVideoFirstLastFrameFlowParams {\n  allPanels: VideoPanel[]\n  linkedPanels: Map<string, boolean>\n  videoModelOptions: VideoModelOption[]\n  onGenerateVideo: (\n    storyboardId: string,\n    panelIndex: number,\n    videoModel?: string,\n    firstLastFrame?: {\n      lastFrameStoryboardId: string\n      lastFramePanelIndex: number\n      flModel: string\n      customPrompt?: string\n    },\n    generationOptions?: VideoGenerationOptions,\n    panelId?: string,\n  ) => Promise<void>\n  t: (key: string) => string\n}\n\nexport function useVideoFirstLastFrameFlow({\n  allPanels,\n  linkedPanels,\n  videoModelOptions,\n  onGenerateVideo,\n  t,\n}: UseVideoFirstLastFrameFlowParams) {\n  const firstLastFrameModelOptions = useMemo(\n    () => videoModelOptions.filter((option) => supportsFirstLastFrame(option)),\n    [videoModelOptions],\n  )\n  const [flModel, setFlModel] = useState(firstLastFrameModelOptions[0]?.value || '')\n  const [flGenerationOptions, setFlGenerationOptions] = useState<VideoGenerationOptions>({})\n  const [flCustomPrompts, setFlCustomPrompts] = useState<Map<string, string>>(new Map())\n\n  useEffect(() => {\n    setFlCustomPrompts((previous) => {\n      const next = new Map(previous)\n      const existingPanelKeys = new Set<string>()\n\n      for (const panel of allPanels) {\n        const panelKey = `${panel.storyboardId}-${panel.panelIndex}`\n        existingPanelKeys.add(panelKey)\n        if (!next.has(panelKey)) {\n          next.set(panelKey, panel.firstLastFramePrompt || '')\n        }\n      }\n\n      for (const key of next.keys()) {\n        if (!existingPanelKeys.has(key)) next.delete(key)\n      }\n\n      return next\n    })\n  }, [allPanels])\n\n  useEffect(() => {\n    if (!flModel && firstLastFrameModelOptions.length > 0) {\n      setFlModel(firstLastFrameModelOptions[0].value)\n      return\n    }\n    if (flModel && !firstLastFrameModelOptions.some((option) => option.value === flModel)) {\n      setFlModel(firstLastFrameModelOptions[0]?.value || '')\n    }\n  }, [firstLastFrameModelOptions, flModel])\n\n  const selectedFlModelOption = useMemo(\n    () => firstLastFrameModelOptions.find((option) => option.value === flModel),\n    [firstLastFrameModelOptions, flModel],\n  )\n  const flPricingTiers = useMemo(\n    () => projectVideoPricingTiersByFixedSelections({\n      tiers: selectedFlModelOption?.videoPricingTiers ?? [],\n      fixedSelections: {\n        generationMode: 'firstlastframe',\n      },\n    }),\n    [selectedFlModelOption?.videoPricingTiers],\n  )\n  const flCapabilityDefinitions = useMemo(\n    () => resolveEffectiveVideoCapabilityDefinitions({\n      videoCapabilities: selectedFlModelOption?.capabilities?.video,\n      pricingTiers: flPricingTiers,\n    }),\n    [flPricingTiers, selectedFlModelOption?.capabilities?.video],\n  )\n\n  useEffect(() => {\n    setFlGenerationOptions((previous) => {\n      return normalizeVideoGenerationSelections({\n        definitions: flCapabilityDefinitions,\n        pricingTiers: flPricingTiers,\n        selection: previous,\n      })\n    })\n  }, [flCapabilityDefinitions, flPricingTiers])\n\n  const flEffectiveCapabilityFields = useMemo(\n    () => resolveEffectiveVideoCapabilityFields({\n      definitions: flCapabilityDefinitions,\n      pricingTiers: flPricingTiers,\n      selection: flGenerationOptions,\n    }),\n    [flCapabilityDefinitions, flGenerationOptions, flPricingTiers],\n  )\n  const flEffectiveFieldMap = useMemo(\n    () => new Map(flEffectiveCapabilityFields.map((field) => [field.field, field])),\n    [flEffectiveCapabilityFields],\n  )\n  const flDefinitionFieldMap = useMemo(\n    () => new Map(flCapabilityDefinitions.map((definition) => [definition.field, definition])),\n    [flCapabilityDefinitions],\n  )\n\n  const flCapabilityFields: FirstLastFrameCapabilityField[] = useMemo(() => {\n    return flCapabilityDefinitions.map((definition) => {\n      const effectiveField = flEffectiveFieldMap.get(definition.field)\n      const enabledOptions = effectiveField?.options ?? []\n      return {\n        field: definition.field,\n        label: toFieldLabel(definition.field),\n        options: definition.options as VideoGenerationOptionValue[],\n        disabledOptions: (definition.options as VideoGenerationOptionValue[])\n          .filter((option) => !enabledOptions.includes(option)),\n        value: effectiveField?.value as VideoGenerationOptionValue | undefined,\n      }\n    })\n  }, [flCapabilityDefinitions, flEffectiveFieldMap])\n\n  const flMissingCapabilityFields = useMemo(\n    () => flEffectiveCapabilityFields\n      .filter((field) => field.options.length === 0 || field.value === undefined)\n      .map((field) => field.field),\n    [flEffectiveCapabilityFields],\n  )\n\n  const setFlCapabilityValue = useCallback((field: string, rawValue: string) => {\n    const definitionField = flDefinitionFieldMap.get(field)\n    if (!definitionField || definitionField.options.length === 0) return\n    const parsedValue = parseByOptionType(rawValue, definitionField.options[0])\n    if (!definitionField.options.includes(parsedValue)) return\n    setFlGenerationOptions((previous) => ({\n      ...normalizeVideoGenerationSelections({\n        definitions: flCapabilityDefinitions,\n        pricingTiers: flPricingTiers,\n        selection: {\n          ...previous,\n          [field]: parsedValue,\n        },\n        pinnedFields: [field],\n      }),\n    }))\n  }, [flCapabilityDefinitions, flDefinitionFieldMap, flPricingTiers])\n\n  const setFlCustomPrompt = useCallback((panelKey: string, value: string) => {\n    setFlCustomPrompts((previous) => new Map(previous).set(panelKey, value))\n  }, [])\n\n  const resetFlCustomPrompt = useCallback((panelKey: string) => {\n    setFlCustomPrompts((previous) => {\n      const next = new Map(previous)\n      next.delete(panelKey)\n      return next\n    })\n  }, [])\n\n  const handleGenerateFirstLastFrame = useCallback(async (\n    firstStoryboardId: string,\n    firstPanelIndex: number,\n    lastStoryboardId: string,\n    lastPanelIndex: number,\n    panelKey: string,\n    generationOptions?: VideoGenerationOptions,\n    firstPanelId?: string,\n  ) => {\n    const persistedCustomPrompt = allPanels.find(\n      (panel) =>\n        panel.storyboardId === firstStoryboardId\n        && panel.panelIndex === firstPanelIndex,\n    )?.firstLastFramePrompt\n    const customPrompt = flCustomPrompts.get(panelKey) ?? persistedCustomPrompt\n    await onGenerateVideo(firstStoryboardId, firstPanelIndex, flModel, {\n      lastFrameStoryboardId: lastStoryboardId,\n      lastFramePanelIndex: lastPanelIndex,\n      flModel,\n      customPrompt,\n    }, generationOptions ?? flGenerationOptions, firstPanelId)\n  }, [allPanels, flCustomPrompts, flGenerationOptions, flModel, onGenerateVideo])\n\n  const getDefaultFlPrompt = useCallback((firstPrompt?: string, lastPrompt?: string): string => {\n    const first = firstPrompt || ''\n    const last = lastPrompt || ''\n    if (last) {\n      return `${first} ${t('firstLastFrame.thenTransitionTo')}: ${last}`\n    }\n    return first\n  }, [t])\n\n  const getNextPanel = useCallback((currentIndex: number): VideoPanel | null => {\n    if (currentIndex >= allPanels.length - 1) return null\n    return allPanels[currentIndex + 1]\n  }, [allPanels])\n\n  const isLinkedAsLastFrame = useCallback((currentIndex: number): boolean => {\n    if (currentIndex === 0) return false\n    const previousPanel = allPanels[currentIndex - 1]\n    const previousKey = `${previousPanel.storyboardId}-${previousPanel.panelIndex}`\n    return linkedPanels.get(previousKey) || false\n  }, [allPanels, linkedPanels])\n\n  return {\n    flModel,\n    flModelOptions: firstLastFrameModelOptions,\n    flGenerationOptions,\n    flCapabilityFields,\n    flMissingCapabilityFields,\n    flCustomPrompts,\n    setFlModel,\n    setFlCapabilityValue,\n    setFlCustomPrompt,\n    resetFlCustomPrompt,\n    handleGenerateFirstLastFrame,\n    getDefaultFlPrompt,\n    getNextPanel,\n    isLinkedAsLastFrame,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/useVideoPanelLinking.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport type { VideoPanel } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\n\ninterface MutationLike<TInput = unknown> {\n  mutateAsync: (input: TInput) => Promise<unknown>\n}\n\ninterface UseVideoPanelLinkingParams {\n  allPanels: VideoPanel[]\n  updatePanelLinkMutation: MutationLike<{\n    storyboardId: string\n    panelIndex: number\n    linked: boolean\n  }>\n}\n\nexport function useVideoPanelLinking({\n  allPanels,\n  updatePanelLinkMutation,\n}: UseVideoPanelLinkingParams) {\n  const [linkedOverrides, setLinkedOverrides] = useState<Map<string, boolean>>(new Map())\n\n  const baseLinkedPanels = useMemo(() => {\n    const map = new Map<string, boolean>()\n    allPanels.forEach((panel) => {\n      if (panel.linkedToNextPanel) {\n        map.set(`${panel.storyboardId}-${panel.panelIndex}`, true)\n      }\n    })\n    return map\n  }, [allPanels])\n\n  const panelKeys = useMemo(() => {\n    const keys = new Set<string>()\n    allPanels.forEach((panel) => {\n      keys.add(`${panel.storyboardId}-${panel.panelIndex}`)\n    })\n    return keys\n  }, [allPanels])\n\n  const linkedPanels = useMemo(() => {\n    const merged = new Map(baseLinkedPanels)\n    linkedOverrides.forEach((value, key) => {\n      if (value) merged.set(key, true)\n      else merged.delete(key)\n    })\n    return merged\n  }, [baseLinkedPanels, linkedOverrides])\n\n  useEffect(() => {\n    setLinkedOverrides((previous) => {\n      if (previous.size === 0) return previous\n      const next = new Map(previous)\n      let changed = false\n      previous.forEach((value, key) => {\n        if (!panelKeys.has(key)) {\n          next.delete(key)\n          changed = true\n          return\n        }\n        const baseValue = baseLinkedPanels.get(key) === true\n        if (baseValue === value) {\n          next.delete(key)\n          changed = true\n        }\n      })\n      return changed ? next : previous\n    })\n  }, [baseLinkedPanels, panelKeys])\n\n  const applyOverride = useCallback((key: string, value: boolean) => {\n    setLinkedOverrides((previous) => {\n      const next = new Map(previous)\n      const baseValue = baseLinkedPanels.get(key) === true\n      if (baseValue === value) next.delete(key)\n      else next.set(key, value)\n      return next\n    })\n  }, [baseLinkedPanels])\n\n  const handleToggleLink = useCallback(async (panelKey: string, storyboardId: string, panelIndex: number) => {\n    const currentLinked = linkedPanels.get(panelKey) || false\n    const newLinked = !currentLinked\n\n    applyOverride(panelKey, newLinked)\n\n    try {\n      await updatePanelLinkMutation.mutateAsync({\n        storyboardId,\n        panelIndex,\n        linked: newLinked,\n      })\n    } catch (error) {\n      _ulogError('Failed to save link state:', error)\n      applyOverride(panelKey, currentLinked)\n    }\n  }, [applyOverride, linkedPanels, updatePanelLinkMutation])\n\n  return {\n    linkedPanels,\n    handleToggleLink,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/useVideoPanelViewport.ts",
    "content": "'use client'\n\nimport { useCallback, useRef, useState } from 'react'\n\nexport function useVideoPanelViewport() {\n  const [highlightedPanelKey, setHighlightedPanelKey] = useState<string | null>(null)\n  const panelRefs = useRef<Map<string, HTMLDivElement>>(new Map())\n\n  const scrollToPanel = useCallback((storyboardId: string, panelIndex: number) => {\n    const panelKey = `${storyboardId}-${panelIndex}`\n    const panelElement = panelRefs.current.get(panelKey)\n    if (!panelElement) return\n\n    const headerOffset = 140\n    const targetY = Math.max(0, window.scrollY + panelElement.getBoundingClientRect().top - headerOffset)\n    window.scrollTo({ top: targetY, behavior: 'smooth' })\n    setHighlightedPanelKey(panelKey)\n    setTimeout(() => setHighlightedPanelKey(null), 3000)\n  }, [])\n\n  const locateVoiceLinePanel = useCallback((storyboardId: string, panelIndex: number) => {\n    scrollToPanel(storyboardId, panelIndex)\n  }, [scrollToPanel])\n\n  return {\n    panelRefs,\n    highlightedPanelKey,\n    locateVoiceLinePanel,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/useVideoPanelsProjection.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport type {\n  Clip,\n  Storyboard,\n  VideoPanel,\n} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\n\ninterface TaskStateLike {\n  phase?: string | null\n  lastError?: { code?: string; message?: string } | null\n}\n\ninterface TaskPresentationLike {\n  getTaskState: (key: string) => TaskStateLike | null\n}\n\ninterface UseVideoPanelsProjectionParams {\n  storyboards: Storyboard[]\n  clips: Clip[]\n  panelVideoStates: TaskPresentationLike\n  panelLipStates: TaskPresentationLike\n}\n\nexport function useVideoPanelsProjection({\n  storyboards,\n  clips,\n  panelVideoStates,\n  panelLipStates,\n}: UseVideoPanelsProjectionParams) {\n  const sortedStoryboards = useMemo(() => {\n    return [...storyboards].sort((left, right) => {\n      const leftIndex = clips.findIndex((clip) => clip.id === left.clipId)\n      const rightIndex = clips.findIndex((clip) => clip.id === right.clipId)\n      return leftIndex - rightIndex\n    })\n  }, [clips, storyboards])\n\n  const allPanels = useMemo<VideoPanel[]>(() => {\n    const panels: VideoPanel[] = []\n    sortedStoryboards.forEach((storyboard) => {\n      const storyboardPanels = storyboard.panels || []\n      storyboardPanels.forEach((panel, index) => {\n        const actualPanelIndex = panel.panelIndex ?? index\n        let charactersArray: string[] = []\n        if (panel.characters) {\n          try {\n            const parsed = typeof panel.characters === 'string' ? JSON.parse(panel.characters) : panel.characters\n            charactersArray = Array.isArray(parsed) ? parsed : []\n          } catch {\n            charactersArray = []\n          }\n        }\n\n        const panelId = panel.id\n        const panelVideoState = panelId ? panelVideoStates.getTaskState(`panel-video:${panelId}`) : null\n        const panelLipState = panelId ? panelLipStates.getTaskState(`panel-lip:${panelId}`) : null\n\n        panels.push({\n          panelId,\n          storyboardId: storyboard.id,\n          panelIndex: actualPanelIndex,\n          textPanel: {\n            panel_number: panel.panelNumber || actualPanelIndex + 1,\n            shot_type: panel.shotType || '',\n            camera_move: panel.cameraMove || '',\n            description: panel.description || '',\n            characters: charactersArray,\n            location: panel.location || '',\n            text_segment: panel.srtSegment || '',\n            duration: panel.duration || undefined,\n            imagePrompt: panel.imagePrompt || undefined,\n            video_prompt: panel.videoPrompt || undefined,\n            videoModel: panel.videoModel || undefined,\n          },\n          imageUrl: panel.imageUrl || undefined,\n          firstLastFramePrompt: panel.firstLastFramePrompt || undefined,\n          videoUrl: panel.videoUrl || undefined,\n          videoGenerationMode: panel.videoGenerationMode || undefined,\n          videoTaskRunning: panelVideoState?.phase === 'queued' || panelVideoState?.phase === 'processing',\n          videoErrorCode:\n            panelVideoState?.phase === 'failed'\n              ? panelVideoState.lastError?.code || panel.videoErrorCode || undefined\n              : panel.videoErrorCode || undefined,\n          videoErrorMessage:\n            panelVideoState?.phase === 'failed'\n              ? panelVideoState.lastError?.message || panel.videoErrorMessage || undefined\n              : panel.videoErrorMessage || undefined,\n          videoModel: panel.videoModel || undefined,\n          linkedToNextPanel: panel.linkedToNextPanel || false,\n          lipSyncVideoUrl: panel.lipSyncVideoUrl || undefined,\n          lipSyncTaskRunning: panelLipState?.phase === 'queued' || panelLipState?.phase === 'processing',\n          lipSyncErrorCode:\n            panelLipState?.phase === 'failed'\n              ? panelLipState.lastError?.code || panel.lipSyncErrorCode || undefined\n              : panel.lipSyncErrorCode || undefined,\n          lipSyncErrorMessage:\n            panelLipState?.phase === 'failed'\n              ? panelLipState.lastError?.message || panel.lipSyncErrorMessage || undefined\n              : panel.lipSyncErrorMessage || undefined,\n        })\n      })\n    })\n    return panels\n  }, [panelLipStates, panelVideoStates, sortedStoryboards])\n\n  return {\n    sortedStoryboards,\n    allPanels,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/useVideoPromptState.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useState } from 'react'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport type { VideoPanel } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\n\nexport type PromptField = 'videoPrompt' | 'firstLastFramePrompt'\n\ninterface UseVideoPromptStateParams {\n  allPanels: VideoPanel[]\n  onUpdateVideoPrompt: (\n    storyboardId: string,\n    panelIndex: number,\n    value: string,\n    field?: PromptField,\n  ) => Promise<void>\n}\n\nfunction buildPromptStateKey(panelKey: string, field: PromptField): string {\n  return `${field}:${panelKey}`\n}\n\nexport function useVideoPromptState({\n  allPanels,\n  onUpdateVideoPrompt,\n}: UseVideoPromptStateParams) {\n  const [panelPrompts, setPanelPrompts] = useState<Map<string, string>>(new Map())\n  const [savingPrompts, setSavingPrompts] = useState<Set<string>>(new Set())\n  const [dirtyPrompts, setDirtyPrompts] = useState<Set<string>>(new Set())\n\n  useEffect(() => {\n    const panelKeySet = new Set<string>()\n    for (const panel of allPanels) {\n      panelKeySet.add(`${panel.storyboardId}-${panel.panelIndex}`)\n    }\n\n    setPanelPrompts((prev) => {\n      const next = new Map(prev)\n      for (const panel of allPanels) {\n        const panelKey = `${panel.storyboardId}-${panel.panelIndex}`\n        const promptEntries: Array<[PromptField, string]> = [\n          ['videoPrompt', panel.textPanel?.video_prompt || ''],\n          ['firstLastFramePrompt', panel.firstLastFramePrompt || ''],\n        ]\n        for (const [field, value] of promptEntries) {\n          const stateKey = buildPromptStateKey(panelKey, field)\n          if (dirtyPrompts.has(stateKey)) continue\n          next.set(stateKey, value)\n        }\n      }\n      for (const key of next.keys()) {\n        const separatorIndex = key.indexOf(':')\n        const panelKey = separatorIndex >= 0 ? key.slice(separatorIndex + 1) : key\n        if (!panelKeySet.has(panelKey)) {\n          next.delete(key)\n        }\n      }\n      return next\n    })\n  }, [allPanels, dirtyPrompts])\n\n  useEffect(() => {\n    setDirtyPrompts((prev) => {\n      if (prev.size === 0) return prev\n      const next = new Set<string>()\n      for (const key of prev) {\n        if (panelPrompts.has(key)) next.add(key)\n      }\n      return next.size === prev.size ? prev : next\n    })\n  }, [panelPrompts])\n\n  useEffect(() => {\n    setDirtyPrompts((prev) => {\n      if (prev.size === 0) return prev\n      const externalPromptMap = new Map<string, string>()\n      for (const panel of allPanels) {\n        const panelKey = `${panel.storyboardId}-${panel.panelIndex}`\n        externalPromptMap.set(\n          buildPromptStateKey(panelKey, 'videoPrompt'),\n          panel.textPanel?.video_prompt || '',\n        )\n        externalPromptMap.set(\n          buildPromptStateKey(panelKey, 'firstLastFramePrompt'),\n          panel.firstLastFramePrompt || '',\n        )\n      }\n      const next = new Set(prev)\n      for (const key of prev) {\n        const externalPrompt = externalPromptMap.get(key)\n        const localPrompt = panelPrompts.get(key)\n        if (externalPrompt === undefined || localPrompt === undefined || externalPrompt === localPrompt) {\n          next.delete(key)\n        }\n      }\n      return next.size === prev.size ? prev : next\n    })\n  }, [allPanels, panelPrompts])\n\n  const getLocalPrompt = useCallback((\n    panelKey: string,\n    externalPrompt?: string,\n    field: PromptField = 'videoPrompt',\n  ): string => {\n    const stateKey = buildPromptStateKey(panelKey, field)\n    if (panelPrompts.has(stateKey)) {\n      return panelPrompts.get(stateKey) || ''\n    }\n    return externalPrompt || ''\n  }, [panelPrompts])\n\n  const updateLocalPrompt = useCallback((\n    panelKey: string,\n    value: string,\n    field: PromptField = 'videoPrompt',\n  ) => {\n    const stateKey = buildPromptStateKey(panelKey, field)\n    setPanelPrompts((prev) => {\n      const next = new Map(prev)\n      next.set(stateKey, value)\n      return next\n    })\n    setDirtyPrompts((prev) => new Set(prev).add(stateKey))\n  }, [])\n\n  const savePrompt = useCallback(async (\n    storyboardId: string,\n    panelIndex: number,\n    panelKey: string,\n    value: string,\n    field: PromptField = 'videoPrompt',\n  ) => {\n    const stateKey = buildPromptStateKey(panelKey, field)\n    setSavingPrompts((prev) => new Set(prev).add(stateKey))\n    try {\n      await onUpdateVideoPrompt(storyboardId, panelIndex, value, field)\n    } catch (error) {\n      _ulogError('保存视频提示词失败:', error)\n    } finally {\n      setSavingPrompts((prev) => {\n        const next = new Set(prev)\n        next.delete(stateKey)\n        return next\n      })\n    }\n  }, [onUpdateVideoPrompt])\n\n  return {\n    savingPrompts,\n    getLocalPrompt,\n    updateLocalPrompt,\n    savePrompt,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/useVideoStageUiState.ts",
    "content": "'use client'\n\nimport { useCallback, useState } from 'react'\n\nexport function useVideoStageUiState() {\n  const [panelVideoPreference, setPanelVideoPreference] = useState<Map<string, boolean>>(new Map())\n  const [voiceLinesExpanded, setVoiceLinesExpanded] = useState(false)\n  const [previewImage, setPreviewImage] = useState<string | null>(null)\n\n  const toggleVoiceLinesExpanded = useCallback(() => {\n    setVoiceLinesExpanded((previous) => !previous)\n  }, [])\n\n  const toggleLipSyncVideo = useCallback((panelKey: string, value: boolean) => {\n    setPanelVideoPreference((previous) => new Map(previous).set(panelKey, value))\n  }, [])\n\n  const closePreviewImage = useCallback(() => {\n    setPreviewImage(null)\n  }, [])\n\n  return {\n    panelVideoPreference,\n    voiceLinesExpanded,\n    previewImage,\n    setPreviewImage,\n    toggleVoiceLinesExpanded,\n    toggleLipSyncVideo,\n    closePreviewImage,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/useVideoTaskStates.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport type { Storyboard } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\nimport { useVideoTaskPresentation } from '@/lib/query/hooks/useTaskPresentation'\nimport { buildPanelLipTargets, buildPanelVideoTargets } from './task-targets'\n\ninterface UseVideoTaskStatesParams {\n  projectId: string\n  storyboards: Storyboard[]\n}\n\nexport function useVideoTaskStates({\n  projectId,\n  storyboards,\n}: UseVideoTaskStatesParams) {\n  const panelVideoTargets = useMemo(() => buildPanelVideoTargets(storyboards), [storyboards])\n  const panelLipTargets = useMemo(() => buildPanelLipTargets(storyboards), [storyboards])\n\n  const panelVideoStates = useVideoTaskPresentation(projectId, panelVideoTargets, {\n    enabled: !!projectId && panelVideoTargets.length > 0,\n  })\n  const panelLipStates = useVideoTaskPresentation(projectId, panelLipTargets, {\n    enabled: !!projectId && panelLipTargets.length > 0,\n  })\n\n  return {\n    panelVideoStates,\n    panelLipStates,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/useVideoVoiceLines.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useVoiceTaskPresentation } from '@/lib/query/hooks/useTaskPresentation'\nimport type { MatchedVoiceLine } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\nimport type { VoiceLine } from './types'\nimport { buildVoiceLineTargets } from './task-targets'\n\ninterface MatchedVoiceLinesQueryLike {\n  data?: {\n    voiceLines?: Array<{\n      id: string\n      lineIndex: number\n      speaker: string\n      content: string\n      audioUrl: string | null\n      audioDuration?: number | null\n      matchedStoryboardId: string | null\n      matchedPanelIndex: number | null\n    }>\n  }\n  refetch: () => Promise<unknown>\n}\n\ninterface UseVideoVoiceLinesParams {\n  projectId: string\n  matchedVoiceLinesQuery: MatchedVoiceLinesQueryLike\n}\n\nexport function useVideoVoiceLines({\n  projectId,\n  matchedVoiceLinesQuery,\n}: UseVideoVoiceLinesParams) {\n  const [panelVoiceLines, setPanelVoiceLines] = useState<Map<string, MatchedVoiceLine[]>>(new Map())\n  const [allVoiceLines, setAllVoiceLines] = useState<VoiceLine[]>([])\n\n  useEffect(() => {\n    const voiceLines = matchedVoiceLinesQuery.data?.voiceLines || []\n    const panelMap = new Map<string, MatchedVoiceLine[]>()\n\n    for (const voiceLine of voiceLines) {\n      if (voiceLine.matchedStoryboardId && voiceLine.matchedPanelIndex !== null) {\n        const panelKey = `${voiceLine.matchedStoryboardId}-${voiceLine.matchedPanelIndex}`\n        const existing = panelMap.get(panelKey) || []\n        existing.push({\n          id: voiceLine.id,\n          lineIndex: voiceLine.lineIndex,\n          speaker: voiceLine.speaker,\n          content: voiceLine.content,\n          audioUrl: voiceLine.audioUrl || undefined,\n          audioDuration: voiceLine.audioDuration || undefined,\n        })\n        panelMap.set(panelKey, existing)\n      }\n    }\n\n    setPanelVoiceLines(panelMap)\n    setAllVoiceLines(voiceLines as VoiceLine[])\n  }, [matchedVoiceLinesQuery.data])\n\n  const voiceLineTargets = useMemo(() => buildVoiceLineTargets(allVoiceLines), [allVoiceLines])\n  const voiceLineStates = useVoiceTaskPresentation(projectId, voiceLineTargets, {\n    enabled: !!projectId && voiceLineTargets.length > 0,\n  })\n\n  const runningVoiceLineIds = useMemo(() => {\n    const ids = new Set<string>()\n    for (const target of voiceLineTargets) {\n      const state = voiceLineStates.getTaskState(target.key)\n      if (state?.phase === 'queued' || state?.phase === 'processing') {\n        ids.add(target.targetId)\n      }\n    }\n    return ids\n  }, [voiceLineStates, voiceLineTargets])\n\n  const reloadVoiceLines = useCallback(async () => {\n    try {\n      await matchedVoiceLinesQuery.refetch()\n    } catch (error) {\n      _ulogError('Failed to reload voice lines:', error)\n    }\n  }, [matchedVoiceLinesQuery])\n\n  return {\n    panelVoiceLines,\n    allVoiceLines,\n    runningVoiceLineIds,\n    reloadVoiceLines,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime/utils.ts",
    "content": "'use client'\n\nimport { extractErrorMessage } from '@/lib/errors/extract'\n\nexport function getErrorMessage(error: unknown): string {\n  return extractErrorMessage(error, 'Unknown error')\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/video-stage-runtime-core.tsx",
    "content": "'use client'\n\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport {\n  VideoToolbar,\n  type VideoGenerationOptionValue,\n  type VideoGenerationOptions,\n  type VideoModelOption,\n} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video'\nimport { AppIcon } from '@/components/ui/icons'\nimport {\n  useDownloadRemoteBlob,\n  useListProjectEpisodeVideoUrls,\n  useMatchedVoiceLines,\n  useUpdateProjectPanelLink,\n} from '@/lib/query/hooks'\nimport { useLipSync } from '@/lib/query/hooks/useStoryboards'\nimport ImagePreviewModal from '@/components/ui/ImagePreviewModal'\nimport { ModelCapabilityDropdown } from '@/components/ui/config-modals/ModelCapabilityDropdown'\nimport VideoTimelinePanel from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video-stage/VideoTimelinePanel'\nimport VideoRenderPanel from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video-stage/VideoRenderPanel'\nimport type { VideoStageShellProps } from './video-stage-runtime/types'\nimport {\n  type EffectiveVideoCapabilityDefinition,\n  normalizeVideoGenerationSelections,\n  resolveEffectiveVideoCapabilityDefinitions,\n  resolveEffectiveVideoCapabilityFields,\n} from '@/lib/model-capabilities/video-effective'\nimport { projectVideoPricingTiersByFixedSelections } from '@/lib/model-pricing/video-tier'\nimport { useVideoTaskStates } from './video-stage-runtime/useVideoTaskStates'\nimport { useVideoPanelsProjection } from './video-stage-runtime/useVideoPanelsProjection'\nimport { useVideoPromptState } from './video-stage-runtime/useVideoPromptState'\nimport { useVideoPanelLinking } from './video-stage-runtime/useVideoPanelLinking'\nimport { useVideoVoiceLines } from './video-stage-runtime/useVideoVoiceLines'\nimport { useVideoDownloadAll } from './video-stage-runtime/useVideoDownloadAll'\nimport { useVideoStageUiState } from './video-stage-runtime/useVideoStageUiState'\nimport { useVideoPanelViewport } from './video-stage-runtime/useVideoPanelViewport'\nimport { useVideoFirstLastFrameFlow } from './video-stage-runtime/useVideoFirstLastFrameFlow'\nimport { filterNormalVideoModelOptions } from '@/lib/model-capabilities/video-model-options'\nimport {\n  buildVideoSubmissionKey,\n  createVideoSubmissionBaseline,\n  shouldResolveVideoSubmissionLock,\n  type VideoSubmissionBaseline,\n} from './video-stage-runtime/immediate-video-submission'\n\nexport type { VideoStageShellProps } from './video-stage-runtime/types'\n\ntype BatchCapabilityDefinition = EffectiveVideoCapabilityDefinition\n\ninterface BatchCapabilityField {\n  field: string\n  label: string\n  labelKey?: string\n  unitKey?: string\n  options: VideoGenerationOptionValue[]\n  disabledOptions?: VideoGenerationOptionValue[]\n}\n\nfunction toFieldLabel(field: string): string {\n  return field.replace(/([A-Z])/g, ' $1').replace(/^./, (char) => char.toUpperCase())\n}\n\nexport function useVideoStageRuntime({\n  projectId,\n  episodeId,\n  storyboards,\n  clips,\n  defaultVideoModel,\n  capabilityOverrides,\n  videoRatio = '16:9',\n  userVideoModels,\n  onGenerateVideo,\n  onGenerateAllVideos,\n  onBack,\n  onUpdateVideoPrompt,\n  onUpdatePanelVideoModel,\n  onOpenAssetLibraryForCharacter,\n  onEnterEditor,\n}: VideoStageShellProps) {\n  const t = useTranslations('video')\n\n  const {\n    panelVideoPreference,\n    voiceLinesExpanded,\n    previewImage,\n    setPreviewImage,\n    toggleVoiceLinesExpanded,\n    toggleLipSyncVideo,\n    closePreviewImage,\n  } = useVideoStageUiState()\n\n  const {\n    panelRefs,\n    highlightedPanelKey,\n    locateVoiceLinePanel,\n  } = useVideoPanelViewport()\n\n  const lipSyncMutation = useLipSync(projectId, episodeId)\n  const listEpisodeVideoUrlsMutation = useListProjectEpisodeVideoUrls(projectId)\n  const updatePanelLinkMutation = useUpdateProjectPanelLink(projectId)\n  const downloadRemoteBlobMutation = useDownloadRemoteBlob()\n  const matchedVoiceLinesQuery = useMatchedVoiceLines(projectId, episodeId)\n\n  const { panelVideoStates, panelLipStates } = useVideoTaskStates({\n    projectId,\n    storyboards,\n  })\n  const { allPanels } = useVideoPanelsProjection({\n    storyboards,\n    clips,\n    panelVideoStates,\n    panelLipStates,\n  })\n\n  const {\n    savingPrompts,\n    getLocalPrompt,\n    updateLocalPrompt,\n    savePrompt,\n  } = useVideoPromptState({\n    allPanels,\n    onUpdateVideoPrompt,\n  })\n\n  const { linkedPanels, handleToggleLink } = useVideoPanelLinking({\n    allPanels,\n    updatePanelLinkMutation,\n  })\n\n  const {\n    panelVoiceLines,\n    allVoiceLines,\n    runningVoiceLineIds,\n    reloadVoiceLines,\n  } = useVideoVoiceLines({\n    projectId,\n    matchedVoiceLinesQuery,\n  })\n\n  const {\n    isDownloading,\n    videosWithUrl,\n    handleDownloadAllVideos,\n  } = useVideoDownloadAll({\n    episodeId,\n    t: (key) => t(key as never),\n    allPanels,\n    panelVideoPreference,\n    listEpisodeVideoUrlsMutation,\n    downloadRemoteBlobMutation,\n  })\n\n  const allVideoModelOptions = useMemo(\n    () => userVideoModels || [],\n    [userVideoModels],\n  )\n  const normalVideoModelOptions = useMemo(\n    () => filterNormalVideoModelOptions(allVideoModelOptions),\n    [allVideoModelOptions],\n  )\n\n  const safeTranslate = useCallback((key: string | undefined, fallback = ''): string => {\n    if (!key) return fallback\n    try {\n      return t(key as never)\n    } catch {\n      return fallback\n    }\n  }, [t])\n\n  const renderCapabilityLabel = useCallback((field: {\n    field: string\n    label: string\n    labelKey?: string\n    unitKey?: string\n  }): string => {\n    const labelText = safeTranslate(field.labelKey, safeTranslate(`capability.${field.field}`, field.label))\n    const unitText = safeTranslate(field.unitKey)\n    return unitText ? `${labelText} (${unitText})` : labelText\n  }, [safeTranslate])\n\n  const [isBatchConfigOpen, setIsBatchConfigOpen] = useState(false)\n  const [isConfirming, setIsConfirming] = useState(false)\n  const [isSubmittingVideoBatch, setIsSubmittingVideoBatch] = useState(false)\n  const [submittingVideoPanelKeys, setSubmittingVideoPanelKeys] = useState<Set<string>>(new Set())\n  const [submittingVideoBaselines, setSubmittingVideoBaselines] = useState<Map<string, VideoSubmissionBaseline>>(new Map())\n  const [batchSelectedModel, setBatchSelectedModel] = useState('')\n  const [batchGenerationOptions, setBatchGenerationOptions] = useState<VideoGenerationOptions>({})\n\n  useEffect(() => {\n    if (normalVideoModelOptions.length === 0) {\n      if (batchSelectedModel) setBatchSelectedModel('')\n      return\n    }\n    if (normalVideoModelOptions.some((model) => model.value === batchSelectedModel)) return\n\n    const nextDefault = normalVideoModelOptions.some((model) => model.value === defaultVideoModel)\n      ? defaultVideoModel\n      : (normalVideoModelOptions[0]?.value || '')\n    setBatchSelectedModel(nextDefault)\n  }, [normalVideoModelOptions, batchSelectedModel, defaultVideoModel])\n\n  const selectedBatchModelOption = useMemo<VideoModelOption | undefined>(\n    () => normalVideoModelOptions.find((option) => option.value === batchSelectedModel),\n    [normalVideoModelOptions, batchSelectedModel],\n  )\n  const batchPricingTiers = useMemo(\n    () => projectVideoPricingTiersByFixedSelections({\n      tiers: selectedBatchModelOption?.videoPricingTiers ?? [],\n      fixedSelections: {\n        generationMode: 'normal',\n      },\n    }),\n    [selectedBatchModelOption?.videoPricingTiers],\n  )\n\n  const batchCapabilityDefinitions = useMemo<BatchCapabilityDefinition[]>(() => {\n    return resolveEffectiveVideoCapabilityDefinitions({\n      videoCapabilities: selectedBatchModelOption?.capabilities?.video,\n      pricingTiers: batchPricingTiers,\n    })\n  }, [batchPricingTiers, selectedBatchModelOption?.capabilities?.video])\n\n  useEffect(() => {\n    setBatchGenerationOptions((previous) => {\n      return normalizeVideoGenerationSelections({\n        definitions: batchCapabilityDefinitions,\n        pricingTiers: batchPricingTiers,\n        selection: previous,\n      })\n    })\n  }, [batchCapabilityDefinitions, batchPricingTiers])\n\n  const batchEffectiveCapabilityFields = useMemo(\n    () => resolveEffectiveVideoCapabilityFields({\n      definitions: batchCapabilityDefinitions,\n      pricingTiers: batchPricingTiers,\n      selection: batchGenerationOptions,\n    }),\n    [batchCapabilityDefinitions, batchGenerationOptions, batchPricingTiers],\n  )\n\n  const batchEffectiveFieldMap = useMemo(\n    () => new Map(batchEffectiveCapabilityFields.map((field) => [field.field, field])),\n    [batchEffectiveCapabilityFields],\n  )\n  const batchDefinitionFieldMap = useMemo(\n    () => new Map(batchCapabilityDefinitions.map((definition) => [definition.field, definition])),\n    [batchCapabilityDefinitions],\n  )\n\n  const batchCapabilityFields = useMemo<BatchCapabilityField[]>(() => {\n    return batchCapabilityDefinitions.map((definition) => {\n      const effectiveField = batchEffectiveFieldMap.get(definition.field)\n      const enabledOptions = effectiveField?.options ?? []\n      return {\n        field: definition.field,\n        label: toFieldLabel(definition.field),\n        labelKey: definition.fieldI18n?.labelKey,\n        unitKey: definition.fieldI18n?.unitKey,\n        options: definition.options as VideoGenerationOptionValue[],\n        disabledOptions: (definition.options as VideoGenerationOptionValue[])\n          .filter((option) => !enabledOptions.includes(option)),\n      }\n    })\n  }, [batchCapabilityDefinitions, batchEffectiveFieldMap])\n\n  const batchMissingCapabilityFields = useMemo(\n    () => batchEffectiveCapabilityFields\n      .filter((field) => field.options.length === 0 || field.value === undefined)\n      .map((field) => field.field),\n    [batchEffectiveCapabilityFields],\n  )\n\n  const setBatchCapabilityValue = useCallback((field: string, rawValue: string) => {\n    const capabilityDefinition = batchDefinitionFieldMap.get(field)\n    if (!capabilityDefinition || capabilityDefinition.options.length === 0) return\n    const sample = capabilityDefinition.options[0]\n    const parsedValue =\n      typeof sample === 'number'\n        ? Number(rawValue)\n        : typeof sample === 'boolean'\n          ? rawValue === 'true'\n          : rawValue\n    if (!capabilityDefinition.options.includes(parsedValue)) return\n    setBatchGenerationOptions((previous) => ({\n      ...normalizeVideoGenerationSelections({\n        definitions: batchCapabilityDefinitions,\n        pricingTiers: batchPricingTiers,\n        selection: {\n          ...previous,\n          [field]: parsedValue,\n        },\n        pinnedFields: [field],\n      }),\n    }))\n  }, [batchCapabilityDefinitions, batchDefinitionFieldMap, batchPricingTiers])\n\n  const handleLipSync = useCallback(async (\n    storyboardId: string,\n    panelIndex: number,\n    voiceLineId: string,\n    panelId?: string,\n  ) => {\n    try {\n      await lipSyncMutation.mutateAsync({\n        storyboardId,\n        panelIndex,\n        voiceLineId,\n        panelId,\n      })\n    } catch (error: unknown) {\n      _ulogError('Lip sync error:', error)\n      throw error\n    }\n  }, [lipSyncMutation])\n\n  const panelBySubmissionKey = useMemo(() => {\n    const next = new Map<string, (typeof allPanels)[number]>()\n    for (const panel of allPanels) {\n      next.set(buildVideoSubmissionKey(panel), panel)\n    }\n    return next\n  }, [allPanels])\n\n  const handleGenerateVideoWithImmediateLock = useCallback(async (\n    storyboardId: string,\n    panelIndex: number,\n    videoModel?: string,\n    firstLastFrame?: {\n      lastFrameStoryboardId: string\n      lastFramePanelIndex: number\n      flModel: string\n      customPrompt?: string\n    },\n    generationOptions?: VideoGenerationOptions,\n    panelId?: string,\n  ) => {\n    if (isSubmittingVideoBatch) return\n\n    const panelKey = buildVideoSubmissionKey({ panelId, storyboardId, panelIndex })\n    const currentPanel = panelBySubmissionKey.get(panelKey)\n    if (currentPanel?.videoTaskRunning || submittingVideoPanelKeys.has(panelKey)) return\n\n    setSubmittingVideoPanelKeys((previous) => {\n      if (previous.has(panelKey)) return previous\n      const next = new Set(previous)\n      next.add(panelKey)\n      return next\n    })\n    if (currentPanel) {\n      setSubmittingVideoBaselines((previous) => {\n        const next = new Map(previous)\n        next.set(panelKey, createVideoSubmissionBaseline(currentPanel))\n        return next\n      })\n    }\n\n    try {\n      await onGenerateVideo(storyboardId, panelIndex, videoModel, firstLastFrame, generationOptions, panelId)\n    } catch (error) {\n      setSubmittingVideoPanelKeys((previous) => {\n        if (!previous.has(panelKey)) return previous\n        const next = new Set(previous)\n        next.delete(panelKey)\n        return next\n      })\n      setSubmittingVideoBaselines((previous) => {\n        if (!previous.has(panelKey)) return previous\n        const next = new Map(previous)\n        next.delete(panelKey)\n        return next\n      })\n      throw error\n    }\n  }, [\n    isSubmittingVideoBatch,\n    onGenerateVideo,\n    panelBySubmissionKey,\n    submittingVideoPanelKeys,\n  ])\n\n  const {\n    flModel,\n    flModelOptions,\n    flGenerationOptions,\n    flCapabilityFields,\n    flMissingCapabilityFields,\n    flCustomPrompts,\n    setFlModel,\n    setFlCapabilityValue,\n    setFlCustomPrompt,\n    resetFlCustomPrompt,\n    handleGenerateFirstLastFrame,\n    getDefaultFlPrompt,\n    getNextPanel,\n    isLinkedAsLastFrame,\n  } = useVideoFirstLastFrameFlow({\n    allPanels,\n    linkedPanels,\n    videoModelOptions: allVideoModelOptions,\n    onGenerateVideo: handleGenerateVideoWithImmediateLock,\n    t: (key) => t(key as never),\n  })\n\n  useEffect(() => {\n    if (submittingVideoPanelKeys.size === 0) return\n\n    const now = Date.now()\n    setSubmittingVideoPanelKeys((previous) => {\n      let changed = false\n      const next = new Set(previous)\n      for (const key of previous) {\n        if (!shouldResolveVideoSubmissionLock(panelBySubmissionKey.get(key), submittingVideoBaselines.get(key), now)) {\n          continue\n        }\n        next.delete(key)\n        changed = true\n      }\n      return changed ? next : previous\n    })\n    setSubmittingVideoBaselines((previous) => {\n      let changed = false\n      const next = new Map(previous)\n      for (const key of previous.keys()) {\n        if (submittingVideoPanelKeys.has(key) && !shouldResolveVideoSubmissionLock(panelBySubmissionKey.get(key), previous.get(key), now)) {\n          continue\n        }\n        next.delete(key)\n        changed = true\n      }\n      return changed ? next : previous\n    })\n  }, [panelBySubmissionKey, submittingVideoBaselines, submittingVideoPanelKeys])\n\n  useEffect(() => {\n    if (!isSubmittingVideoBatch || allPanels.some((panel) => panel.videoTaskRunning)) {\n      if (isSubmittingVideoBatch && allPanels.some((panel) => panel.videoTaskRunning)) {\n        setIsSubmittingVideoBatch(false)\n      }\n      return\n    }\n\n    const timeoutId = window.setTimeout(() => {\n      setIsSubmittingVideoBatch(false)\n    }, 90_000)\n    return () => window.clearTimeout(timeoutId)\n  }, [allPanels, isSubmittingVideoBatch])\n\n  const handleGenerateAllVideosWithImmediateLock = useCallback(async (options?: Parameters<typeof onGenerateAllVideos>[0]) => {\n    if (isSubmittingVideoBatch) return\n    setIsSubmittingVideoBatch(true)\n    try {\n      await onGenerateAllVideos(options)\n    } catch (error) {\n      setIsSubmittingVideoBatch(false)\n      throw error\n    }\n  }, [isSubmittingVideoBatch, onGenerateAllVideos])\n\n  const projectedPanels = useMemo(() => (\n    allPanels.map((panel) => {\n      const panelKey = buildVideoSubmissionKey(panel)\n      if (!isSubmittingVideoBatch && !submittingVideoPanelKeys.has(panelKey)) return panel\n      return {\n        ...panel,\n        videoTaskRunning: true,\n      }\n    })\n  ), [allPanels, isSubmittingVideoBatch, submittingVideoPanelKeys])\n\n  const runningCount = projectedPanels.filter((panel) => panel.videoTaskRunning || panel.lipSyncTaskRunning).length\n  const failedCount = allPanels.filter((panel) => !!panel.videoErrorMessage || !!panel.lipSyncErrorMessage).length\n  const isAnyTaskRunning = runningCount > 0 || isSubmittingVideoBatch\n  const canSubmitBatchGenerate = !!batchSelectedModel && batchMissingCapabilityFields.length === 0\n\n  const handleOpenBatchGenerateModal = useCallback(() => {\n    if (isAnyTaskRunning) return\n    setIsBatchConfigOpen(true)\n  }, [isAnyTaskRunning])\n\n  const handleCloseBatchGenerateModal = useCallback(() => {\n    setIsBatchConfigOpen(false)\n  }, [])\n\n  const handleConfirmBatchGenerate = useCallback(async () => {\n    if (!canSubmitBatchGenerate || isConfirming) return\n\n    setIsConfirming(true)\n    try {\n      await handleGenerateAllVideosWithImmediateLock({\n        videoModel: batchSelectedModel,\n        generationOptions: batchGenerationOptions,\n      })\n      setIsBatchConfigOpen(false)\n    } finally {\n      setIsConfirming(false)\n    }\n  }, [\n    batchGenerationOptions,\n    batchSelectedModel,\n    canSubmitBatchGenerate,\n    handleGenerateAllVideosWithImmediateLock,\n    isConfirming,\n  ])\n\n  return (\n    <div className=\"space-y-6 pb-20\">\n      <VideoToolbar\n        totalPanels={projectedPanels.length}\n        runningCount={runningCount}\n        videosWithUrl={videosWithUrl}\n        failedCount={failedCount}\n        isAnyTaskRunning={isAnyTaskRunning}\n        isDownloading={isDownloading}\n        onGenerateAll={handleOpenBatchGenerateModal}\n        onDownloadAll={handleDownloadAllVideos}\n        onBack={onBack}\n        onEnterEditor={onEnterEditor}\n        videosReady={videosWithUrl > 0}\n      />\n\n      <VideoTimelinePanel\n        projectId={projectId}\n        episodeId={episodeId}\n        allVoiceLines={allVoiceLines}\n        expanded={voiceLinesExpanded}\n        onToggleExpanded={toggleVoiceLinesExpanded}\n        onReloadVoiceLines={reloadVoiceLines}\n        onLocateVoiceLine={locateVoiceLinePanel}\n        onOpenAssetLibraryForCharacter={onOpenAssetLibraryForCharacter}\n      />\n\n      <VideoRenderPanel\n        allPanels={projectedPanels}\n        linkedPanels={linkedPanels}\n        highlightedPanelKey={highlightedPanelKey}\n        panelRefs={panelRefs}\n        videoRatio={videoRatio}\n        defaultVideoModel={defaultVideoModel}\n        capabilityOverrides={capabilityOverrides}\n        userVideoModels={normalVideoModelOptions}\n        projectId={projectId}\n        episodeId={episodeId}\n        runningVoiceLineIds={runningVoiceLineIds}\n        panelVoiceLines={panelVoiceLines}\n        panelVideoPreference={panelVideoPreference}\n        savingPrompts={savingPrompts}\n        flModel={flModel}\n        flModelOptions={flModelOptions}\n        flGenerationOptions={flGenerationOptions}\n        flCapabilityFields={flCapabilityFields}\n        flMissingCapabilityFields={flMissingCapabilityFields}\n        flCustomPrompts={flCustomPrompts}\n        onGenerateVideo={handleGenerateVideoWithImmediateLock}\n        onUpdatePanelVideoModel={onUpdatePanelVideoModel}\n        onLipSync={handleLipSync}\n        onToggleLink={handleToggleLink}\n        onFlModelChange={setFlModel}\n        onFlCapabilityChange={setFlCapabilityValue}\n        onFlCustomPromptChange={setFlCustomPrompt}\n        onResetFlPrompt={resetFlCustomPrompt}\n        onGenerateFirstLastFrame={handleGenerateFirstLastFrame}\n        onPreviewImage={setPreviewImage}\n        onToggleLipSyncVideo={toggleLipSyncVideo}\n        getNextPanel={getNextPanel}\n        isLinkedAsLastFrame={isLinkedAsLastFrame}\n        getDefaultFlPrompt={getDefaultFlPrompt}\n        getLocalPrompt={getLocalPrompt}\n        updateLocalPrompt={updateLocalPrompt}\n        savePrompt={savePrompt}\n      />\n\n      {isBatchConfigOpen && (\n        <div\n          className=\"fixed inset-0 z-[120] glass-overlay flex items-center justify-center p-4\"\n          onClick={handleCloseBatchGenerateModal}\n        >\n          <div\n            className=\"glass-surface-modal w-full max-w-2xl p-5 space-y-4\"\n            onClick={(event) => event.stopPropagation()}\n          >\n            <div className=\"space-y-1\">\n              <h3 className=\"text-lg font-semibold text-[var(--glass-text-primary)]\">\n                {t('toolbar.batchConfigTitle')}\n              </h3>\n              <p className=\"text-sm text-[var(--glass-text-tertiary)]\">\n                {t('toolbar.batchConfigDesc')}\n              </p>\n            </div>\n\n            <ModelCapabilityDropdown\n              models={normalVideoModelOptions}\n              value={batchSelectedModel || undefined}\n              onModelChange={setBatchSelectedModel}\n              capabilityFields={batchCapabilityFields.map((field) => ({\n                field: field.field,\n                label: renderCapabilityLabel(field),\n                options: field.options,\n                disabledOptions: field.disabledOptions,\n              }))}\n              capabilityOverrides={batchGenerationOptions}\n              onCapabilityChange={(field, rawValue) => setBatchCapabilityValue(field, rawValue)}\n              placeholder={t('panelCard.selectModel')}\n            />\n\n            <div className=\"flex justify-end gap-2 pt-1\">\n              <button\n                type=\"button\"\n                onClick={handleCloseBatchGenerateModal}\n                className=\"glass-btn-base glass-btn-secondary px-4 py-2 text-sm font-medium\"\n              >\n                {t('panelCard.cancel')}\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => { void handleConfirmBatchGenerate() }}\n                disabled={!canSubmitBatchGenerate || isConfirming}\n                className=\"glass-btn-base glass-btn-primary px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2\"\n              >\n                {isConfirming ? (\n                  <>\n                    <AppIcon name=\"loader\" className=\"animate-spin h-4 w-4\" />\n                    <span>{t('toolbar.confirming')}</span>\n                  </>\n                ) : (\n                  <span>{t('toolbar.confirmGenerateAll')}</span>\n                )}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {previewImage && <ImagePreviewModal imageUrl={previewImage} onClose={closePreviewImage} />}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/types.ts",
    "content": "'use client'\n\nimport type {\n  SpeakerVoiceEntry as ProviderSpeakerVoiceEntry,\n  SpeakerVoicePatch,\n} from '@/lib/voice/provider-voice-binding'\n\nexport interface VoiceLine {\n  id: string\n  lineIndex: number\n  speaker: string\n  content: string\n  emotionPrompt: string | null\n  emotionStrength: number | null\n  audioUrl: string | null\n  updatedAt: string | null\n  lineTaskRunning: boolean\n  matchedPanelId?: string | null\n  matchedStoryboardId?: string | null\n  matchedPanelIndex?: number | null\n}\n\nexport type PendingVoiceTaskStatus = 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | null\n\nexport interface PendingVoiceGenerationState {\n  submittedUpdatedAt: string | null\n  startedAt: string\n  taskId: string | null\n  taskStatus: PendingVoiceTaskStatus\n  taskErrorMessage: string | null\n}\n\nexport type PendingVoiceGenerationMap = Record<string, PendingVoiceGenerationState>\n\nexport interface Character {\n  id: string\n  name: string\n  customVoiceUrl?: string | null\n  voiceId?: string | null\n}\n\nexport interface BindablePanelOption {\n  id: string\n  storyboardId: string\n  panelIndex: number\n  label: string\n}\n\nexport interface EpisodeStoryboard {\n  id: string\n  clipId?: string | null\n  panels?: Array<{\n    id: string\n    panelIndex: number\n    srtSegment?: string | null\n    description?: string | null\n  }>\n}\n\nexport interface EpisodeClip {\n  id: string\n}\n\nexport type SpeakerVoiceEntry = ProviderSpeakerVoiceEntry\nexport type InlineSpeakerVoiceBinding = SpeakerVoicePatch\n\nexport interface VoiceStageShellProps {\n  projectId: string\n  episodeId: string\n  onBack?: () => void\n  embedded?: boolean\n  onVoiceLineClick?: (storyboardId: string, panelIndex: number) => void\n  onVoiceLinesChanged?: () => void\n  onOpenAssetLibraryForCharacter?: (characterId?: string | null) => void\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useBindablePanelOptions.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport type { BindablePanelOption, EpisodeClip, EpisodeStoryboard } from './types'\n\ninterface UseBindablePanelOptionsParams {\n  episodeData: unknown\n  t: (key: string, values?: { index: number }) => string\n}\n\nexport function useBindablePanelOptions({\n  episodeData,\n  t,\n}: UseBindablePanelOptionsParams) {\n  return useMemo<BindablePanelOption[]>(() => {\n    const payload = episodeData as { storyboards?: EpisodeStoryboard[]; clips?: EpisodeClip[] } | null\n    const storyboards = payload?.storyboards || []\n    const clips = payload?.clips || []\n    const clipIndexMap = new Map<string, number>()\n    clips.forEach((clip, index) => {\n      clipIndexMap.set(clip.id, index)\n    })\n\n    const sortedStoryboards = [...storyboards].sort((a, b) => {\n      const aIndex = a.clipId ? clipIndexMap.get(a.clipId) : undefined\n      const bIndex = b.clipId ? clipIndexMap.get(b.clipId) : undefined\n\n      if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex\n      if (aIndex !== undefined) return -1\n      if (bIndex !== undefined) return 1\n      return 0\n    })\n\n    const options: BindablePanelOption[] = []\n    let globalPanelOrder = 1\n\n    for (const storyboard of sortedStoryboards) {\n      const panels = [...(storyboard.panels || [])].sort((a, b) => a.panelIndex - b.panelIndex)\n      for (const panel of panels) {\n        const previewSource = (panel.srtSegment || panel.description || '').replace(/\\s+/g, ' ').trim()\n        const previewText = previewSource ? ` - ${previewSource.slice(0, 28)}` : ''\n        options.push({\n          id: panel.id,\n          storyboardId: storyboard.id,\n          panelIndex: panel.panelIndex,\n          label: t('lineEditor.panelLabel', { index: globalPanelOrder }) + previewText,\n        })\n        globalPanelOrder += 1\n      }\n    }\n\n    return options\n  }, [episodeData, t])\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useSpeakerAssetNavigation.ts",
    "content": "'use client'\n\nimport { useCallback } from 'react'\nimport type { Character } from './types'\n\ninterface RouterLike {\n  push: (href: string) => void\n}\n\ninterface SearchParamsLike {\n  toString: () => string\n}\n\ninterface UseSpeakerAssetNavigationParams {\n  episodeId: string\n  pathname: string\n  router: RouterLike\n  searchParams: SearchParamsLike\n  onOpenAssetLibraryForCharacter?: (characterId?: string | null) => void\n  matchCharacterBySpeaker: (speaker: string) => Character | undefined\n}\n\nexport function useSpeakerAssetNavigation({\n  episodeId,\n  pathname,\n  router,\n  searchParams,\n  onOpenAssetLibraryForCharacter,\n  matchCharacterBySpeaker,\n}: UseSpeakerAssetNavigationParams) {\n  const handleOpenAssetLibraryForSpeaker = useCallback((speaker: string) => {\n    const matchedCharacter = matchCharacterBySpeaker(speaker)\n    if (onOpenAssetLibraryForCharacter) {\n      onOpenAssetLibraryForCharacter(matchedCharacter?.id || null)\n      return\n    }\n    const params = new URLSearchParams(searchParams.toString())\n    params.set('assetLibrary', '1')\n    params.set('episode', episodeId)\n    if (matchedCharacter?.id) {\n      params.set('focusCharacter', matchedCharacter.id)\n    } else {\n      params.delete('focusCharacter')\n    }\n    router.push(`${pathname}?${params.toString()}`)\n  }, [\n    episodeId,\n    matchCharacterBySpeaker,\n    onOpenAssetLibraryForCharacter,\n    pathname,\n    router,\n    searchParams,\n  ])\n\n  return {\n    handleOpenAssetLibraryForSpeaker,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useVoiceGenerationActions.ts",
    "content": "'use client'\n\nimport { useQueryClient } from '@tanstack/react-query'\nimport { useCallback, useState } from 'react'\nimport { shouldShowError } from '@/lib/error-utils'\nimport { upsertTaskTargetOverlay } from '@/lib/query/task-target-overlay'\nimport { hasAnyVoiceBinding } from '@/lib/voice/provider-voice-binding'\nimport { getErrorMessage, getErrorStatus } from './utils'\nimport type {\n  Character,\n  PendingVoiceGenerationMap,\n  PendingVoiceGenerationState,\n  SpeakerVoiceEntry,\n  VoiceLine,\n} from './types'\n\ninterface MutationLike<TInput = unknown, TOutput = unknown> {\n  mutateAsync: (input: TInput) => Promise<TOutput>\n}\n\ninterface UseVoiceGenerationActionsParams {\n  projectId: string\n  episodeId: string\n  t: (key: string) => string\n  voiceLines: VoiceLine[]\n  linesWithAudio: number\n  speakerCharacterMap: Record<string, Character>\n  speakerVoices: Record<string, SpeakerVoiceEntry>\n  analyzeVoiceMutation: MutationLike<{ episodeId: string }>\n  generateVoiceMutation: MutationLike<{ episodeId: string; lineId?: string; all?: boolean }, {\n    success?: boolean\n    error?: string\n    async?: boolean\n    taskId?: string\n    taskIds?: string[]\n    results?: Array<{ lineId?: string; taskId?: string; audioUrl?: string }>\n  }>\n  downloadVoicesMutation: MutationLike<{ episodeId: string }, Blob>\n  loadData: () => Promise<void>\n  notifyVoiceLinesChanged: () => void\n  setPendingVoiceGenerationByLineId: React.Dispatch<React.SetStateAction<PendingVoiceGenerationMap>>\n}\n\nexport function useVoiceGenerationActions({\n  projectId,\n  episodeId,\n  t,\n  voiceLines,\n  linesWithAudio,\n  speakerCharacterMap,\n  speakerVoices,\n  analyzeVoiceMutation,\n  generateVoiceMutation,\n  downloadVoicesMutation,\n  loadData,\n  notifyVoiceLinesChanged,\n  setPendingVoiceGenerationByLineId,\n}: UseVoiceGenerationActionsParams) {\n  const queryClient = useQueryClient()\n  const [analyzing, setAnalyzing] = useState(false)\n  const [isBatchSubmittingAll, setIsBatchSubmittingAll] = useState(false)\n  const [isDownloading, setIsDownloading] = useState(false)\n\n  const buildPendingGenerationMap = useCallback((lineIds: string[]) => {\n    const next: PendingVoiceGenerationMap = {}\n    const startedAt = new Date().toISOString()\n    for (const lineId of lineIds) {\n      const line = voiceLines.find((item) => item.id === lineId)\n      next[lineId] = {\n        submittedUpdatedAt: line?.updatedAt ?? null,\n        startedAt,\n        taskId: null,\n        taskStatus: null,\n        taskErrorMessage: null,\n      }\n    }\n    return next\n  }, [voiceLines])\n\n  const withTaskState = useCallback((\n    prev: PendingVoiceGenerationMap,\n    lineId: string,\n    patch: Partial<PendingVoiceGenerationState>,\n  ) => {\n    const current = prev[lineId]\n    if (!current) return prev\n    return {\n      ...prev,\n      [lineId]: {\n        ...current,\n        ...patch,\n      },\n    }\n  }, [])\n\n  const handleAnalyze = useCallback(async () => {\n    setAnalyzing(true)\n    try {\n      await analyzeVoiceMutation.mutateAsync({ episodeId })\n      await loadData()\n      notifyVoiceLinesChanged()\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(`${t('errors.analyzeFailed')}: ${getErrorMessage(error)}`)\n      }\n    } finally {\n      setAnalyzing(false)\n    }\n  }, [analyzeVoiceMutation, episodeId, loadData, notifyVoiceLinesChanged, t])\n\n  const handleGenerateLine = useCallback(async (lineId: string) => {\n    const pendingGeneration = buildPendingGenerationMap([lineId])\n    setPendingVoiceGenerationByLineId((prev) => ({\n      ...prev,\n      ...pendingGeneration,\n    }))\n    let handoffToTaskState = false\n\n    try {\n      const data = await generateVoiceMutation.mutateAsync({ episodeId, lineId })\n      if (!data?.success) {\n        throw new Error(data?.error || t('errors.generateFailed'))\n      }\n      if (data?.async && data?.taskId) {\n        setPendingVoiceGenerationByLineId((prev) => withTaskState(prev, lineId, {\n          taskId: data.taskId,\n          taskStatus: 'queued',\n        }))\n        upsertTaskTargetOverlay(queryClient, {\n          projectId,\n          targetType: 'NovelPromotionVoiceLine',\n          targetId: lineId,\n          phase: 'queued',\n          runningTaskId: data.taskId,\n          runningTaskType: 'voice_line',\n          intent: 'generate',\n          hasOutputAtStart: false,\n        })\n        handoffToTaskState = true\n      }\n      notifyVoiceLinesChanged()\n    } catch (error: unknown) {\n      if (getErrorStatus(error) === 402) {\n        alert(`${t('alerts.insufficientBalance')}\\n\\n${getErrorMessage(error) || t('alerts.insufficientBalanceMsg')}`)\n        return\n      }\n      if (shouldShowError(error)) {\n        alert(`${t('errors.generateFailed')}: ${getErrorMessage(error)}`)\n      }\n    } finally {\n      if (handoffToTaskState) return\n      setPendingVoiceGenerationByLineId((prev) => {\n        if (!(lineId in prev)) return prev\n        const next = { ...prev }\n        delete next[lineId]\n        return next\n      })\n    }\n  }, [\n    buildPendingGenerationMap,\n    episodeId,\n    generateVoiceMutation,\n    notifyVoiceLinesChanged,\n    projectId,\n    queryClient,\n    setPendingVoiceGenerationByLineId,\n    t,\n    withTaskState,\n  ])\n\n  const handleGenerateAll = useCallback(async () => {\n    const linesToGenerate = voiceLines.filter((line) => {\n      if (line.audioUrl) return false\n      const character = speakerCharacterMap[line.speaker]\n      const speakerVoice = speakerVoices[line.speaker]\n      return hasAnyVoiceBinding({\n        character,\n        speakerVoice,\n      })\n    })\n\n    if (linesToGenerate.length === 0) {\n      alert(t('alerts.noLinesToGenerate'))\n      return\n    }\n\n    setIsBatchSubmittingAll(true)\n    const lineIds = linesToGenerate.map((line) => line.id)\n    const pendingGeneration = buildPendingGenerationMap(lineIds)\n    setPendingVoiceGenerationByLineId((prev) => ({\n      ...prev,\n      ...pendingGeneration,\n    }))\n    let handoffToTaskState = false\n\n    try {\n      const data = await generateVoiceMutation.mutateAsync({ episodeId, all: true })\n      if (!Array.isArray(data.taskIds) || data.taskIds.length === 0) {\n        setPendingVoiceGenerationByLineId((prev) => {\n          const next = { ...prev }\n          for (const lineId of lineIds) delete next[lineId]\n          return next\n        })\n        alert(data?.error || t('alerts.noLinesToGenerate'))\n        return\n      }\n\n      const taskResults = Array.isArray(data.results) ? data.results : []\n      if (taskResults.length > 0) {\n        for (const result of taskResults) {\n          if (!result?.lineId || !result?.taskId) continue\n          const resultLineId = result.lineId\n          const resultTaskId = result.taskId\n          setPendingVoiceGenerationByLineId((prev) => withTaskState(prev, resultLineId, {\n            taskId: resultTaskId,\n            taskStatus: 'queued',\n          }))\n          upsertTaskTargetOverlay(queryClient, {\n            projectId,\n            targetType: 'NovelPromotionVoiceLine',\n            targetId: resultLineId,\n            phase: 'queued',\n            runningTaskId: resultTaskId,\n            runningTaskType: 'voice_line',\n            intent: 'generate',\n            hasOutputAtStart: false,\n          })\n        }\n      } else {\n        for (let index = 0; index < lineIds.length && index < data.taskIds.length; index += 1) {\n          const currentLineId = lineIds[index]\n          const currentTaskId = data.taskIds[index]\n          setPendingVoiceGenerationByLineId((prev) => withTaskState(prev, currentLineId, {\n            taskId: currentTaskId,\n            taskStatus: 'queued',\n          }))\n          upsertTaskTargetOverlay(queryClient, {\n            projectId,\n            targetType: 'NovelPromotionVoiceLine',\n            targetId: currentLineId,\n            phase: 'queued',\n            runningTaskId: currentTaskId,\n            runningTaskType: 'voice_line',\n            intent: 'generate',\n            hasOutputAtStart: false,\n          })\n        }\n      }\n      handoffToTaskState = true\n      notifyVoiceLinesChanged()\n    } catch (error: unknown) {\n      if (getErrorStatus(error) === 402) {\n        alert(`${t('alerts.insufficientBalance')}\\n\\n${getErrorMessage(error) || t('alerts.insufficientBalanceMsg')}`)\n        return\n      }\n      if (shouldShowError(error)) {\n        alert(`${t('errors.batchFailed')}: ${getErrorMessage(error)}`)\n      }\n    } finally {\n      setIsBatchSubmittingAll(false)\n      if (handoffToTaskState) return\n      setPendingVoiceGenerationByLineId((prev) => {\n        const next = { ...prev }\n        for (const lineId of lineIds) delete next[lineId]\n        return next\n      })\n    }\n  }, [\n    buildPendingGenerationMap,\n    episodeId,\n    generateVoiceMutation,\n    notifyVoiceLinesChanged,\n    projectId,\n    queryClient,\n    setPendingVoiceGenerationByLineId,\n    speakerCharacterMap,\n    speakerVoices,\n    t,\n    voiceLines,\n    withTaskState,\n  ])\n\n  const handleDownloadAll = useCallback(async () => {\n    if (linesWithAudio === 0) return\n\n    setIsDownloading(true)\n    try {\n      const blob = await downloadVoicesMutation.mutateAsync({ episodeId })\n      const url = window.URL.createObjectURL(blob)\n      const anchor = document.createElement('a')\n      anchor.href = url\n      anchor.download = `配音_${new Date().toISOString().slice(0, 10)}.zip`\n      document.body.appendChild(anchor)\n      anchor.click()\n      window.URL.revokeObjectURL(url)\n      document.body.removeChild(anchor)\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(`${t('errors.downloadFailed')}: ${getErrorMessage(error)}`)\n      }\n    } finally {\n      setIsDownloading(false)\n    }\n  }, [downloadVoicesMutation, episodeId, linesWithAudio, t])\n\n  return {\n    analyzing,\n    isBatchSubmittingAll,\n    isDownloading,\n    handleAnalyze,\n    handleGenerateLine,\n    handleGenerateAll,\n    handleDownloadAll,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useVoiceLineBindings.ts",
    "content": "'use client'\n\nimport { useCallback } from 'react'\nimport type { BindablePanelOption, VoiceLine } from './types'\n\ninterface UseVoiceLineBindingsParams {\n  bindablePanelOptions: BindablePanelOption[]\n  onVoiceLineClick?: (storyboardId: string, panelIndex: number) => void\n  handleStartEdit: (line: VoiceLine, boundPanelId: string) => void\n}\n\nexport function useVoiceLineBindings({\n  bindablePanelOptions,\n  onVoiceLineClick,\n  handleStartEdit,\n}: UseVoiceLineBindingsParams) {\n  const getBoundPanelIdForLine = useCallback((line: VoiceLine): string => {\n    if (line.matchedPanelId) return line.matchedPanelId\n    if (!line.matchedStoryboardId || line.matchedPanelIndex === null || line.matchedPanelIndex === undefined) return ''\n\n    const matched = bindablePanelOptions.find(\n      (option) => option.storyboardId === line.matchedStoryboardId && option.panelIndex === line.matchedPanelIndex,\n    )\n    return matched?.id || ''\n  }, [bindablePanelOptions])\n\n  const handleStartEditLine = useCallback((line: VoiceLine) => {\n    handleStartEdit(line, getBoundPanelIdForLine(line))\n  }, [getBoundPanelIdForLine, handleStartEdit])\n\n  const handleLocatePanel = useCallback((voiceLine: VoiceLine) => {\n    if (!onVoiceLineClick) return\n\n    let targetStoryboardId = voiceLine.matchedStoryboardId || null\n    let targetPanelIndex = voiceLine.matchedPanelIndex\n    if (voiceLine.matchedPanelId) {\n      const matchedPanel = bindablePanelOptions.find((option) => option.id === voiceLine.matchedPanelId)\n      if (matchedPanel) {\n        targetStoryboardId = matchedPanel.storyboardId\n        targetPanelIndex = matchedPanel.panelIndex\n      }\n    }\n\n    if (!targetStoryboardId || targetPanelIndex === null || targetPanelIndex === undefined) return\n    onVoiceLineClick(targetStoryboardId, targetPanelIndex)\n  }, [bindablePanelOptions, onVoiceLineClick])\n\n  const handleDownloadSingle = (audioUrl: string) => {\n    window.open(audioUrl, '_blank')\n  }\n\n  return {\n    getBoundPanelIdForLine,\n    handleStartEditLine,\n    handleLocatePanel,\n    handleDownloadSingle,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useVoiceLineCrudActions.ts",
    "content": "'use client'\n\nimport { useCallback } from 'react'\nimport { shouldShowError } from '@/lib/error-utils'\nimport { getErrorMessage } from './utils'\nimport type { PendingVoiceGenerationMap, VoiceLine } from './types'\n\ninterface MutationLike<TInput = unknown, TOutput = unknown> {\n  mutateAsync: (input: TInput) => Promise<TOutput>\n}\n\ninterface UseVoiceLineCrudActionsParams {\n  episodeId: string\n  t: (key: string, values?: { content: string }) => string\n  voiceLines: VoiceLine[]\n  editingLineId: string | null\n  editingContent: string\n  editingSpeaker: string\n  editingMatchedPanelId: string\n  setVoiceLines: React.Dispatch<React.SetStateAction<VoiceLine[]>>\n  setPendingVoiceGenerationByLineId: React.Dispatch<React.SetStateAction<PendingVoiceGenerationMap>>\n  setIsSavingLineEditor: (value: boolean) => void\n  getBoundPanelIdForLine: (line: VoiceLine) => string\n  handleCancelEdit: () => void\n  notifyVoiceLinesChanged: () => void\n  createVoiceLineMutation: MutationLike<{ episodeId: string; content: string; speaker: string; matchedPanelId: string | null }, { voiceLine: VoiceLine }>\n  updateVoiceLineMutation: MutationLike<{ lineId: string; content?: string; speaker?: string; matchedPanelId?: string | null; audioUrl?: string | null; emotionPrompt?: string | null; emotionStrength?: number }, { voiceLine: VoiceLine }>\n  deleteVoiceLineMutation: MutationLike<{ lineId: string }>\n}\n\nexport function useVoiceLineCrudActions({\n  episodeId,\n  t,\n  voiceLines,\n  editingLineId,\n  editingContent,\n  editingSpeaker,\n  editingMatchedPanelId,\n  setVoiceLines,\n  setPendingVoiceGenerationByLineId,\n  setIsSavingLineEditor,\n  getBoundPanelIdForLine,\n  handleCancelEdit,\n  notifyVoiceLinesChanged,\n  createVoiceLineMutation,\n  updateVoiceLineMutation,\n  deleteVoiceLineMutation,\n}: UseVoiceLineCrudActionsParams) {\n  const handleSaveEdit = useCallback(async () => {\n    const content = editingContent.trim()\n    const speaker = editingSpeaker.trim()\n\n    if (!content || !speaker) {\n      alert(t('errors.invalidLineInput'))\n      return\n    }\n\n    setIsSavingLineEditor(true)\n    try {\n      if (editingLineId) {\n        const originalLine = voiceLines.find((line) => line.id === editingLineId)\n        if (!originalLine) return\n\n        const originalMatchedPanelId = getBoundPanelIdForLine(originalLine)\n        if (\n          content === originalLine.content &&\n          speaker === originalLine.speaker &&\n          editingMatchedPanelId === originalMatchedPanelId\n        ) {\n          handleCancelEdit()\n          return\n        }\n\n        const data = await updateVoiceLineMutation.mutateAsync({\n          lineId: editingLineId,\n          content,\n          speaker,\n          matchedPanelId: editingMatchedPanelId || null,\n        })\n        const updatedLine = data.voiceLine as VoiceLine\n        setVoiceLines((prev) => prev.map((line) => (line.id === editingLineId ? updatedLine : line)))\n      } else {\n        const data = await createVoiceLineMutation.mutateAsync({\n          episodeId,\n          content,\n          speaker,\n          matchedPanelId: editingMatchedPanelId || null,\n        })\n        const createdLine = data.voiceLine as VoiceLine\n        setVoiceLines((prev) => [...prev, createdLine].sort((left, right) => left.lineIndex - right.lineIndex))\n      }\n\n      notifyVoiceLinesChanged()\n      handleCancelEdit()\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        const message = editingLineId ? t('errors.saveFailed') : t('errors.addFailed')\n        alert(`${message}: ${getErrorMessage(error)}`)\n      }\n    } finally {\n      setIsSavingLineEditor(false)\n    }\n  }, [\n    createVoiceLineMutation,\n    editingContent,\n    editingLineId,\n    editingMatchedPanelId,\n    editingSpeaker,\n    episodeId,\n    getBoundPanelIdForLine,\n    handleCancelEdit,\n    notifyVoiceLinesChanged,\n    setIsSavingLineEditor,\n    setVoiceLines,\n    t,\n    updateVoiceLineMutation,\n    voiceLines,\n  ])\n\n  const handleDeleteLine = useCallback(async (lineId: string) => {\n    const line = voiceLines.find((item) => item.id === lineId)\n    if (!line) return\n\n    const content = line.content.slice(0, 50) + (line.content.length > 50 ? '...' : '')\n    const confirmed = window.confirm(t('confirm.deleteLine', { content }))\n    if (!confirmed) return\n\n    try {\n      await deleteVoiceLineMutation.mutateAsync({ lineId })\n      setVoiceLines((prev) => {\n        const filtered = prev.filter((item) => item.id !== lineId)\n        return filtered.map((item, index) => ({ ...item, lineIndex: index + 1 }))\n      })\n      setPendingVoiceGenerationByLineId((prev) => {\n        if (!(lineId in prev)) return prev\n        const next = { ...prev }\n        delete next[lineId]\n        return next\n      })\n      notifyVoiceLinesChanged()\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(`${t('errors.deleteFailed')}: ${getErrorMessage(error)}`)\n      }\n    }\n  }, [\n    deleteVoiceLineMutation,\n    notifyVoiceLinesChanged,\n    setPendingVoiceGenerationByLineId,\n    setVoiceLines,\n    t,\n    voiceLines,\n  ])\n\n  const handleDeleteAudio = useCallback(async (lineId: string) => {\n    const line = voiceLines.find((item) => item.id === lineId)\n    if (!line || !line.audioUrl) return\n\n    const content = line.content.slice(0, 50) + (line.content.length > 50 ? '...' : '')\n    const confirmed = window.confirm(t('confirm.deleteAudio', { content }))\n    if (!confirmed) return\n\n    try {\n      await updateVoiceLineMutation.mutateAsync({ lineId, audioUrl: null })\n      setVoiceLines((prev) => prev.map((item) => (item.id === lineId ? { ...item, audioUrl: null } : item)))\n      notifyVoiceLinesChanged()\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(`${t('errors.deleteAudioFailed')}: ${getErrorMessage(error)}`)\n      }\n    }\n  }, [notifyVoiceLinesChanged, setVoiceLines, t, updateVoiceLineMutation, voiceLines])\n\n  const handleSaveEmotionSettings = useCallback(async (\n    lineId: string,\n    emotionPrompt: string | null,\n    emotionStrength: number,\n  ) => {\n    try {\n      await updateVoiceLineMutation.mutateAsync({ lineId, emotionPrompt, emotionStrength })\n      setVoiceLines((prev) => prev.map((line) => (\n        line.id === lineId ? { ...line, emotionPrompt, emotionStrength } : line\n      )))\n    } catch (error: unknown) {\n      if (shouldShowError(error)) {\n        alert(`${t('errors.emotionSaveFailed')}: ${getErrorMessage(error)}`)\n      }\n    }\n  }, [setVoiceLines, t, updateVoiceLineMutation])\n\n  return {\n    handleSaveEdit,\n    handleDeleteLine,\n    handleDeleteAudio,\n    handleSaveEmotionSettings,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useVoiceLineEditorState.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useState } from 'react'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\nimport type { VoiceLine } from './types'\n\ninterface UseVoiceLineEditorStateParams {\n  speakerOptions: string[]\n}\n\nexport function useVoiceLineEditorState({\n  speakerOptions,\n}: UseVoiceLineEditorStateParams) {\n  const [isLineEditorOpen, setIsLineEditorOpen] = useState(false)\n  const [isSavingLineEditor, setIsSavingLineEditor] = useState(false)\n  const [editingLineId, setEditingLineId] = useState<string | null>(null)\n  const [editingContent, setEditingContent] = useState('')\n  const [editingSpeaker, setEditingSpeaker] = useState('')\n  const [editingMatchedPanelId, setEditingMatchedPanelId] = useState('')\n\n  const savingLineEditorState = isSavingLineEditor\n    ? resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'modify',\n      resource: 'audio',\n      hasOutput: false,\n    })\n    : null\n\n  useEffect(() => {\n    if (!isLineEditorOpen) return\n    if (editingLineId) return\n    if (editingSpeaker) return\n    if (speakerOptions.length === 0) return\n    setEditingSpeaker(speakerOptions[0])\n  }, [editingLineId, editingSpeaker, isLineEditorOpen, speakerOptions])\n\n  const handleStartAdd = useCallback(() => {\n    setEditingLineId(null)\n    setEditingContent('')\n    setEditingSpeaker(speakerOptions[0] || '')\n    setEditingMatchedPanelId('')\n    setIsLineEditorOpen(true)\n  }, [speakerOptions])\n\n  const handleStartEdit = useCallback((line: VoiceLine, boundPanelId: string) => {\n    setEditingLineId(line.id)\n    setEditingContent(line.content)\n    setEditingSpeaker(line.speaker)\n    setEditingMatchedPanelId(boundPanelId)\n    setIsLineEditorOpen(true)\n  }, [])\n\n  const handleCancelEdit = useCallback(() => {\n    setEditingLineId(null)\n    setEditingContent('')\n    setEditingSpeaker('')\n    setEditingMatchedPanelId('')\n    setIsLineEditorOpen(false)\n    setIsSavingLineEditor(false)\n  }, [])\n\n  return {\n    isLineEditorOpen,\n    isSavingLineEditor,\n    editingLineId,\n    editingContent,\n    editingSpeaker,\n    editingMatchedPanelId,\n    savingLineEditorState,\n    setIsSavingLineEditor,\n    setEditingContent,\n    setEditingSpeaker,\n    setEditingMatchedPanelId,\n    handleStartAdd,\n    handleStartEdit,\n    handleCancelEdit,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useVoicePlayback.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\nexport function useVoicePlayback() {\n  const [playingLineId, setPlayingLineId] = useState<string | null>(null)\n  const audioRef = useRef<HTMLAudioElement | null>(null)\n\n  const handleTogglePlayAudio = useCallback((lineId: string, audioUrl: string) => {\n    const currentAudio = audioRef.current\n\n    if (currentAudio && playingLineId === lineId) {\n      if (currentAudio.paused) {\n        currentAudio.play().then(() => setPlayingLineId(lineId)).catch(() => setPlayingLineId(null))\n      } else {\n        currentAudio.pause()\n        setPlayingLineId(null)\n      }\n      return\n    }\n\n    if (currentAudio) {\n      currentAudio.pause()\n      currentAudio.currentTime = 0\n    }\n\n    const audio = new Audio(audioUrl)\n    audioRef.current = audio\n    setPlayingLineId(lineId)\n\n    audio.onended = () => {\n      setPlayingLineId(null)\n      if (audioRef.current === audio) audioRef.current = null\n    }\n    audio.onpause = () => {\n      if (!audio.ended) setPlayingLineId(null)\n    }\n\n    audio.play().catch(() => setPlayingLineId(null))\n  }, [playingLineId])\n\n  useEffect(() => {\n    return () => {\n      if (!audioRef.current) return\n      audioRef.current.pause()\n      audioRef.current.currentTime = 0\n      audioRef.current = null\n    }\n  }, [])\n\n  return {\n    playingLineId,\n    handleTogglePlayAudio,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useVoiceRuntimeSync.ts",
    "content": "'use client'\n\nimport { useEffect, useRef } from 'react'\nimport { apiFetch } from '@/lib/api-fetch'\nimport type {\n  PendingVoiceGenerationMap,\n  PendingVoiceGenerationState,\n  PendingVoiceTaskStatus,\n  VoiceLine,\n} from './types'\n\ninterface UseVoiceRuntimeSyncParams {\n  loadData: () => Promise<void>\n  voiceLines: VoiceLine[]\n  activeVoiceTaskLineIds: Set<string>\n  pendingVoiceGenerationByLineId: PendingVoiceGenerationMap\n  setPendingVoiceGenerationByLineId: React.Dispatch<React.SetStateAction<PendingVoiceGenerationMap>>\n  onTaskFailure?: (params: {\n    lineId: string\n    line: VoiceLine | null\n    taskId: string | null\n    errorMessage: string | null\n  }) => void\n}\n\nconst TASK_STATUS_POLL_INTERVAL_MS = 1200\nconst PENDING_RESULT_POLL_INTERVAL_MS = 1500\n\nfunction resolvePendingBaseline(pending: PendingVoiceGenerationState) {\n  const startedTs = Date.parse(pending.startedAt)\n  const submittedTs = pending.submittedUpdatedAt ? Date.parse(pending.submittedUpdatedAt) : Number.NaN\n  if (Number.isNaN(startedTs) && Number.isNaN(submittedTs)) return null\n  if (Number.isNaN(startedTs)) return pending.submittedUpdatedAt\n  if (Number.isNaN(submittedTs)) return pending.startedAt\n  return new Date(Math.max(startedTs, submittedTs)).toISOString()\n}\n\nfunction hasLineGenerationSettled(line: VoiceLine | undefined, pending: PendingVoiceGenerationState) {\n  if (!line) return true\n  const baseline = resolvePendingBaseline(pending)\n  const latestUpdatedAt = typeof line.updatedAt === 'string' ? line.updatedAt : null\n  if (!latestUpdatedAt) {\n    return baseline === null && !!line.audioUrl\n  }\n  if (!baseline) return true\n  const latestTs = Date.parse(latestUpdatedAt)\n  const baselineTs = Date.parse(baseline)\n  if (Number.isNaN(latestTs) || Number.isNaN(baselineTs)) {\n    return latestUpdatedAt !== baseline\n  }\n  return latestTs > baselineTs\n}\n\nasync function fetchTaskStatus(taskId: string): Promise<{\n  status: PendingVoiceTaskStatus\n  errorMessage: string | null\n}> {\n  const response = await apiFetch(`/api/tasks/${taskId}`, {\n    method: 'GET',\n    cache: 'no-store',\n  })\n  if (!response.ok) {\n    throw new Error(`task status fetch failed: ${taskId}`)\n  }\n  const payload = (await response.json()) as {\n    task?: { status?: PendingVoiceTaskStatus | null; errorMessage?: string | null; error?: { message?: string | null } | null } | null\n  }\n  const status = payload.task?.status\n  const errorMessage =\n    typeof payload.task?.error?.message === 'string'\n      ? payload.task.error.message\n      : typeof payload.task?.errorMessage === 'string'\n        ? payload.task.errorMessage\n        : null\n  if (\n    status === 'queued'\n    || status === 'processing'\n    || status === 'completed'\n    || status === 'failed'\n    || status === 'canceled'\n  ) {\n    return { status, errorMessage }\n  }\n  return { status: null, errorMessage }\n}\n\nexport function useVoiceRuntimeSync({\n  loadData,\n  voiceLines,\n  activeVoiceTaskLineIds,\n  pendingVoiceGenerationByLineId,\n  setPendingVoiceGenerationByLineId,\n  onTaskFailure,\n}: UseVoiceRuntimeSyncParams) {\n  const reportedFailedTaskIdsRef = useRef<Set<string>>(new Set())\n  const pendingEntries = Object.entries(pendingVoiceGenerationByLineId)\n  const pendingLineIds = pendingEntries.map(([lineId]) => lineId)\n  const completedPendingEntries = pendingEntries.filter(([, pending]) => pending.taskStatus === 'completed')\n\n  useEffect(() => {\n    void loadData()\n  }, [loadData])\n\n  useEffect(() => {\n    for (const [lineId, pending] of pendingEntries) {\n      if (pending.taskStatus !== 'failed' && pending.taskStatus !== 'canceled') continue\n      const failureKey = pending.taskId || lineId\n      if (reportedFailedTaskIdsRef.current.has(failureKey)) continue\n      reportedFailedTaskIdsRef.current.add(failureKey)\n      const line = voiceLines.find((item) => item.id === lineId) || null\n      onTaskFailure?.({\n        lineId,\n        line,\n        taskId: pending.taskId,\n        errorMessage: pending.taskErrorMessage,\n      })\n    }\n  }, [onTaskFailure, pendingEntries, voiceLines])\n\n  useEffect(() => {\n    if (pendingLineIds.length === 0) return\n    setPendingVoiceGenerationByLineId((prev) => {\n      let changed = false\n      const next: PendingVoiceGenerationMap = { ...prev }\n      for (const [lineId, pending] of Object.entries(prev)) {\n        const line = voiceLines.find((item) => item.id === lineId)\n        const failed = pending.taskStatus === 'failed'\n        const settled = pending.taskStatus === 'completed' && hasLineGenerationSettled(line, pending)\n        if (failed || settled) {\n          delete next[lineId]\n          changed = true\n        }\n      }\n      return changed ? next : prev\n    })\n  }, [\n    activeVoiceTaskLineIds,\n    pendingLineIds.length,\n    setPendingVoiceGenerationByLineId,\n    voiceLines,\n  ])\n\n  useEffect(() => {\n    const taskEntries = pendingEntries.filter(([, pending]) =>\n      !!pending.taskId && pending.taskStatus !== 'completed' && pending.taskStatus !== 'failed',\n    )\n    if (taskEntries.length === 0) return\n    let cancelled = false\n    const pollTaskStatuses = async () => {\n      await Promise.all(taskEntries.map(async ([lineId, pending]) => {\n        if (!pending.taskId) return\n        try {\n          const taskSnapshot = await fetchTaskStatus(pending.taskId)\n          if (cancelled) return\n          setPendingVoiceGenerationByLineId((prev) => {\n            const current = prev[lineId]\n            if (\n              !current ||\n              current.taskId !== pending.taskId ||\n              (\n                current.taskStatus === taskSnapshot.status &&\n                current.taskErrorMessage === taskSnapshot.errorMessage\n              )\n            ) {\n              return prev\n            }\n            return {\n              ...prev,\n              [lineId]: {\n                ...current,\n                taskStatus: taskSnapshot.status,\n                taskErrorMessage: taskSnapshot.errorMessage,\n              },\n            }\n          })\n        } catch (error) {\n          void error\n        }\n      }))\n    }\n    void pollTaskStatuses()\n    const timer = window.setInterval(() => {\n      void pollTaskStatuses()\n    }, TASK_STATUS_POLL_INTERVAL_MS)\n    return () => {\n      cancelled = true\n      window.clearInterval(timer)\n    }\n  }, [activeVoiceTaskLineIds, pendingEntries, pendingVoiceGenerationByLineId, setPendingVoiceGenerationByLineId])\n\n  useEffect(() => {\n    if (completedPendingEntries.length === 0) return\n    void loadData()\n    const timer = window.setInterval(() => {\n      void loadData()\n    }, PENDING_RESULT_POLL_INTERVAL_MS)\n    return () => {\n      window.clearInterval(timer)\n    }\n  }, [completedPendingEntries, loadData, pendingVoiceGenerationByLineId])\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useVoiceSpeakerState.ts",
    "content": "'use client'\n\nimport { useCallback, useMemo } from 'react'\nimport { getSpeakerVoicePreviewUrl, hasAnyVoiceBinding } from '@/lib/voice/provider-voice-binding'\nimport type { Character, SpeakerVoiceEntry, VoiceLine } from './types'\n\ninterface UseVoiceSpeakerStateParams {\n  characters: Character[]\n  voiceLines: VoiceLine[]\n  projectSpeakers: string[]\n  speakerVoices: Record<string, SpeakerVoiceEntry>\n}\n\nexport function useVoiceSpeakerState({\n  characters,\n  voiceLines,\n  projectSpeakers,\n  speakerVoices,\n}: UseVoiceSpeakerStateParams) {\n  const matchCharacterBySpeaker = useCallback((speaker: string): Character | undefined => {\n    const exactMatch = characters.find((character) => character.name === speaker)\n    if (exactMatch) return exactMatch\n    return characters.find((character) => character.name.includes(speaker) || speaker.includes(character.name))\n  }, [characters])\n\n  const speakerCharacterMap = useMemo(() => {\n    const map: Record<string, Character> = {}\n    const speakerSet = new Set<string>(voiceLines.map((line) => line.speaker))\n    speakerSet.forEach((speaker) => {\n      const character = matchCharacterBySpeaker(speaker)\n      if (character) map[speaker] = character\n    })\n    return map\n  }, [matchCharacterBySpeaker, voiceLines])\n\n  const getSpeakerVoiceUrl = useCallback((speaker: string): string | null => {\n    const character = speakerCharacterMap[speaker]\n    if (character?.customVoiceUrl) return character.customVoiceUrl\n    const speakerVoice = speakerVoices[speaker]\n    return getSpeakerVoicePreviewUrl(speakerVoice)\n  }, [speakerCharacterMap, speakerVoices])\n\n  const hasSpeakerVoiceBinding = useCallback((speaker: string): boolean => {\n    const character = speakerCharacterMap[speaker]\n    const speakerVoice = speakerVoices[speaker]\n    return hasAnyVoiceBinding({\n      character,\n      speakerVoice,\n    })\n  }, [speakerCharacterMap, speakerVoices])\n\n  const speakerStats = useMemo(() => {\n    const stats: Record<string, number> = {}\n    for (const line of voiceLines) {\n      stats[line.speaker] = (stats[line.speaker] || 0) + 1\n    }\n    return stats\n  }, [voiceLines])\n\n  const speakers = useMemo(() => Object.keys(speakerStats), [speakerStats])\n\n  const speakerOptions = useMemo(() => {\n    const all = new Set<string>()\n    projectSpeakers.forEach((speaker) => all.add(speaker))\n    voiceLines.forEach((line) => all.add(line.speaker))\n    characters.forEach((character) => {\n      if (character.name) all.add(character.name)\n    })\n    return Array.from(all).filter(Boolean).sort((left, right) => left.localeCompare(right))\n  }, [characters, projectSpeakers, voiceLines])\n\n  const linesWithVoice = useMemo(() => (\n    voiceLines.filter((line) => hasSpeakerVoiceBinding(line.speaker)).length\n  ), [hasSpeakerVoiceBinding, voiceLines])\n\n  const linesWithAudio = useMemo(() => (\n    voiceLines.filter((line) => !!line.audioUrl).length\n  ), [voiceLines])\n\n  const allSpeakersHaveVoice = useMemo(() => (\n    speakers.every((speaker) => hasSpeakerVoiceBinding(speaker))\n  ), [hasSpeakerVoiceBinding, speakers])\n\n  return {\n    speakerCharacterMap,\n    speakerStats,\n    speakers,\n    speakerOptions,\n    matchCharacterBySpeaker,\n    getSpeakerVoiceUrl,\n    hasSpeakerVoiceBinding,\n    linesWithVoice,\n    linesWithAudio,\n    allSpeakersHaveVoice,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useVoiceStageDataLoader.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useFetchProjectVoiceStageData } from '@/lib/query/hooks'\nimport type { SpeakerVoiceEntry, VoiceLine } from './types'\n\ninterface UseVoiceStageDataLoaderParams {\n  projectId: string\n  episodeId: string\n}\n\ninterface VoiceStageDataPayload {\n  voiceLines?: VoiceLine[]\n  speakerVoices?: Record<string, SpeakerVoiceEntry>\n  speakers?: string[]\n}\n\nexport function useVoiceStageDataLoader({\n  projectId,\n  episodeId,\n}: UseVoiceStageDataLoaderParams) {\n  const fetchVoiceStageDataMutation = useFetchProjectVoiceStageData(projectId)\n  const fetchVoiceStageDataRef = useRef(fetchVoiceStageDataMutation)\n  fetchVoiceStageDataRef.current = fetchVoiceStageDataMutation\n  const hasLoadedOnceRef = useRef(false)\n\n  const [voiceLines, setVoiceLines] = useState<VoiceLine[]>([])\n  const [speakerVoices, setSpeakerVoices] = useState<Record<string, SpeakerVoiceEntry>>({})\n  const [projectSpeakers, setProjectSpeakers] = useState<string[]>([])\n  const [loading, setLoading] = useState(true)\n\n  useEffect(() => {\n    hasLoadedOnceRef.current = false\n    setLoading(true)\n  }, [episodeId])\n\n  const loadData = useCallback(async () => {\n    const isInitialLoad = !hasLoadedOnceRef.current\n    if (isInitialLoad) {\n      setLoading(true)\n    }\n    try {\n      const data = await fetchVoiceStageDataRef.current.mutateAsync({ episodeId })\n      const payload = (data || {}) as VoiceStageDataPayload\n      setVoiceLines(payload.voiceLines || [])\n      setSpeakerVoices(payload.speakerVoices || {})\n      setProjectSpeakers(payload.speakers || [])\n    } catch (error) {\n      _ulogError('Load data error:', error)\n    } finally {\n      hasLoadedOnceRef.current = true\n      setLoading(false)\n    }\n  }, [episodeId])\n\n  return {\n    voiceLines,\n    setVoiceLines,\n    speakerVoices,\n    setSpeakerVoices,\n    projectSpeakers,\n    setProjectSpeakers,\n    loading,\n    loadData,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/useVoiceTaskState.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { useVoiceTaskPresentation } from '@/lib/query/hooks/useTaskPresentation'\nimport { resolveTaskPresentationState, type TaskPresentationState } from '@/lib/task/presentation'\nimport type { VoiceLine } from './types'\n\ninterface UseVoiceTaskStateParams {\n  projectId: string\n  voiceLines: VoiceLine[]\n  submittingVoiceLineIds: Set<string>\n}\n\nexport function useVoiceTaskState({\n  projectId,\n  voiceLines,\n  submittingVoiceLineIds,\n}: UseVoiceTaskStateParams) {\n  const voiceLineTargets = useMemo(() => {\n    return voiceLines.map((line) => ({\n      key: `line:${line.id}`,\n      targetType: 'NovelPromotionVoiceLine',\n      targetId: line.id,\n      types: ['voice_line'],\n      resource: 'audio' as const,\n      hasOutput: !!line.audioUrl,\n    }))\n  }, [voiceLines])\n\n  const voiceTaskStates = useVoiceTaskPresentation(projectId, voiceLineTargets, {\n    enabled: !!projectId && voiceLineTargets.length > 0,\n  })\n\n  const voiceStatusStateByLineId = useMemo(() => {\n    const map = new Map<string, TaskPresentationState>()\n    for (const line of voiceLines) {\n      const state = voiceTaskStates.getTaskState(`line:${line.id}`)\n      if (!state) continue\n      const presentation = resolveTaskPresentationState({\n        phase: state.phase,\n        intent: state.intent,\n        resource: 'audio',\n        hasOutput: !!line.audioUrl || !!state.hasOutputAtStart,\n      })\n      map.set(line.id, presentation)\n    }\n    return map\n  }, [voiceLines, voiceTaskStates])\n\n  const activeVoiceTaskLineIds = useMemo(() => {\n    const ids = new Set<string>()\n    for (const line of voiceLines) {\n      const state = voiceTaskStates.getTaskState(`line:${line.id}`)\n      if (!state) continue\n      if (state.phase === 'queued' || state.phase === 'processing') {\n        ids.add(line.id)\n      }\n    }\n    return ids\n  }, [voiceLines, voiceTaskStates])\n\n  const runningLineIds = useMemo(() => {\n    const ids = new Set<string>(submittingVoiceLineIds)\n    for (const lineId of activeVoiceTaskLineIds) {\n      ids.add(lineId)\n    }\n    return ids\n  }, [activeVoiceTaskLineIds, submittingVoiceLineIds])\n\n  return {\n    voiceStatusStateByLineId,\n    activeVoiceTaskLineIds,\n    runningLineIds,\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime/utils.ts",
    "content": "'use client'\n\nimport { extractErrorMessage, extractErrorStatus } from '@/lib/errors/extract'\n\nexport function getErrorMessage(error: unknown): string {\n  return extractErrorMessage(error, 'Unknown error')\n}\n\nexport function getErrorStatus(error: unknown): number | null {\n  return extractErrorStatus(error)\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/stages/voice-stage-runtime-core.tsx",
    "content": "'use client'\n\nimport { useCallback, useMemo, useState } from 'react'\nimport { usePathname, useRouter, useSearchParams } from 'next/navigation'\nimport { useTranslations } from 'next-intl'\nimport { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'\nimport { useEpisodeData } from '@/lib/query/hooks/useProjectData'\nimport {\n  useAnalyzeProjectVoice,\n  useCreateProjectVoiceLine,\n  useDeleteProjectVoiceLine,\n  useDownloadProjectVoices,\n  useGenerateProjectVoice,\n  useUpdateProjectVoiceLine,\n  useUpdateSpeakerVoice,\n} from '@/lib/query/hooks'\nimport VoiceLineList from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice-stage/VoiceLineList'\nimport VoiceControlPanel from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice-stage/VoiceControlPanel'\nimport SpeakerVoiceBindingDialog from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/voice/SpeakerVoiceBindingDialog'\nimport type {\n  Character,\n  InlineSpeakerVoiceBinding,\n  PendingVoiceGenerationMap,\n  VoiceStageShellProps,\n} from './voice-stage-runtime/types'\nimport { useVoicePlayback } from './voice-stage-runtime/useVoicePlayback'\nimport { useVoiceLineEditorState } from './voice-stage-runtime/useVoiceLineEditorState'\nimport { useVoiceTaskState } from './voice-stage-runtime/useVoiceTaskState'\nimport { useBindablePanelOptions } from './voice-stage-runtime/useBindablePanelOptions'\nimport { useVoiceSpeakerState } from './voice-stage-runtime/useVoiceSpeakerState'\nimport { useVoiceStageDataLoader } from './voice-stage-runtime/useVoiceStageDataLoader'\nimport { useSpeakerAssetNavigation } from './voice-stage-runtime/useSpeakerAssetNavigation'\nimport { useVoiceGenerationActions } from './voice-stage-runtime/useVoiceGenerationActions'\nimport { useVoiceLineCrudActions } from './voice-stage-runtime/useVoiceLineCrudActions'\nimport { useVoiceRuntimeSync } from './voice-stage-runtime/useVoiceRuntimeSync'\nimport { useVoiceLineBindings } from './voice-stage-runtime/useVoiceLineBindings'\n\nexport type { VoiceStageShellProps } from './voice-stage-runtime/types'\n\nexport function useVoiceStageRuntime({\n  projectId,\n  episodeId,\n  onBack,\n  embedded = false,\n  onVoiceLineClick,\n  onVoiceLinesChanged,\n  onOpenAssetLibraryForCharacter,\n}: VoiceStageShellProps) {\n  const t = useTranslations('voice')\n  const router = useRouter()\n  const pathname = usePathname()\n  const searchParams = useSearchParams()\n  if (!pathname) {\n    throw new Error('VoiceStage requires a non-null pathname')\n  }\n  if (!searchParams) {\n    throw new Error('VoiceStage requires searchParams')\n  }\n  const { data: assets } = useProjectAssets(projectId)\n  const { data: episodeData } = useEpisodeData(projectId, episodeId)\n  const analyzeVoiceMutation = useAnalyzeProjectVoice(projectId)\n  const generateVoiceMutation = useGenerateProjectVoice(projectId)\n  const createVoiceLineMutation = useCreateProjectVoiceLine(projectId)\n  const updateVoiceLineMutation = useUpdateProjectVoiceLine(projectId)\n  const deleteVoiceLineMutation = useDeleteProjectVoiceLine(projectId)\n  const downloadVoicesMutation = useDownloadProjectVoices(projectId)\n  const updateSpeakerVoiceMutation = useUpdateSpeakerVoice(projectId)\n  const characters: Character[] = useMemo(() => (assets?.characters ?? []) as Character[], [assets?.characters])\n  const {\n    voiceLines,\n    setVoiceLines,\n    speakerVoices,\n    projectSpeakers,\n    loading,\n    loadData,\n  } = useVoiceStageDataLoader({\n    projectId,\n    episodeId,\n  })\n  const notifyVoiceLinesChanged = useCallback(() => {\n    onVoiceLinesChanged?.()\n  }, [onVoiceLinesChanged])\n  const handleVoiceTaskFailure = useCallback((params: {\n    lineId: string\n    line: { lineIndex: number } | null\n    taskId: string | null\n    errorMessage: string | null\n  }) => {\n    const lineLabel = params.line ? `#${params.line.lineIndex}` : t('common.regenerate')\n    const reason = params.errorMessage?.trim() || t('errors.generateFailed')\n    alert(`${t('errors.generateFailed')} (${lineLabel}): ${reason}`)\n  }, [t])\n  const {\n    speakerCharacterMap,\n    speakerStats,\n    speakers,\n    speakerOptions,\n    matchCharacterBySpeaker,\n    getSpeakerVoiceUrl,\n    linesWithVoice,\n    linesWithAudio,\n    allSpeakersHaveVoice,\n  } = useVoiceSpeakerState({\n    characters,\n    voiceLines,\n    projectSpeakers,\n    speakerVoices,\n  })\n  const bindablePanelOptions = useBindablePanelOptions({\n    episodeData,\n    t: (key, values) => t(key, values as never),\n  })\n  const {\n    isLineEditorOpen,\n    isSavingLineEditor,\n    editingLineId,\n    editingContent,\n    editingSpeaker,\n    editingMatchedPanelId,\n    savingLineEditorState,\n    setIsSavingLineEditor,\n    setEditingContent,\n    setEditingSpeaker,\n    setEditingMatchedPanelId,\n    handleStartAdd,\n    handleStartEdit,\n    handleCancelEdit,\n  } = useVoiceLineEditorState({\n    speakerOptions,\n  })\n  const { playingLineId, handleTogglePlayAudio } = useVoicePlayback()\n  const [pendingVoiceGenerationByLineId, setPendingVoiceGenerationByLineId] = useState<PendingVoiceGenerationMap>({})\n  const submittingVoiceLineIds = useMemo(\n    () => new Set(Object.keys(pendingVoiceGenerationByLineId)),\n    [pendingVoiceGenerationByLineId],\n  )\n  const { voiceStatusStateByLineId, activeVoiceTaskLineIds, runningLineIds } = useVoiceTaskState({\n    projectId,\n    voiceLines,\n    submittingVoiceLineIds,\n  })\n  useVoiceRuntimeSync({\n    loadData,\n    voiceLines,\n    activeVoiceTaskLineIds,\n    pendingVoiceGenerationByLineId,\n    setPendingVoiceGenerationByLineId,\n    onTaskFailure: handleVoiceTaskFailure,\n  })\n  const { handleOpenAssetLibraryForSpeaker } = useSpeakerAssetNavigation({\n    episodeId,\n    pathname,\n    router,\n    searchParams,\n    onOpenAssetLibraryForCharacter,\n    matchCharacterBySpeaker,\n  })\n  const {\n    analyzing,\n    isBatchSubmittingAll,\n    isDownloading,\n    handleAnalyze,\n    handleGenerateLine,\n    handleGenerateAll,\n    handleDownloadAll,\n  } = useVoiceGenerationActions({\n    projectId,\n    episodeId,\n    t: (key) => t(key as never),\n    voiceLines,\n    linesWithAudio,\n    speakerCharacterMap,\n    speakerVoices,\n    analyzeVoiceMutation,\n    generateVoiceMutation,\n    downloadVoicesMutation,\n    loadData,\n    notifyVoiceLinesChanged,\n    setPendingVoiceGenerationByLineId,\n  })\n  const {\n    getBoundPanelIdForLine,\n    handleStartEditLine,\n    handleLocatePanel,\n    handleDownloadSingle,\n  } = useVoiceLineBindings({\n    bindablePanelOptions,\n    onVoiceLineClick,\n    handleStartEdit,\n  })\n  const {\n    handleSaveEdit,\n    handleDeleteLine,\n    handleDeleteAudio,\n    handleSaveEmotionSettings,\n  } = useVoiceLineCrudActions({\n    episodeId,\n    t: (key, values) => t(key as never, values as never),\n    voiceLines,\n    editingLineId,\n    editingContent,\n    editingSpeaker,\n    editingMatchedPanelId,\n    setVoiceLines,\n    setPendingVoiceGenerationByLineId,\n    setIsSavingLineEditor,\n    getBoundPanelIdForLine,\n    handleCancelEdit,\n    notifyVoiceLinesChanged,\n    createVoiceLineMutation,\n    updateVoiceLineMutation,\n    deleteVoiceLineMutation,\n  })\n\n  // ─── 内联音色绑定弹窗状态 ───────────────────────────\n  const [inlineBindingSpeaker, setInlineBindingSpeaker] = useState<string | null>(null)\n\n  const handleOpenInlineBinding = useCallback((speaker: string) => {\n    setInlineBindingSpeaker(speaker)\n  }, [])\n\n  const handleCloseInlineBinding = useCallback(() => {\n    setInlineBindingSpeaker(null)\n  }, [])\n\n  /**\n   * 判断发言人是否有匹配的项目角色\n   * 有匹配角色 → 跳转资产中心；无匹配 → 打开内联绑定弹窗\n   */\n  const hasSpeakerCharacter = useCallback((speaker: string): boolean => {\n    return !!matchCharacterBySpeaker(speaker)\n  }, [matchCharacterBySpeaker])\n\n  /**\n   * 内联绑定完成后的回调：将音色信息写入 episode.speakerVoices\n   */\n  const handleInlineVoiceBound = useCallback(async (\n    speaker: string,\n    binding: InlineSpeakerVoiceBinding,\n  ) => {\n    try {\n      await updateSpeakerVoiceMutation.mutateAsync({\n        episodeId,\n        speaker,\n        ...binding,\n      })\n      // 重新加载数据以刷新 speakerVoices\n      await loadData()\n    } catch {\n      // 处理后的错误会被 mutation 的 onError 捕获\n    }\n    setInlineBindingSpeaker(null)\n  }, [episodeId, loadData, updateSpeakerVoiceMutation])\n\n  if (loading) {\n    return (\n      <div className=\"flex items-center justify-center py-20\">\n        <div className=\"text-[var(--glass-text-tertiary)]\">{t('common.loading')}</div>\n      </div>\n    )\n  }\n\n  return (\n    <>\n      <VoiceControlPanel\n        embedded={embedded}\n        onBack={onBack}\n        analyzing={analyzing}\n        isBatchSubmittingAll={isBatchSubmittingAll}\n        isDownloading={isDownloading}\n        runningLineCount={runningLineIds.size}\n        allSpeakersHaveVoice={allSpeakersHaveVoice}\n        totalLines={voiceLines.length}\n        linesWithVoice={linesWithVoice}\n        linesWithAudio={linesWithAudio}\n        speakers={speakers}\n        speakerStats={speakerStats}\n        isLineEditorOpen={isLineEditorOpen}\n        isSavingLineEditor={isSavingLineEditor}\n        editingLineId={editingLineId}\n        editingContent={editingContent}\n        editingSpeaker={editingSpeaker}\n        editingMatchedPanelId={editingMatchedPanelId}\n        speakerOptions={speakerOptions}\n        bindablePanelOptions={bindablePanelOptions}\n        savingLineEditorState={savingLineEditorState}\n        onAnalyze={handleAnalyze}\n        onGenerateAll={handleGenerateAll}\n        onDownloadAll={handleDownloadAll}\n        onStartAdd={handleStartAdd}\n        onOpenAssetLibraryForSpeaker={handleOpenAssetLibraryForSpeaker}\n        onOpenInlineBinding={handleOpenInlineBinding}\n        hasSpeakerCharacter={hasSpeakerCharacter}\n        onCancelEdit={handleCancelEdit}\n        onSaveEdit={handleSaveEdit}\n        onEditingContentChange={setEditingContent}\n        onEditingSpeakerChange={setEditingSpeaker}\n        onEditingMatchedPanelIdChange={setEditingMatchedPanelId}\n        getSpeakerVoiceUrl={getSpeakerVoiceUrl}\n      >\n        <VoiceLineList\n          voiceLines={voiceLines}\n          runningLineIds={runningLineIds}\n          voiceStatusStateByLineId={voiceStatusStateByLineId}\n          playingLineId={playingLineId}\n          analyzing={analyzing}\n          getSpeakerVoiceUrl={getSpeakerVoiceUrl}\n          onTogglePlayAudio={handleTogglePlayAudio}\n          onDownloadSingle={handleDownloadSingle}\n          onGenerateLine={handleGenerateLine}\n          onStartEdit={handleStartEditLine}\n          onLocatePanel={handleLocatePanel}\n          onDeleteLine={handleDeleteLine}\n          onDeleteAudio={handleDeleteAudio}\n          onSaveEmotionSettings={handleSaveEmotionSettings}\n          onAnalyze={handleAnalyze}\n        />\n      </VoiceControlPanel>\n\n      {/* 内联音色绑定弹窗 */}\n      <SpeakerVoiceBindingDialog\n        isOpen={!!inlineBindingSpeaker}\n        speaker={inlineBindingSpeaker ?? ''}\n        projectId={projectId}\n        episodeId={episodeId}\n        onClose={handleCloseInlineBinding}\n        onBound={handleInlineVoiceBound}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/story-to-script/clip-matching.ts",
    "content": "export type ClipMatchLevel = 'L1' | 'L2' | 'L3'\nexport type TextMatchLevel = ClipMatchLevel\n\nexport type TextMarkerMatch = {\n  startIndex: number\n  endIndex: number\n  level: TextMatchLevel\n  confidence: number\n}\n\nexport type ClipBoundaryMatch = {\n  startIndex: number\n  endIndex: number\n  level: ClipMatchLevel\n  confidence: number\n}\n\nexport type ClipContentMatcher = {\n  matchBoundary: (startText: string, endText: string, fromIndex: number) => ClipBoundaryMatch | null\n}\n\nexport type TextMarkerMatcher = {\n  matchMarker: (markerText: string, fromIndex: number) => TextMarkerMatch | null\n}\n\ntype NormalizedContent = {\n  text: string\n  rawStartByNorm: number[]\n  rawEndByNorm: number[]\n}\n\ntype ApproximateNormMatch = {\n  startNorm: number\n  endNorm: number\n  confidence: number\n}\n\nconst APPROX_CONFIDENCE_THRESHOLD = 0.9\nconst APPROX_MAX_CANDIDATES = 240\n\nconst PUNCTUATION_MAP: Record<string, string> = {\n  '，': ',',\n  '。': '.',\n  '！': '!',\n  '？': '?',\n  '；': ';',\n  '：': ':',\n  '（': '(',\n  '）': ')',\n  '【': '[',\n  '】': ']',\n  '《': '<',\n  '》': '>',\n  '「': '\"',\n  '」': '\"',\n  '『': '\"',\n  '』': '\"',\n  '“': '\"',\n  '”': '\"',\n  '‘': \"'\",\n  '’': \"'\",\n  '、': ',',\n  '…': '...',\n}\n\nfunction normalizeChar(ch: string): string {\n  const code = ch.charCodeAt(0)\n  let normalized = ch\n  if (code === 0x3000) {\n    normalized = ' '\n  } else if (code >= 0xff01 && code <= 0xff5e) {\n    normalized = String.fromCharCode(code - 0xfee0)\n  }\n  const mapped = PUNCTUATION_MAP[normalized]\n  return (mapped ?? normalized).toLowerCase()\n}\n\nfunction isWhitespace(ch: string): boolean {\n  return /\\s/u.test(ch)\n}\n\nfunction buildNormalizedContent(raw: string): NormalizedContent {\n  const rawStartByNorm: number[] = []\n  const rawEndByNorm: number[] = []\n  let text = ''\n\n  for (let i = 0; i < raw.length; i += 1) {\n    const transformed = normalizeChar(raw[i])\n    for (let j = 0; j < transformed.length; j += 1) {\n      const ch = transformed[j]\n      if (isWhitespace(ch)) continue\n      text += ch\n      rawStartByNorm.push(i)\n      rawEndByNorm.push(i + 1)\n    }\n  }\n\n  return {\n    text,\n    rawStartByNorm,\n    rawEndByNorm,\n  }\n}\n\nfunction normalizeQuery(text: string): string {\n  return buildNormalizedContent(text).text\n}\n\nfunction findNormIndexForRaw(normalized: NormalizedContent, rawIndex: number): number {\n  if (normalized.rawStartByNorm.length === 0) return 0\n  let left = 0\n  let right = normalized.rawStartByNorm.length\n  while (left < right) {\n    const mid = left + ((right - left) >> 1)\n    if (normalized.rawStartByNorm[mid] < rawIndex) {\n      left = mid + 1\n    } else {\n      right = mid\n    }\n  }\n  return left\n}\n\nfunction tryExactRawMatch(content: string, startText: string, endText: string, fromIndex: number): ClipBoundaryMatch | null {\n  let startCursor = Math.max(0, fromIndex)\n  while (startCursor < content.length) {\n    const startIndex = content.indexOf(startText, startCursor)\n    if (startIndex === -1) return null\n    const endIndex = content.indexOf(endText, startIndex + startText.length)\n    if (endIndex !== -1) {\n      return {\n        startIndex,\n        endIndex: endIndex + endText.length,\n        level: 'L1',\n        confidence: 1,\n      }\n    }\n    startCursor = startIndex + 1\n  }\n  return null\n}\n\nfunction tryExactRawMarkerMatch(content: string, markerText: string, fromIndex: number): TextMarkerMatch | null {\n  const markerIndex = content.indexOf(markerText, Math.max(0, fromIndex))\n  if (markerIndex === -1) return null\n  return {\n    startIndex: markerIndex,\n    endIndex: markerIndex + markerText.length,\n    level: 'L1',\n    confidence: 1,\n  }\n}\n\nfunction tryExactNormalizedMatch(\n  normalized: NormalizedContent,\n  startQuery: string,\n  endQuery: string,\n  fromIndex: number,\n): ClipBoundaryMatch | null {\n  let startNormCursor = findNormIndexForRaw(normalized, fromIndex)\n  while (startNormCursor < normalized.text.length) {\n    const startNormIndex = normalized.text.indexOf(startQuery, startNormCursor)\n    if (startNormIndex === -1) return null\n\n    const rawStart = normalized.rawStartByNorm[startNormIndex]\n    if (rawStart < fromIndex) {\n      startNormCursor = startNormIndex + 1\n      continue\n    }\n\n    let endNormCursor = startNormIndex + startQuery.length\n    while (endNormCursor < normalized.text.length) {\n      const endNormIndex = normalized.text.indexOf(endQuery, endNormCursor)\n      if (endNormIndex === -1) break\n      const endNormLast = endNormIndex + endQuery.length - 1\n      const rawEnd = normalized.rawEndByNorm[endNormLast]\n      if (rawEnd > rawStart) {\n        return {\n          startIndex: rawStart,\n          endIndex: rawEnd,\n          level: 'L2',\n          confidence: 0.97,\n        }\n      }\n      endNormCursor = endNormIndex + 1\n    }\n\n    startNormCursor = startNormIndex + 1\n  }\n  return null\n}\n\nfunction tryExactNormalizedMarkerMatch(\n  normalized: NormalizedContent,\n  markerQuery: string,\n  fromIndex: number,\n): TextMarkerMatch | null {\n  const fromNorm = findNormIndexForRaw(normalized, fromIndex)\n  const markerNormIndex = normalized.text.indexOf(markerQuery, fromNorm)\n  if (markerNormIndex === -1) return null\n\n  const rawStart = normalized.rawStartByNorm[markerNormIndex]\n  if (rawStart < fromIndex) return null\n  const markerNormLast = markerNormIndex + markerQuery.length - 1\n  const rawEnd = normalized.rawEndByNorm[markerNormLast]\n  return {\n    startIndex: rawStart,\n    endIndex: rawEnd,\n    level: 'L2',\n    confidence: 0.97,\n  }\n}\n\nfunction buildLengthCandidates(baseLength: number): number[] {\n  const delta = Math.max(2, Math.floor(baseLength * 0.2))\n  const half = Math.max(1, Math.floor(delta / 2))\n  const candidates = [\n    baseLength - delta,\n    baseLength - half,\n    baseLength,\n    baseLength + half,\n    baseLength + delta,\n  ].filter((value) => value > 0)\n\n  return [...new Set(candidates)]\n}\n\nfunction collectApproximateStarts(haystack: string, query: string, fromNorm: number): number[] {\n  const maxCandidates = APPROX_MAX_CANDIDATES\n  const candidates = new Set<number>()\n  const queryLength = query.length\n  const anchorLength = Math.min(4, queryLength)\n  const midOffset = Math.max(0, Math.floor((queryLength - anchorLength) / 2))\n  const endOffset = Math.max(0, queryLength - anchorLength)\n  const anchors: Array<{ text: string; offset: number }> = [\n    { text: query.slice(0, anchorLength), offset: 0 },\n    { text: query.slice(midOffset, midOffset + anchorLength), offset: midOffset },\n    { text: query.slice(endOffset, endOffset + anchorLength), offset: endOffset },\n  ]\n\n  for (const anchor of anchors) {\n    if (!anchor.text) continue\n    let anchorIdx = haystack.indexOf(anchor.text, fromNorm)\n    let scanCount = 0\n    while (anchorIdx !== -1 && scanCount < maxCandidates * 4) {\n      const candidateStart = anchorIdx - anchor.offset\n      if (candidateStart >= fromNorm && candidateStart < haystack.length) {\n        candidates.add(candidateStart)\n      }\n      if (candidates.size >= maxCandidates * 2) break\n      anchorIdx = haystack.indexOf(anchor.text, anchorIdx + 1)\n      scanCount += 1\n    }\n  }\n\n  if (candidates.size === 0) {\n    const stride = Math.max(1, Math.floor(queryLength / 2))\n    for (let i = fromNorm; i < haystack.length; i += stride) {\n      candidates.add(i)\n      if (candidates.size >= maxCandidates) break\n    }\n  }\n\n  const sorted = [...candidates].sort((a, b) => a - b)\n  if (sorted.length <= maxCandidates) return sorted\n\n  const sampled: number[] = []\n  const stride = sorted.length / maxCandidates\n  for (let i = 0; i < maxCandidates; i += 1) {\n    sampled.push(sorted[Math.floor(i * stride)])\n  }\n  return sampled\n}\n\nfunction levenshteinDistance(a: string, b: string, maxDistance: number): number {\n  if (a === b) return 0\n  const aLen = a.length\n  const bLen = b.length\n  if (Math.abs(aLen - bLen) > maxDistance) return maxDistance + 1\n\n  const prev = new Array<number>(bLen + 1)\n  const curr = new Array<number>(bLen + 1)\n  for (let j = 0; j <= bLen; j += 1) prev[j] = j\n\n  for (let i = 1; i <= aLen; i += 1) {\n    curr[0] = i\n    let rowMin = curr[0]\n    const aCode = a.charCodeAt(i - 1)\n    for (let j = 1; j <= bLen; j += 1) {\n      const substitution = aCode === b.charCodeAt(j - 1) ? 0 : 1\n      const insertCost = curr[j - 1] + 1\n      const deleteCost = prev[j] + 1\n      const replaceCost = prev[j - 1] + substitution\n      const value = Math.min(insertCost, deleteCost, replaceCost)\n      curr[j] = value\n      if (value < rowMin) rowMin = value\n    }\n    if (rowMin > maxDistance) return maxDistance + 1\n    for (let j = 0; j <= bLen; j += 1) prev[j] = curr[j]\n  }\n\n  return prev[bLen]\n}\n\nfunction scoreApproximateSimilarity(query: string, candidate: string): number {\n  const maxLen = Math.max(query.length, candidate.length)\n  if (maxLen === 0) return 0\n  const allowedDistance = Math.floor((1 - APPROX_CONFIDENCE_THRESHOLD) * maxLen)\n  if (Math.abs(query.length - candidate.length) > allowedDistance) return 0\n  const distance = levenshteinDistance(query, candidate, allowedDistance)\n  if (distance > allowedDistance) return 0\n  return 1 - distance / maxLen\n}\n\nfunction findApproximateMatch(\n  normalized: NormalizedContent,\n  query: string,\n  fromIndex: number,\n): ApproximateNormMatch | null {\n  if (query.length < 8) return null\n  const fromNorm = findNormIndexForRaw(normalized, fromIndex)\n  const starts = collectApproximateStarts(normalized.text, query, fromNorm)\n  const lengthCandidates = buildLengthCandidates(query.length)\n\n  let best: ApproximateNormMatch | null = null\n  for (const startNorm of starts) {\n    for (const length of lengthCandidates) {\n      const endNorm = startNorm + length\n      if (endNorm > normalized.text.length) continue\n      const candidate = normalized.text.slice(startNorm, endNorm)\n      const confidence = scoreApproximateSimilarity(query, candidate)\n      if (confidence < APPROX_CONFIDENCE_THRESHOLD) continue\n      if (!best || confidence > best.confidence) {\n        best = {\n          startNorm,\n          endNorm,\n          confidence,\n        }\n      }\n    }\n  }\n\n  return best\n}\n\nfunction tryApproximateNormalizedMatch(\n  normalized: NormalizedContent,\n  startQuery: string,\n  endQuery: string,\n  fromIndex: number,\n): ClipBoundaryMatch | null {\n  const startApprox = findApproximateMatch(normalized, startQuery, fromIndex)\n  if (!startApprox) return null\n\n  const rawStart = normalized.rawStartByNorm[startApprox.startNorm]\n  if (rawStart < fromIndex) return null\n\n  const startRawEnd = normalized.rawEndByNorm[Math.max(0, startApprox.endNorm - 1)] ?? rawStart\n  const endApprox = findApproximateMatch(normalized, endQuery, Math.max(startRawEnd, rawStart))\n  if (!endApprox) return null\n\n  const rawEnd = normalized.rawEndByNorm[Math.max(0, endApprox.endNorm - 1)]\n  if (rawEnd <= rawStart) return null\n\n  return {\n    startIndex: rawStart,\n    endIndex: rawEnd,\n    level: 'L3',\n    confidence: Math.min(startApprox.confidence, endApprox.confidence),\n  }\n}\n\nfunction tryApproximateNormalizedMarkerMatch(\n  normalized: NormalizedContent,\n  markerQuery: string,\n  fromIndex: number,\n): TextMarkerMatch | null {\n  const markerApprox = findApproximateMatch(normalized, markerQuery, fromIndex)\n  if (!markerApprox) return null\n\n  const rawStart = normalized.rawStartByNorm[markerApprox.startNorm]\n  if (rawStart < fromIndex) return null\n  const rawEnd = normalized.rawEndByNorm[Math.max(0, markerApprox.endNorm - 1)]\n  if (rawEnd <= rawStart) return null\n\n  return {\n    startIndex: rawStart,\n    endIndex: rawEnd,\n    level: 'L3',\n    confidence: markerApprox.confidence,\n  }\n}\n\nexport function createTextMarkerMatcher(content: string): TextMarkerMatcher {\n  const normalized = buildNormalizedContent(content)\n\n  return {\n    matchMarker(markerText: string, fromIndex: number): TextMarkerMatch | null {\n      const marker = markerText.trim()\n      if (!marker) return null\n\n      const l1 = tryExactRawMarkerMatch(content, marker, fromIndex)\n      if (l1) return l1\n\n      const normalizedMarker = normalizeQuery(marker)\n      if (!normalizedMarker) return null\n\n      const l2 = tryExactNormalizedMarkerMatch(normalized, normalizedMarker, fromIndex)\n      if (l2) return l2\n\n      const l3 = tryApproximateNormalizedMarkerMatch(normalized, normalizedMarker, fromIndex)\n      if (l3) return l3\n\n      return null\n    },\n  }\n}\n\nexport function createClipContentMatcher(content: string): ClipContentMatcher {\n  const normalized = buildNormalizedContent(content)\n\n  return {\n    matchBoundary(startText: string, endText: string, fromIndex: number): ClipBoundaryMatch | null {\n      const start = startText.trim()\n      const end = endText.trim()\n      if (!start || !end) return null\n\n      const l1 = tryExactRawMatch(content, start, end, fromIndex)\n      if (l1) return l1\n\n      const normalizedStart = normalizeQuery(start)\n      const normalizedEnd = normalizeQuery(end)\n      if (!normalizedStart || !normalizedEnd) return null\n\n      const l2 = tryExactNormalizedMatch(normalized, normalizedStart, normalizedEnd, fromIndex)\n      if (l2) return l2\n\n      const l3 = tryApproximateNormalizedMatch(normalized, normalizedStart, normalizedEnd, fromIndex)\n      if (l3) return l3\n\n      return null\n    },\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/story-to-script/orchestrator.ts",
    "content": "import { safeParseJsonArray, safeParseJsonObject } from '@/lib/json-repair'\nimport { buildCharactersIntroduction } from '@/lib/constants'\nimport { normalizeAnyError } from '@/lib/errors/normalize'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport { createClipContentMatcher, type ClipMatchLevel } from './clip-matching'\nimport { mapWithConcurrency } from '@/lib/async/map-with-concurrency'\nimport {\n  DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY,\n  normalizeWorkflowConcurrencyValue,\n} from '@/lib/workflow-concurrency'\n\nexport type StoryToScriptStepMeta = {\n  stepId: string\n  stepAttempt?: number\n  stepTitle: string\n  stepIndex: number\n  stepTotal: number\n  dependsOn?: string[]\n  groupId?: string\n  parallelKey?: string\n  retryable?: boolean\n  blockedBy?: string[]\n}\n\nexport type StoryToScriptStepOutput = {\n  text: string\n  reasoning: string\n}\n\nexport type StoryToScriptClipCandidate = {\n  id: string\n  startText: string\n  endText: string\n  summary: string\n  location: string | null\n  characters: string[]\n  content: string\n  matchLevel: ClipMatchLevel\n  matchConfidence: number\n}\n\nexport type StoryToScriptScreenplayResult = {\n  clipId: string\n  success: boolean\n  sceneCount: number\n  screenplay?: Record<string, unknown>\n  error?: string\n}\n\nexport type StoryToScriptPromptTemplates = {\n  characterPromptTemplate: string\n  locationPromptTemplate: string\n  clipPromptTemplate: string\n  screenplayPromptTemplate: string\n}\n\nexport type StoryToScriptOrchestratorInput = {\n  concurrency?: number\n  content: string\n  baseCharacters: string[]\n  baseLocations: string[]\n  baseCharacterIntroductions: Array<{ name: string; introduction?: string | null }>\n  promptTemplates: StoryToScriptPromptTemplates\n  runStep: (\n    meta: StoryToScriptStepMeta,\n    prompt: string,\n    action: string,\n    maxOutputTokens: number,\n  ) => Promise<StoryToScriptStepOutput>\n  onStepError?: (meta: StoryToScriptStepMeta, message: string) => void\n  onLog?: (message: string, details?: Record<string, unknown>) => void\n}\n\nexport type StoryToScriptOrchestratorResult = {\n  characterStep: StoryToScriptStepOutput\n  locationStep: StoryToScriptStepOutput\n  splitStep: StoryToScriptStepOutput\n  charactersObject: Record<string, unknown>\n  locationsObject: Record<string, unknown>\n  analyzedCharacters: Record<string, unknown>[]\n  analyzedLocations: Record<string, unknown>[]\n  charactersLibName: string\n  locationsLibName: string\n  charactersIntroduction: string\n  clipList: StoryToScriptClipCandidate[]\n  screenplayResults: StoryToScriptScreenplayResult[]\n  summary: {\n    characterCount: number\n    locationCount: number\n    clipCount: number\n    screenplaySuccessCount: number\n    screenplayFailedCount: number\n    totalScenes: number\n  }\n}\nconst orchestratorLogger = createScopedLogger({ module: 'worker.orchestrator.story_to_script' })\n\nfunction applyTemplate(template: string, replacements: Record<string, string>) {\n  let next = template\n  for (const [key, value] of Object.entries(replacements)) {\n    next = next.replace(new RegExp(`\\\\{${key}\\\\}`, 'g'), value)\n  }\n  return next\n}\n\nfunction parseClipArray(responseText: string): Record<string, unknown>[] {\n  return safeParseJsonArray(responseText, 'clips')\n}\n\nfunction parseScreenplayObject(responseText: string): Record<string, unknown> {\n  return safeParseJsonObject(responseText)\n}\n\nfunction asString(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nfunction toStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value\n    .map((item) => (typeof item === 'string' ? item.trim() : ''))\n    .filter(Boolean)\n}\n\nfunction toObjectArray(value: unknown): Record<string, unknown>[] {\n  if (!Array.isArray(value)) return []\n  return value.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')\n}\n\nfunction extractAnalyzedCharacters(obj: Record<string, unknown>): Record<string, unknown>[] {\n  const primary = toObjectArray(obj.characters)\n  if (primary.length > 0) return primary\n  return toObjectArray(obj.new_characters)\n}\n\nfunction extractAnalyzedLocations(obj: Record<string, unknown>): Record<string, unknown>[] {\n  return toObjectArray(obj.locations)\n}\n\nconst MAX_STEP_ATTEMPTS = 3\nconst MAX_SPLIT_BOUNDARY_ATTEMPTS = 2\nconst MAX_RETRY_DELAY_MS = 10_000\nconst CLIP_BOUNDARY_SUFFIX = `\n\n[Boundary Constraints]\n1. The \"start\" and \"end\" anchors must come from the original text and be locatable.\n2. Allow punctuation/whitespace differences, but do not rewrite key entities or events.\n3. If anchors cannot be located reliably, return [] directly.`\n\nfunction wait(ms: number) {\n  return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nfunction computeRetryDelayMs(attempt: number) {\n  const base = Math.min(1_000 * Math.pow(2, Math.max(0, attempt - 1)), MAX_RETRY_DELAY_MS)\n  const jitter = Math.floor(Math.random() * 300)\n  return base + jitter\n}\n\nfunction isRecoverableJsonParseError(error: unknown, normalizedMessage: string): boolean {\n  if (normalizedMessage.includes('ark responses 调用失败')) return false\n  if (normalizedMessage.includes('invalidparameter')) return false\n  if (normalizedMessage.includes('unknown field')) return false\n\n  if (error instanceof SyntaxError) return true\n\n  return normalizedMessage.includes('unexpected token')\n    || normalizedMessage.includes('unexpected end of json input')\n    || normalizedMessage.includes('json format invalid')\n    || normalizedMessage.includes('invalid clip json format')\n}\n\nasync function runStepWithRetry<T>(\n  runStep: StoryToScriptOrchestratorInput['runStep'],\n  baseMeta: StoryToScriptStepMeta,\n  prompt: string,\n  action: string,\n  maxOutputTokens: number,\n  parse: (text: string) => T,\n): Promise<{ output: StoryToScriptStepOutput; parsed: T }> {\n  let lastError: Error | null = null\n  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {\n    const meta = attempt === 1\n      ? baseMeta\n      : {\n        ...baseMeta,\n        stepId: baseMeta.stepId,\n        stepAttempt: attempt,\n        stepTitle: baseMeta.stepTitle,\n      }\n    try {\n      const output = await runStep(meta, prompt, action, maxOutputTokens)\n      const parsed = parse(output.text)\n      return { output, parsed }\n    } catch (error) {\n      lastError = error instanceof Error ? error : new Error(String(error))\n      const normalizedError = normalizeAnyError(error, { context: 'worker' })\n      const lowerMessage = normalizedError.message.toLowerCase()\n      const shouldRetry = attempt < MAX_STEP_ATTEMPTS\n        && (\n          normalizedError.retryable\n          || isRecoverableJsonParseError(error, lowerMessage)\n        )\n\n      orchestratorLogger.error({\n        action: 'orchestrator.step.retry',\n        message: shouldRetry ? 'step failed, retrying' : 'step failed, no more retry',\n        errorCode: normalizedError.code,\n        retryable: normalizedError.retryable,\n        details: {\n          stepId: baseMeta.stepId,\n          action,\n          attempt,\n          maxAttempts: MAX_STEP_ATTEMPTS,\n        },\n        error: {\n          name: lastError.name,\n          message: lastError.message,\n          stack: lastError.stack,\n        },\n      })\n\n      if (!shouldRetry) {\n        break\n      }\n      await wait(computeRetryDelayMs(attempt))\n    }\n  }\n  throw lastError!\n}\n\nexport async function runStoryToScriptOrchestrator(\n  input: StoryToScriptOrchestratorInput,\n): Promise<StoryToScriptOrchestratorResult> {\n  const {\n    concurrency: rawConcurrency,\n    content,\n    baseCharacters,\n    baseLocations,\n    baseCharacterIntroductions,\n    promptTemplates,\n    runStep,\n    onStepError,\n    onLog,\n  } = input\n  const concurrency = normalizeWorkflowConcurrencyValue(\n    rawConcurrency,\n    DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY,\n  )\n\n  const baseCharactersText = baseCharacters.length > 0 ? baseCharacters.join('、') : '无'\n  const baseLocationsText = baseLocations.length > 0 ? baseLocations.join('、') : '无'\n  const baseCharacterInfo = baseCharacterIntroductions.length > 0\n    ? baseCharacterIntroductions.map((item, index) => `${index + 1}. ${item.name}`).join('\\n')\n    : '暂无已有角色'\n\n  const characterPrompt = applyTemplate(promptTemplates.characterPromptTemplate, {\n    input: content,\n    characters_lib_name: baseCharactersText,\n    characters_lib_info: baseCharacterInfo,\n  })\n  const locationPrompt = applyTemplate(promptTemplates.locationPromptTemplate, {\n    input: content,\n    locations_lib_name: baseLocationsText,\n  })\n\n  onLog?.('开始步骤1：角色/场景分析（并行）')\n  const analysisResults = await mapWithConcurrency(\n    [\n      () => runStepWithRetry(\n        runStep,\n        {\n          stepId: 'analyze_characters',\n          stepTitle: 'progress.streamStep.analyzeCharacters',\n          stepIndex: 1,\n          stepTotal: 2,\n          groupId: 'analysis',\n          parallelKey: 'characters',\n          retryable: true,\n        },\n        characterPrompt,\n        'analyze_characters',\n        2200,\n        safeParseJsonObject,\n      ),\n      () => runStepWithRetry(\n        runStep,\n        {\n          stepId: 'analyze_locations',\n          stepTitle: 'progress.streamStep.analyzeLocations',\n          stepIndex: 2,\n          stepTotal: 2,\n          groupId: 'analysis',\n          parallelKey: 'locations',\n          retryable: true,\n        },\n        locationPrompt,\n        'analyze_locations',\n        2200,\n        safeParseJsonObject,\n      ),\n    ],\n    concurrency,\n    async (run) => await run(),\n  )\n  const { output: characterStep, parsed: charactersObject } = analysisResults[0]\n  const { output: locationStep, parsed: locationsObject } = analysisResults[1]\n\n  const analyzedCharacters = extractAnalyzedCharacters(charactersObject)\n  const analyzedLocations = extractAnalyzedLocations(locationsObject)\n\n  const analyzedCharacterNames = analyzedCharacters\n    .map((item) => asString(item.name).trim())\n    .filter(Boolean)\n  const analyzedLocationNames = analyzedLocations\n    .map((item) => asString(item.name).trim())\n    .filter(Boolean)\n\n  // 合并新发现角色与已有角色库（新角色优先，已有角色补充），避免已有角色被覆盖丢失\n  const analyzedCharacterNameSet = new Set(analyzedCharacterNames)\n  const mergedCharacterNames = [\n    ...analyzedCharacterNames,\n    ...baseCharacters.filter((name) => !analyzedCharacterNameSet.has(name)),\n  ]\n  const charactersLibName = mergedCharacterNames.length > 0\n    ? mergedCharacterNames.join('、')\n    : baseCharactersText\n\n  const locationsLibName = analyzedLocationNames.length > 0\n    ? analyzedLocationNames.join('、')\n    : baseLocationsText\n\n  // 合并角色介绍：新角色 + 未被新角色覆盖的已有角色介绍\n  const mergedCharacterIntroductions = [\n    ...analyzedCharacters.map((item) => ({\n      name: asString(item.name),\n      introduction: asString(item.introduction),\n    })),\n    ...baseCharacterIntroductions\n      .filter((item) => !analyzedCharacterNameSet.has(item.name))\n      .map((item) => ({\n        name: item.name,\n        introduction: item.introduction || '',\n      })),\n  ]\n  const charactersIntroduction = buildCharactersIntroduction(\n    mergedCharacterIntroductions.length > 0\n      ? mergedCharacterIntroductions\n      : baseCharacterIntroductions.map((item) => ({\n        name: item.name,\n        introduction: item.introduction || '',\n      })),\n  )\n\n  onLog?.('开始步骤2：片段切分（最多重试1次）', {\n    charactersLibName,\n    locationsLibName,\n  })\n\n  const splitPromptBase = applyTemplate(promptTemplates.clipPromptTemplate, {\n    input: content,\n    locations_lib_name: locationsLibName || '无',\n    characters_lib_name: charactersLibName || '无',\n    characters_introduction: charactersIntroduction || '暂无角色介绍',\n  })\n  const splitPrompt = `${splitPromptBase}${CLIP_BOUNDARY_SUFFIX}`\n\n  let splitStep: StoryToScriptStepOutput | null = null\n  let clipList: StoryToScriptClipCandidate[] = []\n  let lastBoundaryError: Error | null = null\n\n  for (let attempt = 1; attempt <= MAX_SPLIT_BOUNDARY_ATTEMPTS; attempt += 1) {\n    const splitMeta: StoryToScriptStepMeta = {\n      stepId: 'split_clips',\n      stepAttempt: attempt,\n      stepTitle: 'progress.streamStep.splitClips',\n      stepIndex: 1,\n      stepTotal: 1,\n      dependsOn: ['analyze_characters', 'analyze_locations'],\n      retryable: true,\n    }\n\n    const { output, parsed: rawClipList } = await runStepWithRetry(\n      runStep,\n      splitMeta,\n      splitPrompt,\n      'split_clips',\n      2600,\n      parseClipArray,\n    )\n    if (rawClipList.length === 0) {\n      lastBoundaryError = new Error('split_clips returned empty clips')\n      onLog?.('片段切分结果为空', {\n        attempt,\n        maxAttempts: MAX_SPLIT_BOUNDARY_ATTEMPTS,\n      })\n      continue\n    }\n\n    const matcher = createClipContentMatcher(content)\n    const nextClipList: StoryToScriptClipCandidate[] = []\n    let searchFrom = 0\n    let failedAt: { clipId: string; startText: string; endText: string } | null = null\n\n    for (let index = 0; index < rawClipList.length; index += 1) {\n      const item = rawClipList[index]\n      const startText = asString(item.start)\n      const endText = asString(item.end)\n      const clipId = `clip_${index + 1}`\n      const match = matcher.matchBoundary(startText, endText, searchFrom)\n      if (!match) {\n        failedAt = { clipId, startText, endText }\n        break\n      }\n\n      nextClipList.push({\n        id: clipId,\n        startText,\n        endText,\n        summary: asString(item.summary),\n        location: asString(item.location) || null,\n        characters: toStringArray(item.characters),\n        content: content.slice(match.startIndex, match.endIndex),\n        matchLevel: match.level,\n        matchConfidence: match.confidence,\n      })\n      searchFrom = match.endIndex\n    }\n\n    if (!failedAt) {\n      splitStep = output\n      clipList = nextClipList\n      const levelCount: Record<ClipMatchLevel, number> = { L1: 0, L2: 0, L3: 0 }\n      for (const clip of nextClipList) {\n        levelCount[clip.matchLevel] += 1\n      }\n      onLog?.('片段边界匹配成功', {\n        attempt,\n        clipCount: nextClipList.length,\n        levelCount,\n      })\n      break\n    }\n\n    lastBoundaryError = new Error(\n      `split_clips boundary matching failed at ${failedAt.clipId}: start=\"${failedAt.startText}\" end=\"${failedAt.endText}\"`,\n    )\n    onLog?.('片段边界匹配失败', {\n      attempt,\n      maxAttempts: MAX_SPLIT_BOUNDARY_ATTEMPTS,\n      failedClip: failedAt.clipId,\n      startText: failedAt.startText,\n      endText: failedAt.endText,\n    })\n  }\n\n  if (!splitStep) {\n    throw lastBoundaryError || new Error('split_clips boundary matching failed')\n  }\n\n  onLog?.('开始步骤3：对每个片段做剧本转换（并行）', { clipCount: clipList.length })\n\n  const screenplayResults = await mapWithConcurrency(\n    clipList,\n    concurrency,\n    async (clip, index): Promise<StoryToScriptScreenplayResult> => {\n      const stepMeta: StoryToScriptStepMeta = {\n        stepId: `screenplay_${clip.id}`,\n        stepTitle: 'progress.streamStep.screenplayConversion',\n        stepIndex: index + 1,\n        stepTotal: clipList.length || 1,\n        dependsOn: ['split_clips'],\n        groupId: 'screenplay_conversion',\n        parallelKey: clip.id,\n        retryable: true,\n      }\n\n      try {\n        const screenplayPrompt = applyTemplate(promptTemplates.screenplayPromptTemplate, {\n          clip_content: clip.content,\n          locations_lib_name: locationsLibName || '无',\n          characters_lib_name: charactersLibName || '无',\n          characters_introduction: charactersIntroduction || '暂无角色介绍',\n          clip_id: clip.id,\n        })\n\n        const { parsed: screenplay } = await runStepWithRetry(\n          runStep,\n          stepMeta,\n          screenplayPrompt,\n          'screenplay_conversion',\n          2200,\n          parseScreenplayObject,\n        )\n        const scenes = Array.isArray(screenplay.scenes) ? screenplay.scenes : []\n        return {\n          clipId: clip.id,\n          success: true,\n          sceneCount: scenes.length,\n          screenplay,\n        }\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error)\n        onStepError?.(stepMeta, message)\n        return {\n          clipId: clip.id,\n          success: false,\n          sceneCount: 0,\n          error: message,\n        }\n      }\n    },\n  )\n\n  const screenplaySuccessCount = screenplayResults.filter((item) => item.success).length\n  const screenplayFailedCount = screenplayResults.length - screenplaySuccessCount\n  const totalScenes = screenplayResults.reduce((sum, item) => sum + item.sceneCount, 0)\n\n  return {\n    characterStep,\n    locationStep,\n    splitStep,\n    charactersObject,\n    locationsObject,\n    analyzedCharacters,\n    analyzedLocations,\n    charactersLibName,\n    locationsLibName,\n    charactersIntroduction,\n    clipList,\n    screenplayResults,\n    summary: {\n      characterCount: analyzedCharacters.length,\n      locationCount: analyzedLocations.length,\n      clipCount: clipList.length,\n      screenplaySuccessCount,\n      screenplayFailedCount,\n      totalScenes,\n    },\n  }\n}\n"
  },
  {
    "path": "src/lib/novel-promotion/story-to-script/types.ts",
    "content": "import type {\n  RunStepStatus,\n  RunStreamEvent,\n  RunStreamEventType,\n  RunStreamLane,\n  RunStreamStatus,\n} from '@/lib/novel-promotion/run-stream/types'\n\nexport type StoryToScriptLane = RunStreamLane\nexport type StoryToScriptRunEventType = RunStreamEventType\nexport type StoryToScriptRunStatus = RunStreamStatus\nexport type StoryToScriptStepStatus = RunStepStatus\nexport type StoryToScriptStreamEvent = RunStreamEvent\n"
  },
  {
    "path": "src/lib/openai-compat-media-template.ts",
    "content": "export type TemplateHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n\nexport type TemplateContentType =\n  | 'application/json'\n  | 'multipart/form-data'\n  | 'application/x-www-form-urlencoded'\n\nexport type TemplateHeaderMap = Record<string, string>\n\nexport type TemplateBodyValue =\n  | string\n  | number\n  | boolean\n  | null\n  | { [key: string]: TemplateBodyValue }\n  | TemplateBodyValue[]\n\nexport interface TemplateEndpoint {\n  method: TemplateHttpMethod\n  path: string\n  contentType?: TemplateContentType\n  headers?: TemplateHeaderMap\n  bodyTemplate?: TemplateBodyValue\n  multipartFileFields?: string[]\n}\n\nexport interface TemplateResponseMap {\n  taskIdPath?: string\n  statusPath?: string\n  outputUrlPath?: string\n  outputUrlsPath?: string\n  errorPath?: string\n}\n\nexport interface TemplatePollingConfig {\n  intervalMs: number\n  timeoutMs: number\n  doneStates: string[]\n  failStates: string[]\n}\n\nexport interface OpenAICompatMediaTemplate {\n  version: 1\n  mediaType: 'image' | 'video'\n  mode: 'sync' | 'async'\n  create: TemplateEndpoint\n  status?: TemplateEndpoint\n  content?: TemplateEndpoint\n  response: TemplateResponseMap\n  polling?: TemplatePollingConfig\n}\n\nexport type OpenAICompatMediaTemplateSource = 'ai' | 'manual'\n\nexport const TEMPLATE_PLACEHOLDER_ALLOWLIST = new Set([\n  'model',\n  'prompt',\n  'image',\n  'images',\n  'aspect_ratio',\n  'duration',\n  'resolution',\n  'size',\n  'task_id',\n])\n"
  },
  {
    "path": "src/lib/openai-compat-template-runtime.ts",
    "content": "import type {\n  OpenAICompatMediaTemplate,\n  TemplateBodyValue,\n  TemplateEndpoint,\n  TemplateHeaderMap,\n} from '@/lib/openai-compat-media-template'\nimport { toUploadFile } from '@/lib/model-gateway/openai-compat/common'\n\nexport type TemplateVariableMap = Record<string, TemplateBodyValue | undefined>\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction cloneTemplateBodyValue(value: TemplateBodyValue): TemplateBodyValue {\n  if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n    return value\n  }\n  if (Array.isArray(value)) {\n    return value.map((item) => cloneTemplateBodyValue(item))\n  }\n  const output: Record<string, TemplateBodyValue> = {}\n  for (const [key, nestedValue] of Object.entries(value)) {\n    output[key] = cloneTemplateBodyValue(nestedValue as TemplateBodyValue)\n  }\n  return output\n}\n\nfunction stringifyVariable(value: TemplateBodyValue | undefined): string {\n  if (Array.isArray(value) || isRecord(value)) return JSON.stringify(value)\n  if (typeof value === 'number') return String(value)\n  if (typeof value === 'boolean') return String(value)\n  if (typeof value === 'string') return value\n  return ''\n}\n\nfunction resolvePlaceholderValue(\n  placeholder: string,\n  variables: TemplateVariableMap,\n): TemplateBodyValue | undefined {\n  if (!(placeholder in variables)) {\n    throw new Error(`OPENAI_COMPAT_TEMPLATE_VARIABLE_MISSING: ${placeholder}`)\n  }\n  return variables[placeholder]\n}\n\nfunction resolvePlaceholderText(\n  placeholder: string,\n  variables: TemplateVariableMap,\n): string {\n  return stringifyVariable(resolvePlaceholderValue(placeholder, variables))\n}\n\nfunction matchExactPlaceholder(value: string): string | null {\n  const match = value.match(/^\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}$/)\n  return match?.[1] || null\n}\n\nfunction toSnakeCase(value: string): string {\n  return value\n    .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n    .replace(/[\\s-]+/g, '_')\n    .toLowerCase()\n}\n\nfunction toTemplateVariableValue(value: unknown): TemplateBodyValue | undefined {\n  if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n    return value\n  }\n  if (Array.isArray(value)) {\n    const items: TemplateBodyValue[] = []\n    for (const item of value) {\n      const converted = toTemplateVariableValue(item)\n      if (converted === undefined) return undefined\n      items.push(converted)\n    }\n    return items\n  }\n  if (!isRecord(value)) return undefined\n\n  const output: Record<string, TemplateBodyValue> = {}\n  for (const [key, nestedValue] of Object.entries(value)) {\n    const converted = toTemplateVariableValue(nestedValue)\n    if (converted === undefined) return undefined\n    output[key] = converted\n  }\n  return output\n}\n\nfunction appendTemplateOptionVariables(\n  target: TemplateVariableMap,\n  source: Record<string, unknown> | undefined,\n) {\n  if (!source) return\n  for (const [rawKey, rawValue] of Object.entries(source)) {\n    const value = toTemplateVariableValue(rawValue)\n    if (value === undefined) continue\n    const trimmedKey = rawKey.trim()\n    if (!trimmedKey) continue\n    target[trimmedKey] = value\n    const snakeKey = toSnakeCase(trimmedKey)\n    if (!(snakeKey in target)) {\n      target[snakeKey] = value\n    }\n  }\n}\n\nfunction setHeaderIfMissing(headers: Record<string, string>, key: string, value: string) {\n  const existingKey = Object.keys(headers).find((headerKey) => headerKey.toLowerCase() === key.toLowerCase())\n  if (!existingKey) {\n    headers[key] = value\n  }\n}\n\nfunction deleteHeader(headers: Record<string, string>, key: string) {\n  for (const headerKey of Object.keys(headers)) {\n    if (headerKey.toLowerCase() === key.toLowerCase()) {\n      delete headers[headerKey]\n    }\n  }\n}\n\nfunction isMultipartFileField(\n  multipartFileFields: Set<string>,\n  fieldPath: string,\n): boolean {\n  return multipartFileFields.has(fieldPath)\n}\n\nasync function appendMultipartFileValue(\n  formData: FormData,\n  formKey: string,\n  value: TemplateBodyValue,\n  fieldPath: string,\n  indexSeed: number,\n): Promise<number> {\n  if (typeof value === 'string') {\n    formData.append(formKey, await toUploadFile(value, indexSeed))\n    return indexSeed + 1\n  }\n  if (Array.isArray(value)) {\n    let nextIndex = indexSeed\n    for (const item of value) {\n      nextIndex = await appendMultipartFileValue(formData, formKey, item, fieldPath, nextIndex)\n    }\n    return nextIndex\n  }\n  throw new Error(`OPENAI_COMPAT_TEMPLATE_MULTIPART_FILE_INVALID: ${fieldPath}`)\n}\n\nasync function appendMultipartValue(\n  formData: FormData,\n  formKey: string,\n  value: TemplateBodyValue,\n  fieldPath: string,\n  multipartFileFields: Set<string>,\n  fileIndexSeed: number,\n): Promise<number> {\n  if (isMultipartFileField(multipartFileFields, fieldPath)) {\n    return appendMultipartFileValue(formData, formKey, value, fieldPath, fileIndexSeed)\n  }\n\n  if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n    formData.append(formKey, value === null ? 'null' : String(value))\n    return fileIndexSeed\n  }\n\n  if (Array.isArray(value)) {\n    let nextIndex = fileIndexSeed\n    for (const item of value) {\n      if (\n        item === null\n        || typeof item === 'string'\n        || typeof item === 'number'\n        || typeof item === 'boolean'\n      ) {\n        formData.append(formKey, item === null ? 'null' : String(item))\n        continue\n      }\n      const nestedKey = `${formKey}[]`\n      nextIndex = await appendMultipartValue(\n        formData,\n        nestedKey,\n        item,\n        fieldPath,\n        multipartFileFields,\n        nextIndex,\n      )\n    }\n    return nextIndex\n  }\n\n  let nextIndex = fileIndexSeed\n  for (const [nestedKey, nestedValue] of Object.entries(value)) {\n    const nextFormKey = formKey ? `${formKey}[${nestedKey}]` : nestedKey\n    const nextFieldPath = fieldPath ? `${fieldPath}.${nestedKey}` : nestedKey\n    nextIndex = await appendMultipartValue(\n      formData,\n      nextFormKey,\n      nestedValue as TemplateBodyValue,\n      nextFieldPath,\n      multipartFileFields,\n      nextIndex,\n    )\n  }\n  return nextIndex\n}\n\nasync function buildMultipartBody(\n  endpoint: TemplateEndpoint,\n  renderedBody: TemplateBodyValue,\n): Promise<FormData> {\n  if (!isRecord(renderedBody)) {\n    throw new Error('OPENAI_COMPAT_TEMPLATE_MULTIPART_BODY_INVALID')\n  }\n\n  const formData = new FormData()\n  const multipartFileFields = new Set(endpoint.multipartFileFields || [])\n  let fileIndex = 0\n\n  for (const [key, value] of Object.entries(renderedBody)) {\n    fileIndex = await appendMultipartValue(\n      formData,\n      key,\n      value as TemplateBodyValue,\n      key,\n      multipartFileFields,\n      fileIndex,\n    )\n  }\n  return formData\n}\n\nfunction appendUrlEncodedValue(\n  params: URLSearchParams,\n  formKey: string,\n  value: TemplateBodyValue,\n) {\n  if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n    params.append(formKey, value === null ? 'null' : String(value))\n    return\n  }\n  if (Array.isArray(value)) {\n    for (const item of value) {\n      appendUrlEncodedValue(params, formKey, item)\n    }\n    return\n  }\n  for (const [nestedKey, nestedValue] of Object.entries(value)) {\n    const nextKey = formKey ? `${formKey}[${nestedKey}]` : nestedKey\n    appendUrlEncodedValue(params, nextKey, nestedValue as TemplateBodyValue)\n  }\n}\n\nasync function buildRequestBody(\n  endpoint: TemplateEndpoint,\n  renderedBody: TemplateBodyValue,\n  headers: Record<string, string>,\n): Promise<BodyInit> {\n  const contentType = endpoint.contentType || 'application/json'\n\n  if (contentType === 'multipart/form-data') {\n    deleteHeader(headers, 'Content-Type')\n    return buildMultipartBody(endpoint, renderedBody)\n  }\n\n  if (contentType === 'application/x-www-form-urlencoded') {\n    const params = new URLSearchParams()\n    appendUrlEncodedValue(params, '', renderedBody)\n    setHeaderIfMissing(headers, 'Content-Type', 'application/x-www-form-urlencoded')\n    return params\n  }\n\n  setHeaderIfMissing(headers, 'Content-Type', 'application/json')\n  return JSON.stringify(renderedBody)\n}\n\nexport function renderTemplateString(\n  template: string,\n  variables: TemplateVariableMap,\n): string {\n  return template.replace(/\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}/g, (_match, key) => {\n    return resolvePlaceholderText(String(key), variables)\n  })\n}\n\nexport function renderTemplateValue(\n  value: TemplateBodyValue,\n  variables: TemplateVariableMap,\n): TemplateBodyValue {\n  if (typeof value === 'string') {\n    const exactPlaceholder = matchExactPlaceholder(value)\n    if (exactPlaceholder) {\n      const resolved = resolvePlaceholderValue(exactPlaceholder, variables)\n      return resolved === undefined ? '' : cloneTemplateBodyValue(resolved)\n    }\n    return renderTemplateString(value, variables)\n  }\n  if (value === null || typeof value === 'number' || typeof value === 'boolean') return value\n  if (Array.isArray(value)) {\n    return value.map((item) => renderTemplateValue(item, variables))\n  }\n  const out: Record<string, TemplateBodyValue> = {}\n  for (const [key, nestedValue] of Object.entries(value)) {\n    out[key] = renderTemplateValue(nestedValue as TemplateBodyValue, variables)\n  }\n  return out\n}\n\nexport function resolveTemplateEndpointUrl(baseUrl: string, path: string): string {\n  const trimmedPath = path.trim()\n  if (trimmedPath.startsWith('http://') || trimmedPath.startsWith('https://')) {\n    return trimmedPath\n  }\n\n  const normalizedBase = baseUrl.replace(/\\/+$/, '')\n  let normalizedPath = trimmedPath.replace(/^\\/+/, '')\n\n  // Prevent accidental /v1/v1 duplication for openai-compatible providers:\n  // baseUrl is normalized to include /v1, so relative template path should omit /v1.\n  try {\n    const parsedBase = new URL(normalizedBase)\n    const baseSegments = parsedBase.pathname.split('/').filter(Boolean)\n    const baseEndsWithV1 = baseSegments.length > 0 && baseSegments[baseSegments.length - 1] === 'v1'\n    if (baseEndsWithV1 && /^v1(?:\\/|$|\\?)/.test(normalizedPath)) {\n      normalizedPath = normalizedPath.replace(/^v1\\/?/, '')\n    }\n  } catch {\n    // Keep original path behavior for invalid base urls; caller will fail explicitly downstream.\n  }\n\n  return `${normalizedBase}/${normalizedPath}`\n}\n\nexport function renderTemplateHeaders(\n  headers: TemplateHeaderMap | undefined,\n  variables: TemplateVariableMap,\n): Record<string, string> {\n  if (!headers) return {}\n  const output: Record<string, string> = {}\n  for (const [key, value] of Object.entries(headers)) {\n    output[key] = renderTemplateString(value, variables)\n  }\n  return output\n}\n\nfunction parsePathSegments(path: string): Array<string | number> {\n  const normalized = path.replace(/^\\$\\./, '')\n  if (!normalized) return []\n  const segments: Array<string | number> = []\n  const dotParts = normalized.split('.')\n  for (const part of dotParts) {\n    const regex = /([^[\\]]+)|\\[(\\d+)\\]/g\n    let match = regex.exec(part)\n    while (match) {\n      if (match[1]) segments.push(match[1])\n      if (match[2]) segments.push(Number.parseInt(match[2], 10))\n      match = regex.exec(part)\n    }\n  }\n  return segments\n}\n\nexport function readJsonPath(payload: unknown, path: string | undefined): unknown {\n  if (!path) return undefined\n  if (!path.startsWith('$.')) return undefined\n  const segments = parsePathSegments(path)\n  let current: unknown = payload\n  for (const segment of segments) {\n    if (typeof segment === 'number') {\n      if (!Array.isArray(current)) return undefined\n      current = current[segment]\n      continue\n    }\n    if (!isRecord(current)) return undefined\n    current = current[segment]\n  }\n  return current\n}\n\nexport type RenderedTemplateRequest = {\n  endpointUrl: string\n  method: TemplateEndpoint['method']\n  headers: Record<string, string>\n  body?: BodyInit\n}\n\nexport async function buildRenderedTemplateRequest(input: {\n  baseUrl: string\n  endpoint: TemplateEndpoint\n  variables: TemplateVariableMap\n  defaultAuthHeader?: string\n}): Promise<RenderedTemplateRequest> {\n  const renderedPath = renderTemplateString(input.endpoint.path, input.variables)\n  const endpointUrl = resolveTemplateEndpointUrl(input.baseUrl, renderedPath)\n  const headers = renderTemplateHeaders(input.endpoint.headers, input.variables)\n  if (input.defaultAuthHeader && !headers.Authorization) {\n    headers.Authorization = input.defaultAuthHeader\n  }\n\n  let body: BodyInit | undefined\n  if (input.endpoint.bodyTemplate !== undefined) {\n    const renderedBody = renderTemplateValue(input.endpoint.bodyTemplate, input.variables)\n    body = await buildRequestBody(input.endpoint, renderedBody, headers)\n  }\n\n  return {\n    endpointUrl,\n    method: input.endpoint.method,\n    headers,\n    ...(body !== undefined ? { body } : {}),\n  }\n}\n\nexport function normalizeResponseJson(rawText: string): unknown {\n  const trimmed = rawText.trim()\n  if (!trimmed) return null\n  try {\n    return JSON.parse(trimmed) as unknown\n  } catch {\n    return trimmed\n  }\n}\n\nexport function buildTemplateVariables(input: {\n  model: string\n  prompt: string\n  image?: string\n  images?: string[]\n  aspectRatio?: string\n  duration?: number\n  resolution?: string\n  size?: string\n  taskId?: string\n  extra?: Record<string, unknown>\n}): TemplateVariableMap {\n  const variables: TemplateVariableMap = {\n    model: input.model,\n    prompt: input.prompt,\n    image: input.image || '',\n    images: input.images || [],\n    aspect_ratio: input.aspectRatio || '',\n    duration: input.duration ?? null,\n    resolution: input.resolution || '',\n    size: input.size || '',\n    task_id: input.taskId || '',\n  }\n  appendTemplateOptionVariables(variables, input.extra)\n  return variables\n}\n\nexport function extractTemplateError(\n  template: OpenAICompatMediaTemplate,\n  payload: unknown,\n  status: number,\n): string {\n  const mapped = readJsonPath(payload, template.response.errorPath)\n  if (typeof mapped === 'string' && mapped.trim()) return mapped.trim()\n  const fallbackCandidates = [\n    readJsonPath(payload, '$.error.message_zh'),\n    readJsonPath(payload, '$.error.message'),\n    readJsonPath(payload, '$.message_zh'),\n    readJsonPath(payload, '$.message'),\n    readJsonPath(payload, '$.error'),\n  ]\n  for (const candidate of fallbackCandidates) {\n    if (typeof candidate === 'string' && candidate.trim()) {\n      return `Template request failed with status ${status}: ${candidate.trim()}`\n    }\n  }\n  if (typeof payload === 'string' && payload.trim()) {\n    const snippet = payload.trim().slice(0, 300)\n    return `Template request failed with status ${status}: ${snippet}`\n  }\n  if (payload && typeof payload === 'object') {\n    try {\n      const snippet = JSON.stringify(payload).slice(0, 300)\n      if (snippet) return `Template request failed with status ${status}: ${snippet}`\n    } catch {\n      // Fall through to generic message below.\n    }\n  }\n  return `Template request failed with status ${status}`\n}\n"
  },
  {
    "path": "src/lib/prisma-error.ts",
    "content": "type PrismaLikeError = {\n  code?: unknown\n  message?: unknown\n}\n\nconst PRISMA_CODE_PATTERN = /^P\\d{4}$/i\n\nconst RETRYABLE_PRISMA_CODES = new Set([\n  'P1001',\n  'P1002',\n  'P1008',\n  'P1017',\n  'P2024',\n  'P2028',\n])\n\nfunction toMessage(value: unknown): string {\n  if (typeof value === 'string' && value.trim()) return value.trim()\n  return ''\n}\n\nexport function isPrismaErrorCode(value: unknown): value is string {\n  return typeof value === 'string' && PRISMA_CODE_PATTERN.test(value.trim())\n}\n\nexport function getPrismaErrorCode(error: unknown): string | null {\n  if (!error || typeof error !== 'object') return null\n  const code = (error as PrismaLikeError).code\n  if (!isPrismaErrorCode(code)) return null\n  return code.trim().toUpperCase()\n}\n\nexport function isPrismaRetryableCode(code: string): boolean {\n  return RETRYABLE_PRISMA_CODES.has(code.trim().toUpperCase())\n}\n\nexport function isLikelyPrismaDisconnectError(error: unknown): boolean {\n  if (!error || typeof error !== 'object') return false\n  const message = toMessage((error as PrismaLikeError).message).toLowerCase()\n  if (!message) return false\n  return (\n    message.includes('server has closed the connection')\n    || message.includes('unable to start a transaction in the given time')\n    || message.includes('connection timed out')\n    || message.includes(\"can't reach database server\")\n  )\n}\n\nexport function isRetryablePrismaError(error: unknown): boolean {\n  const code = getPrismaErrorCode(error)\n  if (code && isPrismaRetryableCode(code)) return true\n  return isLikelyPrismaDisconnectError(error)\n}\n"
  },
  {
    "path": "src/lib/prisma-retry.ts",
    "content": "import { isRetryablePrismaError } from '@/lib/prisma-error'\n\ntype PrismaRetryOptions = {\n  maxRetries?: number\n  initialDelayMs?: number\n}\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => {\n    setTimeout(resolve, ms)\n  })\n}\n\nfunction toRetryCount(value: number | undefined, fallback: number): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return fallback\n  const normalized = Math.floor(value)\n  if (normalized < 0) return 0\n  return normalized\n}\n\nfunction toDelay(value: number | undefined, fallback: number): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return fallback\n  const normalized = Math.floor(value)\n  if (normalized < 0) return 0\n  return normalized\n}\n\nexport async function withPrismaRetry<T>(\n  operation: () => Promise<T>,\n  options: PrismaRetryOptions = {},\n): Promise<T> {\n  const maxRetries = toRetryCount(options.maxRetries, 2)\n  const initialDelayMs = toDelay(options.initialDelayMs, 80)\n\n  let attempt = 0\n  while (true) {\n    try {\n      return await operation()\n    } catch (error) {\n      if (!isRetryablePrismaError(error) || attempt >= maxRetries) {\n        throw error\n      }\n      const backoffMs = initialDelayMs * (attempt + 1)\n      if (backoffMs > 0) {\n        await sleep(backoffMs)\n      }\n      attempt += 1\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/prisma.ts",
    "content": "import { PrismaClient } from '@prisma/client'\n\nconst globalForPrisma = global as unknown as { prisma: PrismaClient }\n\nexport const prisma = globalForPrisma.prisma || new PrismaClient()\n\nif (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma\n\n"
  },
  {
    "path": "src/lib/prompt-i18n/build-prompt.ts",
    "content": "import { PROMPT_CATALOG } from './catalog'\nimport { PromptI18nError } from './errors'\nimport { getPromptTemplate } from './template-store'\nimport type { BuildPromptInput } from './types'\n\nconst SINGLE_PLACEHOLDER_PATTERN = /\\{([A-Za-z0-9_]+)\\}/g\nconst DOUBLE_PLACEHOLDER_PATTERN = /\\{\\{([A-Za-z0-9_]+)\\}\\}/g\n\nfunction extractPlaceholders(template: string): string[] {\n  const keys = new Set<string>()\n\n  for (const match of template.matchAll(SINGLE_PLACEHOLDER_PATTERN)) {\n    const key = match[1]\n    if (key) keys.add(key)\n  }\n  for (const match of template.matchAll(DOUBLE_PLACEHOLDER_PATTERN)) {\n    const key = match[1]\n    if (key) keys.add(key)\n  }\n\n  return Array.from(keys)\n}\n\nfunction escapeRegex(raw: string) {\n  return raw.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\nfunction replaceAllPlaceholders(template: string, key: string, value: string): string {\n  const escaped = escapeRegex(key)\n  const pattern = new RegExp(`\\\\{\\\\{${escaped}\\\\}\\\\}|\\\\{${escaped}\\\\}`, 'g')\n  return template.replace(pattern, value)\n}\n\nexport function buildPrompt(input: BuildPromptInput): string {\n  const { promptId, locale, variables = {} } = input\n  const entry = PROMPT_CATALOG[promptId]\n  if (!entry) {\n    throw new PromptI18nError(\n      'PROMPT_ID_UNREGISTERED',\n      promptId,\n      `Prompt is not registered: ${promptId}`,\n    )\n  }\n\n  const template = getPromptTemplate(promptId, locale)\n\n  const templatePlaceholders = extractPlaceholders(template)\n  const defined = new Set(entry.variableKeys)\n\n  for (const key of templatePlaceholders) {\n    if (!defined.has(key)) {\n      throw new PromptI18nError(\n        'PROMPT_PLACEHOLDER_MISMATCH',\n        promptId,\n        `Template placeholder not declared in catalog: ${key}`,\n        { key },\n      )\n    }\n  }\n\n  const providedKeys = Object.keys(variables)\n  for (const key of providedKeys) {\n    if (!defined.has(key)) {\n      throw new PromptI18nError(\n        'PROMPT_VARIABLE_UNEXPECTED',\n        promptId,\n        `Unexpected prompt variable: ${key}`,\n        { key },\n      )\n    }\n    if (typeof variables[key] !== 'string') {\n      throw new PromptI18nError(\n        'PROMPT_VARIABLE_VALUE_INVALID',\n        promptId,\n        `Prompt variable must be string: ${key}`,\n        { key, type: typeof variables[key] },\n      )\n    }\n  }\n\n  for (const key of entry.variableKeys) {\n    if (!(key in variables)) {\n      throw new PromptI18nError(\n        'PROMPT_VARIABLE_MISSING',\n        promptId,\n        `Missing prompt variable: ${key}`,\n        { key },\n      )\n    }\n  }\n\n  let rendered = template\n  for (const key of entry.variableKeys) {\n    rendered = replaceAllPlaceholders(rendered, key, variables[key] || '')\n  }\n\n  return rendered\n}\n"
  },
  {
    "path": "src/lib/prompt-i18n/catalog.ts",
    "content": "import { PROMPT_IDS, type PromptId } from './prompt-ids'\nimport type { PromptCatalogEntry } from './types'\n\nexport const PROMPT_CATALOG: Record<PromptId, PromptCatalogEntry> = {\n  [PROMPT_IDS.CHARACTER_IMAGE_TO_DESCRIPTION]: {\n    pathStem: 'character-reference/character_image_to_description',\n    variableKeys: [],\n  },\n  [PROMPT_IDS.CHARACTER_REFERENCE_TO_SHEET]: {\n    pathStem: 'character-reference/character_reference_to_sheet',\n    variableKeys: [],\n  },\n  [PROMPT_IDS.NP_AGENT_ACTING_DIRECTION]: {\n    pathStem: 'novel-promotion/agent_acting_direction',\n    variableKeys: ['panels_json', 'panel_count', 'characters_info'],\n  },\n  [PROMPT_IDS.NP_AGENT_CHARACTER_PROFILE]: {\n    pathStem: 'novel-promotion/agent_character_profile',\n    variableKeys: ['input', 'characters_lib_info'],\n  },\n  [PROMPT_IDS.NP_AGENT_CHARACTER_VISUAL]: {\n    pathStem: 'novel-promotion/agent_character_visual',\n    variableKeys: ['character_profiles'],\n  },\n  [PROMPT_IDS.NP_AGENT_CINEMATOGRAPHER]: {\n    pathStem: 'novel-promotion/agent_cinematographer',\n    variableKeys: ['panels_json', 'panel_count', 'locations_description', 'characters_info'],\n  },\n  [PROMPT_IDS.NP_AGENT_CLIP]: {\n    pathStem: 'novel-promotion/agent_clip',\n    variableKeys: ['input', 'locations_lib_name', 'characters_lib_name', 'characters_introduction'],\n  },\n  [PROMPT_IDS.NP_AGENT_SHOT_VARIANT_ANALYSIS]: {\n    pathStem: 'novel-promotion/agent_shot_variant_analysis',\n    variableKeys: ['panel_description', 'shot_type', 'camera_move', 'location', 'characters_info'],\n  },\n  [PROMPT_IDS.NP_AGENT_SHOT_VARIANT_GENERATE]: {\n    pathStem: 'novel-promotion/agent_shot_variant_generate',\n    variableKeys: [\n      'original_description',\n      'original_shot_type',\n      'original_camera_move',\n      'location',\n      'characters_info',\n      'variant_title',\n      'variant_description',\n      'target_shot_type',\n      'target_camera_move',\n      'video_prompt',\n      'character_assets',\n      'location_asset',\n      'aspect_ratio',\n      'style',\n    ],\n  },\n  [PROMPT_IDS.NP_AGENT_STORYBOARD_DETAIL]: {\n    pathStem: 'novel-promotion/agent_storyboard_detail',\n    variableKeys: ['panels_json', 'characters_age_gender', 'locations_description'],\n  },\n  [PROMPT_IDS.NP_AGENT_STORYBOARD_INSERT]: {\n    pathStem: 'novel-promotion/agent_storyboard_insert',\n    variableKeys: [\n      'prev_panel_json',\n      'next_panel_json',\n      'characters_full_description',\n      'locations_description',\n      'user_input',\n    ],\n  },\n  [PROMPT_IDS.NP_AGENT_STORYBOARD_PLAN]: {\n    pathStem: 'novel-promotion/agent_storyboard_plan',\n    variableKeys: [\n      'characters_lib_name',\n      'locations_lib_name',\n      'characters_introduction',\n      'characters_appearance_list',\n      'characters_full_description',\n      'clip_json',\n      'clip_content',\n    ],\n  },\n  [PROMPT_IDS.NP_CHARACTER_CREATE]: {\n    pathStem: 'novel-promotion/character_create',\n    variableKeys: ['user_input'],\n  },\n  [PROMPT_IDS.NP_CHARACTER_DESCRIPTION_UPDATE]: {\n    pathStem: 'novel-promotion/character_description_update',\n    variableKeys: ['original_description', 'modify_instruction', 'image_context'],\n  },\n  [PROMPT_IDS.NP_CHARACTER_MODIFY]: {\n    pathStem: 'novel-promotion/character_modify',\n    variableKeys: ['character_input', 'user_input'],\n  },\n  [PROMPT_IDS.NP_CHARACTER_REGENERATE]: {\n    pathStem: 'novel-promotion/character_regenerate',\n    variableKeys: ['character_name', 'current_descriptions', 'change_reason', 'novel_text'],\n  },\n  [PROMPT_IDS.NP_EPISODE_SPLIT]: {\n    pathStem: 'novel-promotion/episode_split',\n    variableKeys: ['CONTENT'],\n  },\n  [PROMPT_IDS.NP_IMAGE_PROMPT_MODIFY]: {\n    pathStem: 'novel-promotion/image_prompt_modify',\n    variableKeys: ['prompt_input', 'user_input', 'video_prompt_input'],\n  },\n  [PROMPT_IDS.NP_LOCATION_CREATE]: {\n    pathStem: 'novel-promotion/location_create',\n    variableKeys: ['user_input'],\n  },\n  [PROMPT_IDS.NP_LOCATION_DESCRIPTION_UPDATE]: {\n    pathStem: 'novel-promotion/location_description_update',\n    variableKeys: ['location_name', 'original_description', 'modify_instruction', 'image_context'],\n  },\n  [PROMPT_IDS.NP_LOCATION_MODIFY]: {\n    pathStem: 'novel-promotion/location_modify',\n    variableKeys: ['location_name', 'location_input', 'user_input'],\n  },\n  [PROMPT_IDS.NP_LOCATION_REGENERATE]: {\n    pathStem: 'novel-promotion/location_regenerate',\n    variableKeys: ['location_name', 'current_descriptions'],\n  },\n  [PROMPT_IDS.NP_SCREENPLAY_CONVERSION]: {\n    pathStem: 'novel-promotion/screenplay_conversion',\n    variableKeys: ['clip_content', 'locations_lib_name', 'characters_lib_name', 'characters_introduction', 'clip_id'],\n  },\n  [PROMPT_IDS.NP_SELECT_LOCATION]: {\n    pathStem: 'novel-promotion/select_location',\n    variableKeys: ['input', 'locations_lib_name'],\n  },\n  [PROMPT_IDS.NP_SINGLE_PANEL_IMAGE]: {\n    pathStem: 'novel-promotion/single_panel_image',\n    variableKeys: ['storyboard_text_json_input', 'source_text', 'aspect_ratio', 'style'],\n  },\n  [PROMPT_IDS.NP_STORYBOARD_EDIT]: {\n    pathStem: 'novel-promotion/storyboard_edit',\n    variableKeys: ['user_input'],\n  },\n  [PROMPT_IDS.NP_VOICE_ANALYSIS]: {\n    pathStem: 'novel-promotion/voice_analysis',\n    variableKeys: ['input', 'characters_lib_name', 'characters_introduction', 'storyboard_json'],\n  },\n}\n"
  },
  {
    "path": "src/lib/prompt-i18n/errors.ts",
    "content": "import type { PromptId } from './prompt-ids'\n\nexport type PromptI18nErrorCode =\n  | 'PROMPT_ID_UNREGISTERED'\n  | 'PROMPT_TEMPLATE_NOT_FOUND'\n  | 'PROMPT_VARIABLE_MISSING'\n  | 'PROMPT_VARIABLE_UNEXPECTED'\n  | 'PROMPT_VARIABLE_VALUE_INVALID'\n  | 'PROMPT_PLACEHOLDER_MISMATCH'\n\nexport class PromptI18nError extends Error {\n  readonly code: PromptI18nErrorCode\n  readonly promptId: PromptId\n  readonly details?: Record<string, unknown>\n\n  constructor(\n    code: PromptI18nErrorCode,\n    promptId: PromptId,\n    message: string,\n    details?: Record<string, unknown>,\n  ) {\n    super(message)\n    this.name = 'PromptI18nError'\n    this.code = code\n    this.promptId = promptId\n    this.details = details\n  }\n}\n"
  },
  {
    "path": "src/lib/prompt-i18n/index.ts",
    "content": "export { PROMPT_IDS, type PromptId } from './prompt-ids'\nexport { buildPrompt } from './build-prompt'\nexport { PROMPT_CATALOG } from './catalog'\nexport { getPromptTemplate } from './template-store'\nexport { PromptI18nError, type PromptI18nErrorCode } from './errors'\nexport type {\n  BuildPromptInput,\n  PromptCatalogEntry,\n  PromptLocale,\n  PromptVariables,\n} from './types'\n"
  },
  {
    "path": "src/lib/prompt-i18n/prompt-ids.ts",
    "content": "export const PROMPT_IDS = {\n  CHARACTER_IMAGE_TO_DESCRIPTION: 'character_image_to_description',\n  CHARACTER_REFERENCE_TO_SHEET: 'character_reference_to_sheet',\n  NP_AGENT_ACTING_DIRECTION: 'np_agent_acting_direction',\n  NP_AGENT_CHARACTER_PROFILE: 'np_agent_character_profile',\n  NP_AGENT_CHARACTER_VISUAL: 'np_agent_character_visual',\n  NP_AGENT_CINEMATOGRAPHER: 'np_agent_cinematographer',\n  NP_AGENT_CLIP: 'np_agent_clip',\n  NP_AGENT_SHOT_VARIANT_ANALYSIS: 'np_agent_shot_variant_analysis',\n  NP_AGENT_SHOT_VARIANT_GENERATE: 'np_agent_shot_variant_generate',\n  NP_AGENT_STORYBOARD_DETAIL: 'np_agent_storyboard_detail',\n  NP_AGENT_STORYBOARD_INSERT: 'np_agent_storyboard_insert',\n  NP_AGENT_STORYBOARD_PLAN: 'np_agent_storyboard_plan',\n  NP_CHARACTER_CREATE: 'np_character_create',\n  NP_CHARACTER_DESCRIPTION_UPDATE: 'np_character_description_update',\n  NP_CHARACTER_MODIFY: 'np_character_modify',\n  NP_CHARACTER_REGENERATE: 'np_character_regenerate',\n  NP_EPISODE_SPLIT: 'np_episode_split',\n  NP_IMAGE_PROMPT_MODIFY: 'np_image_prompt_modify',\n  NP_LOCATION_CREATE: 'np_location_create',\n  NP_LOCATION_DESCRIPTION_UPDATE: 'np_location_description_update',\n  NP_LOCATION_MODIFY: 'np_location_modify',\n  NP_LOCATION_REGENERATE: 'np_location_regenerate',\n  NP_SCREENPLAY_CONVERSION: 'np_screenplay_conversion',\n  NP_SELECT_LOCATION: 'np_select_location',\n  NP_SINGLE_PANEL_IMAGE: 'np_single_panel_image',\n  NP_STORYBOARD_EDIT: 'np_storyboard_edit',\n  NP_VOICE_ANALYSIS: 'np_voice_analysis',\n} as const\n\nexport type PromptId = (typeof PROMPT_IDS)[keyof typeof PROMPT_IDS]\n"
  },
  {
    "path": "src/lib/prompt-i18n/template-store.ts",
    "content": "import fs from 'fs'\nimport path from 'path'\nimport { PROMPT_CATALOG } from './catalog'\nimport type { PromptId } from './prompt-ids'\nimport type { PromptLocale } from './types'\nimport { PromptI18nError } from './errors'\n\nconst templateCache = new Map<string, string>()\n\nfunction buildCacheKey(promptId: PromptId, locale: PromptLocale) {\n  return `${promptId}:${locale}`\n}\n\nexport function getPromptTemplate(promptId: PromptId, locale: PromptLocale): string {\n  const entry = PROMPT_CATALOG[promptId]\n  if (!entry) {\n    throw new PromptI18nError(\n      'PROMPT_ID_UNREGISTERED',\n      promptId,\n      `Prompt is not registered: ${promptId}`,\n    )\n  }\n\n  const cacheKey = buildCacheKey(promptId, locale)\n  const cached = templateCache.get(cacheKey)\n  if (cached) return cached\n\n  const filePath = path.join(process.cwd(), 'lib', 'prompts', `${entry.pathStem}.${locale}.txt`)\n  let template = ''\n  try {\n    template = fs.readFileSync(filePath, 'utf-8')\n  } catch {\n    throw new PromptI18nError(\n      'PROMPT_TEMPLATE_NOT_FOUND',\n      promptId,\n      `Prompt template not found: ${filePath}`,\n      { filePath, locale },\n    )\n  }\n\n  templateCache.set(cacheKey, template)\n  return template\n}\n"
  },
  {
    "path": "src/lib/prompt-i18n/types.ts",
    "content": "import type { Locale } from '@/i18n/routing'\nimport type { PromptId } from './prompt-ids'\n\nexport type PromptLocale = Locale\n\nexport type PromptVariables = Record<string, string>\n\nexport type PromptCatalogEntry = {\n  pathStem: string\n  variableKeys: readonly string[]\n}\n\nexport type BuildPromptInput = {\n  promptId: PromptId\n  locale: PromptLocale\n  variables?: PromptVariables\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/audio.ts",
    "content": "import {\n  assertOfficialModelRegistered,\n  type OfficialModelModality,\n} from '@/lib/providers/official/model-registry'\nimport { getProviderConfig } from '@/lib/api-config'\nimport type { GenerateResult } from '@/lib/generators/base'\nimport { ensureBailianCatalogRegistered } from './catalog'\nimport { synthesizeWithBailianTTS } from './tts'\nimport type { BailianGenerateRequestOptions } from './types'\n\nexport interface BailianAudioGenerateParams {\n  userId: string\n  text: string\n  voice?: string\n  rate?: number\n  options: BailianGenerateRequestOptions\n}\n\nfunction assertRegistered(modelId: string): void {\n  ensureBailianCatalogRegistered()\n  assertOfficialModelRegistered({\n    provider: 'bailian',\n    modality: 'audio' satisfies OfficialModelModality,\n    modelId,\n  })\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nexport async function generateBailianAudio(params: BailianAudioGenerateParams): Promise<GenerateResult> {\n  assertRegistered(params.options.modelId)\n  const voiceId = readTrimmedString(params.voice)\n  const text = readTrimmedString(params.text)\n  if (!voiceId) {\n    throw new Error('BAILIAN_VOICE_ID_REQUIRED')\n  }\n  if (!text) {\n    throw new Error('BAILIAN_TEXT_REQUIRED')\n  }\n\n  const { apiKey } = await getProviderConfig(params.userId, params.options.provider)\n  const result = await synthesizeWithBailianTTS({\n    text,\n    voiceId,\n    modelId: params.options.modelId,\n  }, apiKey)\n  if (!result.success || !result.audioData) {\n    throw new Error(result.error || 'BAILIAN_AUDIO_SYNTHESIZE_FAILED')\n  }\n  const fallbackDataUrl = `data:audio/wav;base64,${result.audioData.toString('base64')}`\n  const audioUrl = result.audioUrl || fallbackDataUrl\n\n  return {\n    success: true,\n    audioUrl,\n    requestId: result.requestId,\n  }\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/catalog.ts",
    "content": "import { registerOfficialModel } from '@/lib/providers/official/model-registry'\nimport type { OfficialModelModality } from '@/lib/providers/official/model-registry'\n\nconst BAILIAN_CATALOG: Readonly<Record<OfficialModelModality, readonly string[]>> = {\n  llm: [\n    'qwen3.5-plus',\n    'qwen3.5-flash',\n  ],\n  image: [],\n  video: [\n    'wan2.6-i2v-flash',\n    'wan2.6-i2v',\n    'wan2.5-i2v-preview',\n    'wan2.2-i2v-plus',\n    'wan2.2-kf2v-flash',\n    'wanx2.1-kf2v-plus',\n  ],\n  audio: [\n    'qwen3-tts-vd-2026-01-26',\n  ],\n}\n\nlet initialized = false\n\nexport function ensureBailianCatalogRegistered(): void {\n  if (initialized) return\n  initialized = true\n  for (const modality of Object.keys(BAILIAN_CATALOG) as OfficialModelModality[]) {\n    for (const modelId of BAILIAN_CATALOG[modality]) {\n      registerOfficialModel({ provider: 'bailian', modality, modelId })\n    }\n  }\n}\n\nexport function listBailianCatalogModels(modality: OfficialModelModality): readonly string[] {\n  ensureBailianCatalogRegistered()\n  return BAILIAN_CATALOG[modality]\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/image.ts",
    "content": "import {\n  assertOfficialModelRegistered,\n  type OfficialModelModality,\n} from '@/lib/providers/official/model-registry'\nimport { ensureBailianCatalogRegistered } from './catalog'\nimport type { BailianGenerateRequestOptions } from './types'\n\nexport interface BailianImageGenerateParams {\n  userId: string\n  prompt: string\n  referenceImages?: string[]\n  options: BailianGenerateRequestOptions\n}\n\nfunction assertRegistered(modelId: string): void {\n  ensureBailianCatalogRegistered()\n  assertOfficialModelRegistered({\n    provider: 'bailian',\n    modality: 'image' satisfies OfficialModelModality,\n    modelId,\n  })\n}\n\nexport async function generateBailianImage(params: BailianImageGenerateParams): Promise<never> {\n  assertRegistered(params.options.modelId)\n  throw new Error('OFFICIAL_PROVIDER_NOT_IMPLEMENTED: bailian image')\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/index.ts",
    "content": "export { ensureBailianCatalogRegistered, listBailianCatalogModels } from './catalog'\nexport { completeBailianLlm } from './llm'\nexport { generateBailianImage } from './image'\nexport { generateBailianVideo } from './video'\nexport { generateBailianAudio } from './audio'\nexport { BAILIAN_TTS_MODEL_ID, synthesizeWithBailianTTS } from './tts'\nexport {\n  collectBailianManagedVoiceIds,\n  collectProjectBailianManagedVoiceIds,\n  cleanupUnreferencedBailianVoices,\n  isBailianManagedVoiceBinding,\n} from './voice-cleanup'\nexport { deleteBailianVoice } from './voice-manage'\nexport {\n  createVoiceDesign,\n  validatePreviewText,\n  validateVoicePrompt,\n} from './voice-design'\nexport { probeBailian } from './probe'\nexport type {\n  BailianGenerateRequestOptions,\n  BailianLlmMessage,\n  BailianProbeResult,\n  BailianProbeStep,\n} from './types'\nexport type {\n  VoiceDesignInput,\n  VoiceDesignResult,\n} from './voice-design'\nexport type {\n  BailianVoiceBinding,\n  BailianVoiceCleanupResult,\n} from './voice-cleanup'\nexport type {\n  BailianTTSInput,\n  BailianTTSResult,\n} from './tts'\n"
  },
  {
    "path": "src/lib/providers/bailian/llm.ts",
    "content": "import OpenAI from 'openai'\nimport {\n  assertOfficialModelRegistered,\n  type OfficialModelModality,\n} from '@/lib/providers/official/model-registry'\nimport { ensureBailianCatalogRegistered } from './catalog'\nimport type { BailianLlmMessage } from './types'\n\nexport interface BailianLlmCompletionParams {\n  modelId: string\n  messages: BailianLlmMessage[]\n  apiKey: string\n  baseUrl?: string\n  temperature?: number\n}\n\nfunction assertRegistered(modelId: string): void {\n  ensureBailianCatalogRegistered()\n  assertOfficialModelRegistered({\n    provider: 'bailian',\n    modality: 'llm' satisfies OfficialModelModality,\n    modelId,\n  })\n}\n\nexport async function completeBailianLlm(\n  _params: BailianLlmCompletionParams,\n): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  assertRegistered(_params.modelId)\n  const baseURL = typeof _params.baseUrl === 'string' && _params.baseUrl.trim()\n    ? _params.baseUrl.trim()\n    : 'https://dashscope.aliyuncs.com/compatible-mode/v1'\n  const client = new OpenAI({\n    apiKey: _params.apiKey,\n    baseURL,\n    timeout: 30_000,\n  })\n  const completion = await client.chat.completions.create({\n    model: _params.modelId,\n    messages: _params.messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],\n    temperature: _params.temperature ?? 0.7,\n  })\n  return completion as OpenAI.Chat.Completions.ChatCompletion\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/probe.ts",
    "content": "import type { BailianProbeResult, BailianProbeStep } from './types'\n\nfunction classifyStatus(status: number): string {\n  if (status === 401 || status === 403) return `Authentication failed (${status})`\n  if (status === 429) return `Rate limited (${status})`\n  return `Provider error (${status})`\n}\n\nexport async function probeBailian(apiKey: string): Promise<BailianProbeResult> {\n  const steps: BailianProbeStep[] = []\n  try {\n    const response = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/models', {\n      method: 'GET',\n      headers: { Authorization: `Bearer ${apiKey}` },\n      signal: AbortSignal.timeout(20_000),\n    })\n    if (!response.ok) {\n      const detail = await response.text().catch(() => '')\n      steps.push({\n        name: 'models',\n        status: 'fail',\n        message: classifyStatus(response.status),\n        detail: detail.slice(0, 500),\n      })\n      steps.push({\n        name: 'credits',\n        status: 'skip',\n        message: 'Not supported by Bailian probe API',\n      })\n      return { success: false, steps }\n    }\n    const data = await response.json() as { data?: Array<{ id?: string }> }\n    const count = Array.isArray(data.data) ? data.data.length : 0\n    steps.push({\n      name: 'models',\n      status: 'pass',\n      message: `Found ${count} models`,\n    })\n    steps.push({\n      name: 'credits',\n      status: 'skip',\n      message: 'Not supported by Bailian probe API',\n    })\n    return { success: true, steps }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    steps.push({\n      name: 'models',\n      status: 'fail',\n      message: `Network error: ${message}`,\n    })\n    steps.push({\n      name: 'credits',\n      status: 'skip',\n      message: 'Not supported by Bailian probe API',\n    })\n    return { success: false, steps }\n  }\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/tts.ts",
    "content": "import { toFetchableUrl } from '@/lib/storage/utils'\n\nexport const BAILIAN_TTS_MODEL_ID = 'qwen3-tts-vd-2026-01-26'\nconst BAILIAN_TTS_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'\nconst BAILIAN_TTS_MAX_CHARS = 600\n\nexport interface BailianTTSInput {\n  text: string\n  voiceId: string\n  languageType?: string\n  modelId?: string\n}\n\nexport interface BailianTTSResult {\n  success: boolean\n  audioData?: Buffer\n  audioDuration?: number\n  audioUrl?: string\n  requestId?: string\n  error?: string\n  characters?: number\n}\n\ninterface BailianTTSResponse {\n  request_id?: string\n  code?: string\n  message?: string\n  output?: {\n    audio?: {\n      data?: string\n      url?: string\n      id?: string\n      expires_at?: number\n    }\n  }\n  usage?: {\n    characters?: number\n  }\n}\n\ninterface WavFormat {\n  audioFormat: number\n  numChannels: number\n  sampleRate: number\n  byteRate: number\n  blockAlign: number\n  bitsPerSample: number\n}\n\ninterface WavDecoded {\n  format: WavFormat\n  data: Buffer\n}\n\ninterface BailianTTSSegmentResult {\n  audioBuffer: Buffer\n  audioUrl?: string\n  requestId?: string\n  characters: number\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction getWavDurationFromBuffer(buffer: Buffer): number {\n  try {\n    const decoded = decodeWavBuffer(buffer)\n    if (decoded.format.byteRate <= 0) return 0\n    return Math.round((decoded.data.length / decoded.format.byteRate) * 1000)\n  } catch {\n    return 0\n  }\n}\n\nfunction decodeWavBuffer(buffer: Buffer): WavDecoded {\n  if (buffer.length < 44) {\n    throw new Error('BAILIAN_TTS_WAV_TOO_SHORT')\n  }\n  if (buffer.subarray(0, 4).toString('ascii') !== 'RIFF' || buffer.subarray(8, 12).toString('ascii') !== 'WAVE') {\n    throw new Error('BAILIAN_TTS_WAV_INVALID_HEADER')\n  }\n\n  let fmt: WavFormat | null = null\n  let pcmData: Buffer | null = null\n  let offset = 12\n\n  while (offset + 8 <= buffer.length) {\n    const chunkId = buffer.subarray(offset, offset + 4).toString('ascii')\n    const chunkSize = buffer.readUInt32LE(offset + 4)\n    const chunkStart = offset + 8\n    const chunkEnd = chunkStart + chunkSize\n    if (chunkEnd > buffer.length) {\n      throw new Error('BAILIAN_TTS_WAV_CHUNK_OUT_OF_RANGE')\n    }\n\n    if (chunkId === 'fmt ') {\n      if (chunkSize < 16) {\n        throw new Error('BAILIAN_TTS_WAV_FMT_INVALID')\n      }\n      fmt = {\n        audioFormat: buffer.readUInt16LE(chunkStart),\n        numChannels: buffer.readUInt16LE(chunkStart + 2),\n        sampleRate: buffer.readUInt32LE(chunkStart + 4),\n        byteRate: buffer.readUInt32LE(chunkStart + 8),\n        blockAlign: buffer.readUInt16LE(chunkStart + 12),\n        bitsPerSample: buffer.readUInt16LE(chunkStart + 14),\n      }\n    } else if (chunkId === 'data') {\n      pcmData = buffer.subarray(chunkStart, chunkEnd)\n    }\n\n    offset = chunkEnd + (chunkSize % 2)\n  }\n\n  if (!fmt || !pcmData) {\n    throw new Error('BAILIAN_TTS_WAV_MISSING_CHUNKS')\n  }\n\n  return {\n    format: fmt,\n    data: Buffer.from(pcmData),\n  }\n}\n\nfunction buildWavBuffer(format: WavFormat, pcmData: Buffer): Buffer {\n  const headerSize = 44\n  const output = Buffer.allocUnsafe(headerSize + pcmData.length)\n  output.write('RIFF', 0, 'ascii')\n  output.writeUInt32LE(36 + pcmData.length, 4)\n  output.write('WAVE', 8, 'ascii')\n  output.write('fmt ', 12, 'ascii')\n  output.writeUInt32LE(16, 16)\n  output.writeUInt16LE(format.audioFormat, 20)\n  output.writeUInt16LE(format.numChannels, 22)\n  output.writeUInt32LE(format.sampleRate, 24)\n  output.writeUInt32LE(format.byteRate, 28)\n  output.writeUInt16LE(format.blockAlign, 32)\n  output.writeUInt16LE(format.bitsPerSample, 34)\n  output.write('data', 36, 'ascii')\n  output.writeUInt32LE(pcmData.length, 40)\n  pcmData.copy(output, 44)\n  return output\n}\n\nfunction isWavFormatEqual(left: WavFormat, right: WavFormat): boolean {\n  return left.audioFormat === right.audioFormat\n    && left.numChannels === right.numChannels\n    && left.sampleRate === right.sampleRate\n    && left.byteRate === right.byteRate\n    && left.blockAlign === right.blockAlign\n    && left.bitsPerSample === right.bitsPerSample\n}\n\nfunction mergeWavBuffers(buffers: Buffer[]): Buffer {\n  if (buffers.length === 0) {\n    throw new Error('BAILIAN_TTS_SEGMENTS_EMPTY')\n  }\n  if (buffers.length === 1) {\n    return buffers[0]\n  }\n\n  const decoded = buffers.map((buffer) => decodeWavBuffer(buffer))\n  const [first, ...rest] = decoded\n  for (const item of rest) {\n    if (!isWavFormatEqual(first.format, item.format)) {\n      throw new Error('BAILIAN_TTS_SEGMENT_WAV_FORMAT_MISMATCH')\n    }\n  }\n  const mergedData = Buffer.concat(decoded.map((item) => item.data))\n  return buildWavBuffer(first.format, mergedData)\n}\n\nconst SPLIT_HINT_CHARS = new Set([\n  '。', '！', '？', '；', '，', '、',\n  '.', '!', '?', ';', ',', ':', '：',\n  '\\n',\n])\n\nfunction splitTextByLimit(text: string, maxChars: number): string[] {\n  const trimmed = text.trim()\n  if (!trimmed) return []\n  const chars = Array.from(trimmed)\n  if (chars.length <= maxChars) return [trimmed]\n\n  const segments: string[] = []\n  let cursor = 0\n  while (cursor < chars.length) {\n    const hardEnd = Math.min(cursor + maxChars, chars.length)\n    if (hardEnd === chars.length) {\n      const segment = chars.slice(cursor, hardEnd).join('').trim()\n      if (segment) segments.push(segment)\n      break\n    }\n\n    let splitPoint = hardEnd\n    for (let index = hardEnd - 1; index > cursor; index -= 1) {\n      if (SPLIT_HINT_CHARS.has(chars[index])) {\n        splitPoint = index + 1\n        break\n      }\n    }\n\n    const segment = chars.slice(cursor, splitPoint).join('').trim()\n    if (!segment) {\n      throw new Error('BAILIAN_TTS_SPLIT_FAILED')\n    }\n    segments.push(segment)\n    cursor = splitPoint\n    while (cursor < chars.length && /\\s/.test(chars[cursor])) {\n      cursor += 1\n    }\n  }\n\n  return segments\n}\n\nasync function parseBailianTTSResponse(response: Response): Promise<BailianTTSResponse> {\n  const raw = await response.text()\n  if (!raw) return {}\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!parsed || typeof parsed !== 'object') {\n      throw new Error('BAILIAN_TTS_RESPONSE_INVALID')\n    }\n    return parsed as BailianTTSResponse\n  } catch {\n    throw new Error('BAILIAN_TTS_RESPONSE_INVALID_JSON')\n  }\n}\n\nasync function readAudioBufferFromResponseAudio(audio: NonNullable<BailianTTSResponse['output']>['audio']): Promise<{\n  audioBuffer: Buffer\n  audioUrl?: string\n}> {\n  const audioDataBase64 = readTrimmedString(audio?.data)\n  const audioUrl = readTrimmedString(audio?.url)\n\n  if (audioDataBase64) {\n    return {\n      audioBuffer: Buffer.from(audioDataBase64, 'base64'),\n      audioUrl: audioUrl || undefined,\n    }\n  }\n  if (!audioUrl) {\n    throw new Error('BAILIAN_TTS_AUDIO_MISSING')\n  }\n\n  const audioResponse = await fetch(toFetchableUrl(audioUrl))\n  if (!audioResponse.ok) {\n    throw new Error(`BAILIAN_TTS_AUDIO_DOWNLOAD_FAILED(${audioResponse.status})`)\n  }\n  const arrayBuffer = await audioResponse.arrayBuffer()\n  return {\n    audioBuffer: Buffer.from(arrayBuffer),\n    audioUrl,\n  }\n}\n\nasync function synthesizeSegment(params: {\n  text: string\n  voiceId: string\n  languageType: string\n  modelId: string\n  apiKey: string\n}): Promise<BailianTTSSegmentResult> {\n  const response = await fetch(BAILIAN_TTS_ENDPOINT, {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${params.apiKey}`,\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      model: params.modelId,\n      input: {\n        text: params.text,\n        voice: params.voiceId,\n        language_type: params.languageType,\n      },\n    }),\n  })\n  const data = await parseBailianTTSResponse(response)\n  if (!response.ok) {\n    const code = readTrimmedString(data.code)\n    const message = readTrimmedString(data.message)\n    throw new Error(`BAILIAN_TTS_FAILED(${response.status}): ${code || message || 'unknown error'}`)\n  }\n\n  const outputAudio = data.output?.audio\n  if (!outputAudio) {\n    throw new Error('BAILIAN_TTS_OUTPUT_AUDIO_MISSING')\n  }\n\n  const audio = await readAudioBufferFromResponseAudio(outputAudio)\n  const characters = typeof data.usage?.characters === 'number' && Number.isFinite(data.usage.characters)\n    ? data.usage.characters\n    : 0\n\n  return {\n    audioBuffer: audio.audioBuffer,\n    audioUrl: audio.audioUrl,\n    requestId: readTrimmedString(data.request_id) || undefined,\n    characters,\n  }\n}\n\nexport async function synthesizeWithBailianTTS(\n  input: BailianTTSInput,\n  apiKey: string,\n): Promise<BailianTTSResult> {\n  const text = readTrimmedString(input.text)\n  const voiceId = readTrimmedString(input.voiceId)\n  const languageType = readTrimmedString(input.languageType) || 'Chinese'\n  const modelId = readTrimmedString(input.modelId) || BAILIAN_TTS_MODEL_ID\n\n  if (!apiKey.trim()) {\n    return { success: false, error: 'BAILIAN_API_KEY_REQUIRED' }\n  }\n  if (!text) {\n    return { success: false, error: 'BAILIAN_TTS_TEXT_REQUIRED' }\n  }\n  if (!voiceId) {\n    return { success: false, error: 'BAILIAN_TTS_VOICE_ID_REQUIRED' }\n  }\n\n  const segments = splitTextByLimit(text, BAILIAN_TTS_MAX_CHARS)\n  if (segments.length === 0) {\n    return { success: false, error: 'BAILIAN_TTS_TEXT_REQUIRED' }\n  }\n\n  try {\n    const buffers: Buffer[] = []\n    let totalCharacters = 0\n    let lastRequestId: string | undefined\n    let firstAudioUrl: string | undefined\n\n    for (const segment of segments) {\n      const result = await synthesizeSegment({\n        text: segment,\n        voiceId,\n        languageType,\n        modelId,\n        apiKey,\n      })\n      buffers.push(result.audioBuffer)\n      totalCharacters += result.characters\n      if (!firstAudioUrl && result.audioUrl) {\n        firstAudioUrl = result.audioUrl\n      }\n      if (result.requestId) {\n        lastRequestId = result.requestId\n      }\n    }\n\n    const mergedAudio = mergeWavBuffers(buffers)\n    return {\n      success: true,\n      audioData: mergedAudio,\n      audioDuration: getWavDurationFromBuffer(mergedAudio),\n      audioUrl: segments.length === 1 ? firstAudioUrl : undefined,\n      requestId: lastRequestId,\n      characters: totalCharacters,\n    }\n  } catch (error: unknown) {\n    return {\n      success: false,\n      error: error instanceof Error ? error.message : 'BAILIAN_TTS_UNKNOWN_ERROR',\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/types.ts",
    "content": "export type BailianProviderKey = 'bailian'\n\nexport interface BailianGenerateRequestOptions {\n  provider: string\n  modelId: string\n  modelKey: string\n  [key: string]: unknown\n}\n\nexport interface BailianLlmMessage {\n  role: 'user' | 'assistant' | 'system'\n  content: string\n}\n\nexport interface BailianProbeStep {\n  name: 'models' | 'credits'\n  status: 'pass' | 'fail' | 'skip'\n  message: string\n  detail?: string\n}\n\nexport interface BailianProbeResult {\n  success: boolean\n  steps: BailianProbeStep[]\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/video.ts",
    "content": "import {\n  assertOfficialModelRegistered,\n  type OfficialModelModality,\n} from '@/lib/providers/official/model-registry'\nimport { getProviderConfig } from '@/lib/api-config'\nimport type { GenerateResult } from '@/lib/generators/base'\nimport { toFetchableUrl } from '@/lib/storage/utils'\nimport { ensureBailianCatalogRegistered } from './catalog'\nimport type { BailianGenerateRequestOptions } from './types'\n\nexport interface BailianVideoGenerateParams {\n  userId: string\n  imageUrl: string\n  prompt?: string\n  options: BailianGenerateRequestOptions\n}\n\nfunction assertRegistered(modelId: string): void {\n  ensureBailianCatalogRegistered()\n  assertOfficialModelRegistered({\n    provider: 'bailian',\n    modality: 'video' satisfies OfficialModelModality,\n    modelId,\n  })\n}\n\nconst BAILIAN_VIDEO_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis'\nconst BAILIAN_KF2V_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis'\nconst BAILIAN_KF2V_MODELS = new Set([\n  'wan2.2-kf2v-flash',\n  'wanx2.1-kf2v-plus',\n])\n\ninterface BailianVideoSubmitResponse {\n  request_id?: string\n  code?: string\n  message?: string\n  output?: {\n    task_id?: string\n    task_status?: string\n  }\n}\n\ninterface BailianVideoSubmitParameters {\n  resolution?: string\n  size?: string\n  watermark?: boolean\n  prompt_extend?: boolean\n  duration?: number\n}\n\ninterface BailianVideoSubmitBody {\n  model: string\n  input: Record<string, string>\n  parameters?: BailianVideoSubmitParameters\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction readOptionalBoolean(value: unknown): boolean | undefined {\n  return typeof value === 'boolean' ? value : undefined\n}\n\nfunction readOptionalPositiveInteger(value: unknown, fieldName: string): number | undefined {\n  if (value === undefined) return undefined\n  if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {\n    throw new Error(`BAILIAN_VIDEO_OPTION_INVALID_${fieldName.toUpperCase()}`)\n  }\n  return value\n}\n\nfunction isKf2vModel(modelId: string): boolean {\n  return BAILIAN_KF2V_MODELS.has(modelId)\n}\n\nfunction assertNoUnsupportedOptions(options: BailianGenerateRequestOptions): void {\n  const allowedOptionKeys = new Set([\n    'provider',\n    'modelId',\n    'modelKey',\n    'prompt',\n    'resolution',\n    'size',\n    'watermark',\n    'promptExtend',\n    'duration',\n    'lastFrameImageUrl',\n  ])\n  for (const [key, value] of Object.entries(options)) {\n    if (value === undefined) continue\n    if (!allowedOptionKeys.has(key)) {\n      throw new Error(`BAILIAN_VIDEO_OPTION_UNSUPPORTED: ${key}`)\n    }\n  }\n}\n\nfunction buildSubmitRequest(params: BailianVideoGenerateParams): {\n  endpoint: string\n  body: BailianVideoSubmitBody\n} {\n  const imageUrl = readTrimmedString(params.imageUrl)\n  if (!imageUrl) {\n    throw new Error('BAILIAN_VIDEO_IMAGE_URL_REQUIRED')\n  }\n  const modelId = readTrimmedString(params.options.modelId)\n  if (!modelId) {\n    throw new Error('BAILIAN_VIDEO_MODEL_ID_REQUIRED')\n  }\n\n  const firstFrameUrl = toFetchableUrl(imageUrl)\n  const kf2v = isKf2vModel(modelId)\n  const lastFrameImageUrl = readTrimmedString(params.options.lastFrameImageUrl)\n  if (kf2v && !lastFrameImageUrl) {\n    throw new Error('BAILIAN_VIDEO_LAST_FRAME_IMAGE_URL_REQUIRED')\n  }\n  if (!kf2v && lastFrameImageUrl) {\n    throw new Error(`BAILIAN_VIDEO_LAST_FRAME_UNSUPPORTED_FOR_MODEL: ${modelId}`)\n  }\n\n  const prompt = readTrimmedString(params.prompt) || readTrimmedString(params.options.prompt)\n  const resolution = readTrimmedString(params.options.resolution)\n  const size = readTrimmedString(params.options.size)\n  const watermark = readOptionalBoolean(params.options.watermark)\n  const promptExtend = readOptionalBoolean(params.options.promptExtend)\n  const duration = readOptionalPositiveInteger(params.options.duration, 'duration')\n\n  const submitBody: BailianVideoSubmitBody = {\n    model: modelId,\n    input: kf2v\n      ? {\n        first_frame_url: firstFrameUrl,\n        last_frame_url: toFetchableUrl(lastFrameImageUrl),\n      }\n      : {\n        img_url: firstFrameUrl,\n      },\n  }\n  if (prompt) {\n    submitBody.input.prompt = prompt\n  }\n\n  const submitParameters: BailianVideoSubmitParameters = {}\n  if (resolution) {\n    submitParameters.resolution = resolution\n  }\n  if (size) {\n    submitParameters.size = size\n  }\n  if (typeof watermark === 'boolean') {\n    submitParameters.watermark = watermark\n  }\n  if (typeof promptExtend === 'boolean') {\n    submitParameters.prompt_extend = promptExtend\n  }\n  if (typeof duration === 'number') {\n    submitParameters.duration = duration\n  }\n  if (Object.keys(submitParameters).length > 0) {\n    submitBody.parameters = submitParameters\n  }\n\n  return {\n    endpoint: kf2v ? BAILIAN_KF2V_ENDPOINT : BAILIAN_VIDEO_ENDPOINT,\n    body: submitBody,\n  }\n}\n\nasync function parseSubmitResponse(response: Response): Promise<BailianVideoSubmitResponse> {\n  const raw = await response.text()\n  if (!raw) return {}\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!parsed || typeof parsed !== 'object') {\n      throw new Error('BAILIAN_VIDEO_RESPONSE_INVALID')\n    }\n    return parsed as BailianVideoSubmitResponse\n  } catch {\n    throw new Error('BAILIAN_VIDEO_RESPONSE_INVALID_JSON')\n  }\n}\n\nexport async function generateBailianVideo(params: BailianVideoGenerateParams): Promise<GenerateResult> {\n  assertRegistered(params.options.modelId)\n  assertNoUnsupportedOptions(params.options)\n\n  const { apiKey } = await getProviderConfig(params.userId, params.options.provider)\n  const submitRequest = buildSubmitRequest(params)\n  const response = await fetch(submitRequest.endpoint, {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${apiKey}`,\n      'Content-Type': 'application/json',\n      'X-DashScope-Async': 'enable',\n    },\n    body: JSON.stringify(submitRequest.body),\n  })\n  const data = await parseSubmitResponse(response)\n\n  if (!response.ok) {\n    const code = readTrimmedString(data.code)\n    const message = readTrimmedString(data.message)\n    throw new Error(`BAILIAN_VIDEO_SUBMIT_FAILED(${response.status}): ${code || message || 'unknown error'}`)\n  }\n\n  const taskId = readTrimmedString(data.output?.task_id)\n  if (!taskId) {\n    throw new Error('BAILIAN_VIDEO_TASK_ID_MISSING')\n  }\n\n  return {\n    success: true,\n    async: true,\n    requestId: taskId,\n    externalId: `BAILIAN:VIDEO:${taskId}`,\n  }\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/voice-cleanup.ts",
    "content": "import { getProviderConfig } from '@/lib/api-config'\nimport { prisma } from '@/lib/prisma'\nimport { deleteBailianVoice } from './voice-manage'\n\nexport interface BailianVoiceBinding {\n  voiceId?: string | null\n  voiceType?: string | null\n}\n\ninterface CleanupReferenceScope {\n  userId: string\n  excludeProjectId?: string\n  excludeNovelCharacterId?: string\n  excludeGlobalCharacterId?: string\n}\n\nexport interface BailianVoiceCleanupResult {\n  requestedVoiceIds: string[]\n  skippedReferencedVoiceIds: string[]\n  deletedVoiceIds: string[]\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction toLowerCase(value: string | null | undefined): string {\n  return typeof value === 'string' ? value.trim().toLowerCase() : ''\n}\n\nfunction looksLikeBailianVoiceId(voiceId: string): boolean {\n  return voiceId.startsWith('qwen-tts-vd-')\n}\n\nexport function isBailianManagedVoiceBinding(binding: BailianVoiceBinding): boolean {\n  const voiceId = readTrimmedString(binding.voiceId)\n  if (!voiceId) return false\n\n  const voiceType = toLowerCase(binding.voiceType)\n  if (voiceType === 'qwen-designed') return true\n  return looksLikeBailianVoiceId(voiceId)\n}\n\nexport function collectBailianManagedVoiceIds(bindings: BailianVoiceBinding[]): string[] {\n  const deduped = new Set<string>()\n  for (const binding of bindings) {\n    if (!isBailianManagedVoiceBinding(binding)) continue\n    const voiceId = readTrimmedString(binding.voiceId)\n    if (!voiceId) continue\n    deduped.add(voiceId)\n  }\n  return Array.from(deduped)\n}\n\nfunction parseSpeakerVoiceBindings(raw: string | null | undefined): BailianVoiceBinding[] {\n  const source = readTrimmedString(raw)\n  if (!source) return []\n\n  let parsed: unknown\n  try {\n    parsed = JSON.parse(source)\n  } catch {\n    return []\n  }\n  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n    return []\n  }\n\n  const bindings: BailianVoiceBinding[] = []\n  for (const value of Object.values(parsed)) {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) continue\n    const node = value as Record<string, unknown>\n    bindings.push({\n      voiceId: readTrimmedString(node.voiceId) || null,\n      voiceType: readTrimmedString(node.voiceType) || null,\n    })\n  }\n  return bindings\n}\n\nexport async function collectProjectBailianManagedVoiceIds(projectId: string): Promise<string[]> {\n  const novelProject = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    select: {\n      characters: {\n        select: {\n          voiceId: true,\n          voiceType: true,\n        },\n      },\n      episodes: {\n        select: {\n          speakerVoices: true,\n        },\n      },\n    },\n  })\n  if (!novelProject) return []\n\n  const bindings: BailianVoiceBinding[] = []\n  for (const character of novelProject.characters) {\n    bindings.push({\n      voiceId: character.voiceId,\n      voiceType: character.voiceType,\n    })\n  }\n  for (const episode of novelProject.episodes) {\n    bindings.push(...parseSpeakerVoiceBindings(episode.speakerVoices))\n  }\n  return collectBailianManagedVoiceIds(bindings)\n}\n\nasync function findReferencedVoiceIds(params: {\n  voiceIds: string[]\n  scope: CleanupReferenceScope\n}): Promise<Set<string>> {\n  const voiceIds = params.voiceIds\n  const scope = params.scope\n  const referenced = new Set<string>()\n\n  const novelCharacters = await prisma.novelPromotionCharacter.findMany({\n    where: {\n      voiceId: { in: voiceIds },\n      ...(scope.excludeNovelCharacterId\n        ? { id: { not: scope.excludeNovelCharacterId } }\n        : {}),\n      novelPromotionProject: {\n        project: {\n          userId: scope.userId,\n          ...(scope.excludeProjectId\n            ? { id: { not: scope.excludeProjectId } }\n            : {}),\n        },\n      },\n    },\n    select: { voiceId: true },\n  })\n  for (const row of novelCharacters) {\n    const voiceId = readTrimmedString(row.voiceId)\n    if (voiceId) referenced.add(voiceId)\n  }\n\n  const globalCharacters = await prisma.globalCharacter.findMany({\n    where: {\n      userId: scope.userId,\n      voiceId: { in: voiceIds },\n      ...(scope.excludeGlobalCharacterId\n        ? { id: { not: scope.excludeGlobalCharacterId } }\n        : {}),\n    },\n    select: { voiceId: true },\n  })\n  for (const row of globalCharacters) {\n    const voiceId = readTrimmedString(row.voiceId)\n    if (voiceId) referenced.add(voiceId)\n  }\n\n  const globalVoices = await prisma.globalVoice.findMany({\n    where: {\n      userId: scope.userId,\n      voiceId: { in: voiceIds },\n    },\n    select: { voiceId: true },\n  })\n  for (const row of globalVoices) {\n    const voiceId = readTrimmedString(row.voiceId)\n    if (voiceId) referenced.add(voiceId)\n  }\n\n  const episodes = await prisma.novelPromotionEpisode.findMany({\n    where: {\n      speakerVoices: { not: null },\n      novelPromotionProject: {\n        project: {\n          userId: scope.userId,\n          ...(scope.excludeProjectId\n            ? { id: { not: scope.excludeProjectId } }\n            : {}),\n        },\n      },\n    },\n    select: { speakerVoices: true },\n  })\n  for (const episode of episodes) {\n    const bindings = parseSpeakerVoiceBindings(episode.speakerVoices)\n    for (const binding of bindings) {\n      const voiceId = readTrimmedString(binding.voiceId)\n      if (!voiceId) continue\n      if (!voiceIds.includes(voiceId)) continue\n      if (!isBailianManagedVoiceBinding(binding)) continue\n      referenced.add(voiceId)\n    }\n  }\n\n  return referenced\n}\n\nexport async function cleanupUnreferencedBailianVoices(params: {\n  voiceIds: string[]\n  scope: CleanupReferenceScope\n}): Promise<BailianVoiceCleanupResult> {\n  const dedupedVoiceIds = Array.from(\n    new Set(\n      params.voiceIds\n        .map((item) => readTrimmedString(item))\n        .filter((item) => item.length > 0),\n    ),\n  )\n  if (dedupedVoiceIds.length === 0) {\n    return {\n      requestedVoiceIds: [],\n      skippedReferencedVoiceIds: [],\n      deletedVoiceIds: [],\n    }\n  }\n\n  const referenced = await findReferencedVoiceIds({\n    voiceIds: dedupedVoiceIds,\n    scope: params.scope,\n  })\n\n  const toDelete = dedupedVoiceIds.filter((voiceId) => !referenced.has(voiceId))\n  if (toDelete.length === 0) {\n    return {\n      requestedVoiceIds: dedupedVoiceIds,\n      skippedReferencedVoiceIds: dedupedVoiceIds,\n      deletedVoiceIds: [],\n    }\n  }\n\n  const { apiKey } = await getProviderConfig(params.scope.userId, 'bailian')\n  const deletedVoiceIds: string[] = []\n  for (const voiceId of toDelete) {\n    try {\n      await deleteBailianVoice({ apiKey, voiceId })\n      deletedVoiceIds.push(voiceId)\n    } catch (error: unknown) {\n      const message = error instanceof Error ? error.message : String(error)\n      throw new Error(`BAILIAN_VOICE_CLEANUP_FAILED(${voiceId}): ${message}`)\n    }\n  }\n\n  return {\n    requestedVoiceIds: dedupedVoiceIds,\n    skippedReferencedVoiceIds: dedupedVoiceIds.filter((voiceId) => referenced.has(voiceId)),\n    deletedVoiceIds,\n  }\n}\n\n"
  },
  {
    "path": "src/lib/providers/bailian/voice-design.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\n\nexport interface VoiceDesignInput {\n  voicePrompt: string\n  previewText: string\n  preferredName?: string\n  language?: 'zh' | 'en'\n}\n\nexport interface VoiceDesignResult {\n  success: boolean\n  voiceId?: string\n  targetModel?: string\n  audioBase64?: string\n  sampleRate?: number\n  responseFormat?: string\n  usageCount?: number\n  requestId?: string\n  error?: string\n  errorCode?: string\n}\n\nexport async function createVoiceDesign(\n  input: VoiceDesignInput,\n  apiKey: string,\n): Promise<VoiceDesignResult> {\n  if (!apiKey) {\n    return {\n      success: false,\n      error: '请配置阿里百炼 API Key',\n    }\n  }\n\n  const requestBody = {\n    model: 'qwen-voice-design',\n    input: {\n      action: 'create',\n      target_model: 'qwen3-tts-vd-2026-01-26',\n      voice_prompt: input.voicePrompt,\n      preview_text: input.previewText,\n      preferred_name: input.preferredName || 'custom_voice',\n      language: input.language || 'zh',\n    },\n    parameters: {\n      sample_rate: 24000,\n      response_format: 'wav',\n    },\n  }\n\n  _ulogInfo('[VoiceDesign] 请求体:', JSON.stringify(requestBody, null, 2))\n\n  try {\n    const response = await fetch('https://dashscope.aliyuncs.com/api/v1/services/audio/tts/customization', {\n      method: 'POST',\n      headers: {\n        Authorization: `Bearer ${apiKey}`,\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify(requestBody),\n    })\n\n    const data = await response.json() as {\n      output?: {\n        voice?: string\n        target_model?: string\n        preview_audio?: {\n          data?: string\n          sample_rate?: number\n          response_format?: string\n        }\n      }\n      usage?: { count?: number }\n      request_id?: string\n      code?: string\n      message?: string\n    }\n\n    if (response.ok && data.output) {\n      return {\n        success: true,\n        voiceId: data.output.voice,\n        targetModel: data.output.target_model,\n        audioBase64: data.output.preview_audio?.data,\n        sampleRate: data.output.preview_audio?.sample_rate,\n        responseFormat: data.output.preview_audio?.response_format,\n        usageCount: data.usage?.count,\n        requestId: data.request_id,\n      }\n    }\n\n    return {\n      success: false,\n      error: data.message || '声音设计 API 调用失败',\n      errorCode: data.code,\n      requestId: data.request_id,\n    }\n  } catch (error: unknown) {\n    const message = error instanceof Error ? error.message : '网络请求失败'\n    return {\n      success: false,\n      error: message || '网络请求失败',\n    }\n  }\n}\n\nexport function validateVoicePrompt(voicePrompt: string): { valid: boolean; error?: string } {\n  if (!voicePrompt || voicePrompt.trim().length === 0) {\n    return { valid: false, error: '声音提示词不能为空' }\n  }\n  if (voicePrompt.length > 500) {\n    return { valid: false, error: '声音提示词不能超过500个字符' }\n  }\n  return { valid: true }\n}\n\nexport function validatePreviewText(previewText: string): { valid: boolean; error?: string } {\n  if (!previewText || previewText.trim().length === 0) {\n    return { valid: false, error: '预览文本不能为空' }\n  }\n  if (previewText.length < 5) {\n    return { valid: false, error: '预览文本至少需要5个字符' }\n  }\n  if (previewText.length > 200) {\n    return { valid: false, error: '预览文本不能超过200个字符' }\n  }\n  return { valid: true }\n}\n"
  },
  {
    "path": "src/lib/providers/bailian/voice-manage.ts",
    "content": "const BAILIAN_VOICE_CUSTOMIZATION_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/customization'\n\ninterface BailianVoiceManageResponse {\n  request_id?: string\n  code?: string\n  message?: string\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nasync function parseManageResponse(response: Response): Promise<BailianVoiceManageResponse> {\n  const raw = await response.text()\n  if (!raw) return {}\n  try {\n    const parsed = JSON.parse(raw) as unknown\n    if (!parsed || typeof parsed !== 'object') {\n      throw new Error('BAILIAN_VOICE_MANAGE_RESPONSE_INVALID')\n    }\n    return parsed as BailianVoiceManageResponse\n  } catch {\n    throw new Error('BAILIAN_VOICE_MANAGE_RESPONSE_INVALID_JSON')\n  }\n}\n\nexport async function deleteBailianVoice(params: {\n  apiKey: string\n  voiceId: string\n}): Promise<{ requestId?: string }> {\n  const apiKey = readTrimmedString(params.apiKey)\n  const voiceId = readTrimmedString(params.voiceId)\n  if (!apiKey) {\n    throw new Error('BAILIAN_API_KEY_REQUIRED')\n  }\n  if (!voiceId) {\n    throw new Error('BAILIAN_VOICE_ID_REQUIRED')\n  }\n\n  const response = await fetch(BAILIAN_VOICE_CUSTOMIZATION_ENDPOINT, {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${apiKey}`,\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({\n      model: 'qwen-voice-design',\n      input: {\n        action: 'delete',\n        voice: voiceId,\n      },\n    }),\n  })\n\n  const data = await parseManageResponse(response)\n  if (!response.ok) {\n    const code = readTrimmedString(data.code)\n    const message = readTrimmedString(data.message)\n    throw new Error(`BAILIAN_VOICE_DELETE_FAILED(${response.status}): ${code || message || 'unknown error'}`)\n  }\n\n  return {\n    requestId: readTrimmedString(data.request_id) || undefined,\n  }\n}\n\n"
  },
  {
    "path": "src/lib/providers/fal/base-url.ts",
    "content": "const DEFAULT_FAL_QUEUE_BASE_URL = 'https://queue.fal.run'\n\nfunction normalizeBaseUrl(value: string): string {\n  return value.replace(/\\/+$/, '')\n}\n\nexport function resolveFalQueueBaseUrl(): string {\n  const override = process.env.FAL_QUEUE_BASE_URL?.trim()\n  if (!override) return DEFAULT_FAL_QUEUE_BASE_URL\n  return normalizeBaseUrl(override)\n}\n\nexport function buildFalQueueUrl(path: string): string {\n  const normalizedPath = path.replace(/^\\/+/, '')\n  return `${resolveFalQueueBaseUrl()}/${normalizedPath}`\n}\n"
  },
  {
    "path": "src/lib/providers/official/model-registry.ts",
    "content": "export type OfficialProviderKey = 'bailian' | 'siliconflow'\nexport type OfficialModelModality = 'llm' | 'image' | 'video' | 'audio'\n\ninterface RegisterOfficialModelInput {\n  provider: OfficialProviderKey\n  modality: OfficialModelModality\n  modelId: string\n}\n\ninterface AssertOfficialModelInput {\n  provider: OfficialProviderKey\n  modality: OfficialModelModality\n  modelId: string\n}\n\nconst registry = new Set<string>()\n\nfunction buildRegistryKey(input: RegisterOfficialModelInput): string {\n  return `${input.provider}::${input.modality}::${input.modelId}`\n}\n\nfunction readTrimmedString(value: string): string {\n  return value.trim()\n}\n\nexport function registerOfficialModel(input: RegisterOfficialModelInput): void {\n  const modelId = readTrimmedString(input.modelId)\n  if (!modelId) {\n    throw new Error('MODEL_REGISTRY_INVALID_MODEL_ID')\n  }\n  registry.add(buildRegistryKey({ ...input, modelId }))\n}\n\nexport function isOfficialModelRegistered(input: AssertOfficialModelInput): boolean {\n  const modelId = readTrimmedString(input.modelId)\n  if (!modelId) return false\n  return registry.has(buildRegistryKey({ ...input, modelId }))\n}\n\nexport function assertOfficialModelRegistered(input: AssertOfficialModelInput): void {\n  if (isOfficialModelRegistered(input)) return\n  throw new Error(`MODEL_NOT_REGISTERED: ${input.provider}/${input.modality}/${input.modelId}`)\n}\n\nexport function resetOfficialModelRegistryForTest(): void {\n  registry.clear()\n}\n"
  },
  {
    "path": "src/lib/providers/siliconflow/audio.ts",
    "content": "import {\n  assertOfficialModelRegistered,\n  type OfficialModelModality,\n} from '@/lib/providers/official/model-registry'\nimport { ensureSiliconFlowCatalogRegistered } from './catalog'\nimport type { SiliconFlowGenerateRequestOptions } from './types'\n\nexport interface SiliconFlowAudioGenerateParams {\n  userId: string\n  text: string\n  voice?: string\n  rate?: number\n  options: SiliconFlowGenerateRequestOptions\n}\n\nfunction assertRegistered(modelId: string): void {\n  ensureSiliconFlowCatalogRegistered()\n  assertOfficialModelRegistered({\n    provider: 'siliconflow',\n    modality: 'audio' satisfies OfficialModelModality,\n    modelId,\n  })\n}\n\nexport async function generateSiliconFlowAudio(params: SiliconFlowAudioGenerateParams): Promise<never> {\n  assertRegistered(params.options.modelId)\n  throw new Error('OFFICIAL_PROVIDER_NOT_IMPLEMENTED: siliconflow audio')\n}\n"
  },
  {
    "path": "src/lib/providers/siliconflow/catalog.ts",
    "content": "import { registerOfficialModel } from '@/lib/providers/official/model-registry'\nimport type { OfficialModelModality } from '@/lib/providers/official/model-registry'\n\nconst SILICONFLOW_CATALOG: Readonly<Record<OfficialModelModality, readonly string[]>> = {\n  llm: [],\n  image: [],\n  video: [],\n  audio: [],\n}\n\nlet initialized = false\n\nexport function ensureSiliconFlowCatalogRegistered(): void {\n  if (initialized) return\n  initialized = true\n  for (const modality of Object.keys(SILICONFLOW_CATALOG) as OfficialModelModality[]) {\n    for (const modelId of SILICONFLOW_CATALOG[modality]) {\n      registerOfficialModel({ provider: 'siliconflow', modality, modelId })\n    }\n  }\n}\n\nexport function listSiliconFlowCatalogModels(modality: OfficialModelModality): readonly string[] {\n  ensureSiliconFlowCatalogRegistered()\n  return SILICONFLOW_CATALOG[modality]\n}\n"
  },
  {
    "path": "src/lib/providers/siliconflow/image.ts",
    "content": "import {\n  assertOfficialModelRegistered,\n  type OfficialModelModality,\n} from '@/lib/providers/official/model-registry'\nimport { ensureSiliconFlowCatalogRegistered } from './catalog'\nimport type { SiliconFlowGenerateRequestOptions } from './types'\n\nexport interface SiliconFlowImageGenerateParams {\n  userId: string\n  prompt: string\n  referenceImages?: string[]\n  options: SiliconFlowGenerateRequestOptions\n}\n\nfunction assertRegistered(modelId: string): void {\n  ensureSiliconFlowCatalogRegistered()\n  assertOfficialModelRegistered({\n    provider: 'siliconflow',\n    modality: 'image' satisfies OfficialModelModality,\n    modelId,\n  })\n}\n\nexport async function generateSiliconFlowImage(params: SiliconFlowImageGenerateParams): Promise<never> {\n  assertRegistered(params.options.modelId)\n  throw new Error('OFFICIAL_PROVIDER_NOT_IMPLEMENTED: siliconflow image')\n}\n"
  },
  {
    "path": "src/lib/providers/siliconflow/index.ts",
    "content": "export { ensureSiliconFlowCatalogRegistered, listSiliconFlowCatalogModels } from './catalog'\nexport { completeSiliconFlowLlm } from './llm'\nexport { generateSiliconFlowImage } from './image'\nexport { generateSiliconFlowVideo } from './video'\nexport { generateSiliconFlowAudio } from './audio'\nexport { probeSiliconFlow } from './probe'\nexport type {\n  SiliconFlowGenerateRequestOptions,\n  SiliconFlowLlmMessage,\n  SiliconFlowProbeResult,\n  SiliconFlowProbeStep,\n} from './types'\n"
  },
  {
    "path": "src/lib/providers/siliconflow/llm.ts",
    "content": "import type OpenAI from 'openai'\nimport {\n  assertOfficialModelRegistered,\n  type OfficialModelModality,\n} from '@/lib/providers/official/model-registry'\nimport { ensureSiliconFlowCatalogRegistered } from './catalog'\nimport type { SiliconFlowLlmMessage } from './types'\n\nexport interface SiliconFlowLlmCompletionParams {\n  modelId: string\n  messages: SiliconFlowLlmMessage[]\n  apiKey: string\n  baseUrl?: string\n  temperature?: number\n}\n\nfunction assertRegistered(modelId: string): void {\n  ensureSiliconFlowCatalogRegistered()\n  assertOfficialModelRegistered({\n    provider: 'siliconflow',\n    modality: 'llm' satisfies OfficialModelModality,\n    modelId,\n  })\n}\n\nexport async function completeSiliconFlowLlm(\n  params: SiliconFlowLlmCompletionParams,\n): Promise<OpenAI.Chat.Completions.ChatCompletion> {\n  assertRegistered(params.modelId)\n  throw new Error('OFFICIAL_PROVIDER_NOT_IMPLEMENTED: siliconflow llm')\n}\n"
  },
  {
    "path": "src/lib/providers/siliconflow/probe.ts",
    "content": "import type { SiliconFlowProbeResult, SiliconFlowProbeStep } from './types'\n\nfunction classifyStatus(status: number): string {\n  if (status === 401 || status === 403) return `Authentication failed (${status})`\n  if (status === 429) return `Rate limited (${status})`\n  return `Provider error (${status})`\n}\n\nexport async function probeSiliconFlow(apiKey: string): Promise<SiliconFlowProbeResult> {\n  const steps: SiliconFlowProbeStep[] = []\n  const headers = { Authorization: `Bearer ${apiKey}` }\n\n  try {\n    const modelsResponse = await fetch('https://api.siliconflow.cn/v1/models', {\n      method: 'GET',\n      headers,\n      signal: AbortSignal.timeout(20_000),\n    })\n    if (!modelsResponse.ok) {\n      const detail = await modelsResponse.text().catch(() => '')\n      steps.push({\n        name: 'models',\n        status: 'fail',\n        message: classifyStatus(modelsResponse.status),\n        detail: detail.slice(0, 500),\n      })\n      steps.push({\n        name: 'credits',\n        status: 'skip',\n        message: 'Skipped because model probe failed',\n      })\n      return { success: false, steps }\n    }\n\n    const modelData = await modelsResponse.json() as { data?: Array<{ id?: string }> }\n    const count = Array.isArray(modelData.data) ? modelData.data.length : 0\n    steps.push({\n      name: 'models',\n      status: 'pass',\n      message: `Found ${count} models`,\n    })\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    steps.push({\n      name: 'models',\n      status: 'fail',\n      message: `Network error: ${message}`,\n    })\n    steps.push({\n      name: 'credits',\n      status: 'skip',\n      message: 'Skipped because model probe failed',\n    })\n    return { success: false, steps }\n  }\n\n  try {\n    const userInfoResponse = await fetch('https://api.siliconflow.cn/v1/user/info', {\n      method: 'GET',\n      headers,\n      signal: AbortSignal.timeout(20_000),\n    })\n    if (!userInfoResponse.ok) {\n      const detail = await userInfoResponse.text().catch(() => '')\n      steps.push({\n        name: 'credits',\n        status: 'fail',\n        message: classifyStatus(userInfoResponse.status),\n        detail: detail.slice(0, 500),\n      })\n      return { success: false, steps }\n    }\n\n    const info = await userInfoResponse.json() as { balance?: unknown; data?: { balance?: unknown } }\n    const balance = typeof info.balance === 'number'\n      ? info.balance\n      : typeof info.data?.balance === 'number'\n        ? info.data.balance\n        : undefined\n    steps.push({\n      name: 'credits',\n      status: 'pass',\n      message: typeof balance === 'number' ? `Balance: ${balance}` : 'User info reachable',\n    })\n    return { success: true, steps }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error)\n    steps.push({\n      name: 'credits',\n      status: 'fail',\n      message: `Network error: ${message}`,\n    })\n    return { success: false, steps }\n  }\n}\n"
  },
  {
    "path": "src/lib/providers/siliconflow/types.ts",
    "content": "export type SiliconFlowProviderKey = 'siliconflow'\n\nexport interface SiliconFlowGenerateRequestOptions {\n  provider: string\n  modelId: string\n  modelKey: string\n  [key: string]: unknown\n}\n\nexport interface SiliconFlowLlmMessage {\n  role: 'user' | 'assistant' | 'system'\n  content: string\n}\n\nexport interface SiliconFlowProbeStep {\n  name: 'models' | 'credits'\n  status: 'pass' | 'fail' | 'skip'\n  message: string\n  detail?: string\n}\n\nexport interface SiliconFlowProbeResult {\n  success: boolean\n  steps: SiliconFlowProbeStep[]\n}\n"
  },
  {
    "path": "src/lib/providers/siliconflow/video.ts",
    "content": "import {\n  assertOfficialModelRegistered,\n  type OfficialModelModality,\n} from '@/lib/providers/official/model-registry'\nimport { ensureSiliconFlowCatalogRegistered } from './catalog'\nimport type { SiliconFlowGenerateRequestOptions } from './types'\n\nexport interface SiliconFlowVideoGenerateParams {\n  userId: string\n  imageUrl: string\n  prompt?: string\n  options: SiliconFlowGenerateRequestOptions\n}\n\nfunction assertRegistered(modelId: string): void {\n  ensureSiliconFlowCatalogRegistered()\n  assertOfficialModelRegistered({\n    provider: 'siliconflow',\n    modality: 'video' satisfies OfficialModelModality,\n    modelId,\n  })\n}\n\nexport async function generateSiliconFlowVideo(params: SiliconFlowVideoGenerateParams): Promise<never> {\n  assertRegistered(params.options.modelId)\n  throw new Error('OFFICIAL_PROVIDER_NOT_IMPLEMENTED: siliconflow video')\n}\n"
  },
  {
    "path": "src/lib/query/client.ts",
    "content": "import { QueryClient } from '@tanstack/react-query'\n\n/**\n * 全局 QueryClient 配置\n * 用于统一管理所有数据请求的缓存和状态\n */\nexport const queryClient = new QueryClient({\n    defaultOptions: {\n        queries: {\n            // 数据在 5 秒内认为是新鲜的，不会重新请求\n            staleTime: 5000,\n            // 缓存数据保留 10 分钟\n            gcTime: 10 * 60 * 1000,\n            // 窗口聚焦时自动刷新\n            refetchOnWindowFocus: true,\n            // 网络恢复时自动刷新\n            refetchOnReconnect: true,\n            // 失败后重试 1 次\n            retry: 1,\n            // 重试延迟\n            retryDelay: 1000,\n        },\n        mutations: {\n            // mutation 不重试\n            retry: 0,\n        },\n    },\n})\n\n/**\n * 获取全局 QueryClient 实例\n * 用于在非 React 组件中访问缓存\n */\nexport function getQueryClient() {\n    return queryClient\n}\n"
  },
  {
    "path": "src/lib/query/hooks/index.ts",
    "content": "/**\n * React Query Hooks 统一导出\n * \n * 使用示例：\n * import { useProjectAssets, useGenerateProjectCharacterImage } from '@/lib/query/hooks'\n */\n\n// 中心资产库\nexport {\n    useGlobalCharacters,\n    useGlobalLocations,\n    useGlobalVoices,\n    useGlobalFolders,\n    useCreateFolder,\n    useUpdateFolder,\n    useDeleteFolder,\n    useRefreshGlobalAssets,\n    type GlobalCharacter,\n    type GlobalCharacterAppearance,\n    type GlobalLocation,\n    type GlobalLocationImage,\n    type GlobalVoice,\n    type GlobalFolder,\n} from './useGlobalAssets'\nexport {\n    useGenerateCharacterImage,\n    useModifyCharacterImage,\n    useSelectCharacterImage,\n    useUndoCharacterImage,\n    useUploadCharacterImage,\n    useDeleteCharacter,\n    useDeleteCharacterAppearance,\n    useUploadCharacterVoice,\n    useGenerateLocationImage,\n    useModifyLocationImage,\n    useSelectLocationImage,\n    useUndoLocationImage,\n    useUploadLocationImage,\n    useDeleteLocation,\n    useDeleteVoice,\n    useUpdateCharacterName,\n    useUpdateLocationName,\n    useUpdateCharacterAppearanceDescription,\n    useUpdateLocationSummary,\n    useAiModifyCharacterDescription,\n    useAiModifyLocationDescription,\n    useDesignAssetHubVoice,\n    useSaveDesignedAssetHubVoice,\n    useUploadAssetHubVoice,\n    useAiDesignLocation,\n    useCreateAssetHubLocation,\n    useUploadAssetHubTempMedia,\n    useAiDesignCharacter,\n    useExtractAssetHubReferenceCharacterDescription,\n    useCreateAssetHubCharacter,\n} from '../mutations/useAssetHubMutations'\n\n// 项目资产\nexport {\n    useProjectAssets,\n    useProjectCharacters,\n    useProjectLocations,\n    useRefreshProjectAssets,\n    type ProjectAssetsData,\n} from './useProjectAssets'\nexport {\n    useGenerateProjectCharacterImage,\n    useModifyProjectCharacterImage,\n    useRegenerateCharacterGroup,\n    useRegenerateSingleCharacterImage,\n    useSelectProjectCharacterImage,\n    useUndoProjectCharacterImage,\n    useUploadProjectCharacterImage,\n    useDeleteProjectCharacter,\n    useDeleteProjectAppearance,\n    useUpdateProjectCharacterName,\n    useUploadProjectCharacterVoice,\n    useGenerateProjectLocationImage,\n    useModifyProjectLocationImage,\n    useRegenerateLocationGroup,\n    useRegenerateSingleLocationImage,\n    useSelectProjectLocationImage,\n    useUndoProjectLocationImage,\n    useUploadProjectLocationImage,\n    useDeleteProjectLocation,\n    useUpdateProjectLocationName,\n    useUpdateProjectAppearanceDescription,\n    useUpdateProjectLocationDescription,\n    useUpdateProjectCharacterIntroduction,\n    useAiModifyProjectAppearanceDescription,\n    useAiModifyProjectLocationDescription,\n    useAiCreateProjectLocation,\n    useCreateProjectLocation,\n    useAiCreateProjectCharacter,\n    useUploadProjectTempMedia,\n    useExtractProjectReferenceCharacterDescription,\n    useCreateProjectCharacter,\n    useCreateProjectCharacterAppearance,\n    useAnalyzeProjectGlobalAssets,\n    useCopyProjectAssetFromGlobal,\n    useAiModifyProjectShotPrompt,\n    useUpdateProjectConfig,\n    useUpdateProjectEpisodeField,\n    useAnalyzeProjectAssets,\n    useGetProjectStoryboardStats,\n    useUpdateProjectPanelVideoPrompt,\n    useRegenerateProjectPanelImage,\n    useModifyProjectStoryboardImage,\n    useDownloadProjectImages,\n    useUpdateProjectPanel,\n    useCreateProjectPanel,\n    useDeleteProjectPanel,\n    useDeleteProjectStoryboardGroup,\n    useRegenerateProjectStoryboardText,\n    useCreateProjectStoryboardGroup,\n    useMoveProjectStoryboardGroup,\n    useInsertProjectPanel,\n    useConfirmProjectCharacterSelection,\n    useConfirmProjectLocationSelection,\n    useConfirmProjectCharacterProfile,\n    useBatchConfirmProjectCharacterProfiles,\n    useUpdateProjectCharacterVoiceSettings,\n    useSaveProjectDesignedVoice,\n    useUpdateProjectClip,\n    useFetchProjectVoiceStageData,\n    useAnalyzeProjectVoice,\n    useGenerateProjectVoice,\n    useCreateProjectVoiceLine,\n    useUpdateProjectVoiceLine,\n    useDeleteProjectVoiceLine,\n    useDownloadProjectVoices,\n    useBatchGenerateCharacterImages,\n    useBatchGenerateLocationImages,\n    useDesignProjectVoice,\n    useAnalyzeProjectShotVariants,\n    useUpdateProjectPhotographyPlan,\n    useUpdateProjectPanelActingNotes,\n    useListProjectEpisodeVideoUrls,\n    useUpdateProjectPanelLink,\n    useListProjectEpisodes,\n    useSplitProjectEpisodes,\n    useSplitProjectEpisodesByMarkers,\n    useSaveProjectEpisodesBatch,\n    useDownloadRemoteBlob,\n    useCreateProjectPanelVariant,\n    useClearProjectStoryboardError,\n    useUpdateSpeakerVoice,\n} from '../mutations/useProjectMutations'\n\nexport type {\n    Character,\n    CharacterAppearance,\n    Location,\n    LocationImage,\n} from '@/types/project'\n\n// 分镜\nexport {\n    useStoryboards,\n    useRegeneratePanelImage,\n    useModifyPanelImage,\n    useGenerateVideo,\n    useBatchGenerateVideos,\n    useSelectPanelCandidate,\n    useRefreshStoryboards,\n    type StoryboardPanel,\n    type StoryboardGroup,\n    type StoryboardData,\n    type PanelCandidate,\n} from './useStoryboards'\n\n// 语音\nexport {\n    useVoiceLines,\n    useMatchedVoiceLines,\n    useGenerateVoice,\n    useBatchGenerateVoices,\n    useUpdateVoiceText,\n    useRefreshVoiceLines,\n    type VoiceLine,\n    type MatchedVoiceLine,\n    type VoiceLinesData,\n    type MatchedVoiceLinesData,\n} from './useVoiceLines'\n\n// 实时任务\nexport {\n    useSSE,\n} from './useSSE'\nexport {\n    useStoryToScriptRunStream,\n} from './useStoryToScriptRunStream'\nexport {\n    useScriptToStoryboardRunStream,\n} from './useScriptToStoryboardRunStream'\n\nexport {\n    useAssetTaskPresentation,\n    useStoryboardTaskPresentation,\n    useVideoTaskPresentation,\n    useVoiceTaskPresentation,\n    type TaskPresentationTarget,\n} from './useTaskPresentation'\n\n// 项目数据\nexport {\n    useProjectData,\n    useRefreshProjectData,\n    useEpisodeData,\n    useEpisodes,\n    useRefreshEpisodeData,\n    useRefreshAll,\n    type Episode,\n} from './useProjectData'\n\nexport {\n    useUserModels,\n    type UserModelOption as QueryUserModelOption,\n    type UserModelsPayload as QueryUserModelsPayload,\n} from './useUserModels'\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/event-parser.ts",
    "content": "import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'\nimport { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types'\nimport { resolveTaskErrorMessage as resolveUnifiedTaskErrorMessage } from '@/lib/task/error-message'\nimport type { RunResult } from './types'\n\nexport function parseSSEBlock(block: string): { event: string; data: string } | null {\n  const lines = block.split('\\n')\n  let event = 'message'\n  const dataLines: string[] = []\n\n  for (const line of lines) {\n    if (!line) continue\n    if (line.startsWith('event:')) {\n      event = line.slice(6).trim()\n      continue\n    }\n    if (line.startsWith('data:')) {\n      dataLines.push(line.slice(5).trim())\n    }\n  }\n\n  if (dataLines.length === 0) return null\n  return {\n    event,\n    data: dataLines.join('\\n'),\n  }\n}\n\nexport function toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nexport function readTextField(payload: Record<string, unknown>, key: string): string | undefined {\n  const value = payload[key]\n  return typeof value === 'string' ? value : undefined\n}\n\nexport function readStepField(payload: Record<string, unknown>, key: string): number | undefined {\n  const value = payload[key]\n  return typeof value === 'number' && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : undefined\n}\n\nfunction readStringArrayField(payload: Record<string, unknown>, key: string): string[] {\n  const value = payload[key]\n  if (!Array.isArray(value)) return []\n  const rows: string[] = []\n  for (const item of value) {\n    if (typeof item !== 'string') continue\n    const trimmed = item.trim()\n    if (!trimmed) continue\n    rows.push(trimmed)\n  }\n  return rows\n}\n\nfunction readBoolField(payload: Record<string, unknown>, key: string): boolean | undefined {\n  const value = payload[key]\n  if (value === true) return true\n  if (value === false) return false\n  return undefined\n}\n\nfunction normalizeLifecycleType(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  if (value === TASK_EVENT_TYPE.PROGRESS) return TASK_EVENT_TYPE.PROCESSING\n  if (\n    value === TASK_EVENT_TYPE.CREATED ||\n    value === TASK_EVENT_TYPE.PROCESSING ||\n    value === TASK_EVENT_TYPE.COMPLETED ||\n    value === TASK_EVENT_TYPE.FAILED\n  ) {\n    return value\n  }\n  return null\n}\n\nfunction stageLooksCompleted(stage: string | undefined) {\n  if (!stage) return false\n  return (\n    stage === 'llm_completed' ||\n    stage === 'worker_llm_completed' ||\n    stage === 'worker_llm_complete' ||\n    stage === 'llm_proxy_persist' ||\n    stage === 'completed'\n  )\n}\n\nfunction stageLooksFailed(stage: string | undefined) {\n  if (!stage) return false\n  return stage === 'llm_error' || stage === 'worker_llm_error' || stage === 'error'\n}\n\nfunction resolveTaskErrorMessage(payload: Record<string, unknown>, fallback = 'task failed') {\n  return resolveUnifiedTaskErrorMessage(payload, fallback)\n}\n\nfunction extractTerminalPayload(payload: Record<string, unknown>) {\n  const result = toObject(payload.result)\n  if (Object.keys(result).length > 0) {\n    return result\n  }\n  return payload\n}\n\nexport function mapTaskSSEEventToRunEvents(event: SSEEvent): RunStreamEvent[] {\n  const rawPayload = toObject(event.payload)\n  const payloadMeta = toObject(rawPayload.meta)\n  const runId = readTextField(rawPayload, 'runId')\n    || readTextField(payloadMeta, 'runId')\n    || (typeof event.taskId === 'string' ? event.taskId : '')\n  if (!runId) return []\n  const payload = rawPayload\n  const lifecycleType =\n    event.type === TASK_SSE_EVENT_TYPE.LIFECYCLE\n      ? normalizeLifecycleType(payload.lifecycleType)\n      : null\n  const ts = typeof event.ts === 'string' ? event.ts : new Date().toISOString()\n  const flowStageTitle = readTextField(payload, 'flowStageTitle')\n  const flowStageIndex = readStepField(payload, 'flowStageIndex')\n  const flowStageTotal = readStepField(payload, 'flowStageTotal')\n  const rawStepId = readTextField(payload, 'stepId')\n  const rawStepTitle = readTextField(payload, 'stepTitle')\n  const stepId =\n    rawStepId ||\n    (\n      event.type === TASK_SSE_EVENT_TYPE.STREAM\n      ? `step:${event.taskType || 'llm'}`\n      : undefined)\n  const stepAttempt = readStepField(payload, 'stepAttempt')\n  const stepTitle = rawStepTitle || flowStageTitle || undefined\n  const stepIndex = readStepField(payload, 'stepIndex') ?? flowStageIndex\n  const stepTotal = readStepField(payload, 'stepTotal') ?? flowStageTotal\n  const stage = readTextField(payload, 'stage')\n  const message = readTextField(payload, 'message')\n  const done = payload.done === true\n  const text = readTextField(payload, 'output') || readTextField(payload, 'text')\n  const reasoning = readTextField(payload, 'reasoning') || readTextField(payload, 'thinking')\n  const dependsOn = readStringArrayField(payload, 'dependsOn')\n  const blockedBy = readStringArrayField(payload, 'blockedBy')\n  const groupId = readTextField(payload, 'groupId')\n  const parallelKey = readTextField(payload, 'parallelKey')\n  const retryable = readBoolField(payload, 'retryable')\n  const stale = readBoolField(payload, 'stale')\n\n  if (event.type === TASK_SSE_EVENT_TYPE.STREAM) {\n    const stream = toObject(payload.stream)\n    const kind = stream.kind === 'reasoning' ? 'reasoning' : 'text'\n    const delta = typeof stream.delta === 'string' ? stream.delta : ''\n    if (!delta || !stepId) return []\n    const lane = stream.lane === 'reasoning' || kind === 'reasoning' ? 'reasoning' : 'text'\n    const seq =\n      typeof stream.seq === 'number' && Number.isFinite(stream.seq) ? Math.max(1, Math.floor(stream.seq)) : undefined\n    const streamStepAttempt =\n      typeof stream.attempt === 'number' && Number.isFinite(stream.attempt)\n        ? Math.max(1, Math.floor(stream.attempt))\n        : undefined\n\n    return [{\n      runId,\n      event: 'step.chunk',\n      ts,\n      status: 'running',\n      stepId,\n      stepAttempt: stepAttempt ?? streamStepAttempt,\n      stepTitle,\n      stepIndex,\n      stepTotal,\n      dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n      blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n      groupId,\n      parallelKey,\n      retryable,\n      lane,\n      seq,\n      textDelta: lane === 'text' ? delta : undefined,\n      reasoningDelta: lane === 'reasoning' ? delta : undefined,\n      message,\n    }]\n  }\n\n  if (event.type !== TASK_SSE_EVENT_TYPE.LIFECYCLE) return []\n\n  const runEvents: RunStreamEvent[] = []\n\n  if (lifecycleType === TASK_EVENT_TYPE.CREATED) {\n    runEvents.push({\n      runId,\n      event: 'run.start',\n      ts,\n      status: 'running',\n      message,\n      payload,\n    })\n    return runEvents\n  }\n\n  if (lifecycleType === TASK_EVENT_TYPE.PROCESSING) {\n    if (stepId) {\n      runEvents.push({\n        runId,\n        event: 'step.start',\n        ts,\n        status: 'running',\n        stepId,\n        stepAttempt,\n        stepTitle,\n        stepIndex,\n        stepTotal,\n        dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n        blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n        groupId,\n        parallelKey,\n        retryable,\n        message,\n      })\n      if (done || stageLooksCompleted(stage)) {\n        runEvents.push({\n          runId,\n          event: 'step.complete',\n          ts,\n          status: stale ? 'stale' : 'completed',\n          stepId,\n          stepAttempt,\n          stepTitle,\n          stepIndex,\n          stepTotal,\n          dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n          blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n          groupId,\n          parallelKey,\n          retryable,\n          text,\n          reasoning,\n          message,\n        })\n      } else if (stageLooksFailed(stage)) {\n        runEvents.push({\n          runId,\n          event: 'step.error',\n          ts,\n          status: 'failed',\n          stepId,\n          stepAttempt,\n          stepTitle,\n          stepIndex,\n          stepTotal,\n          dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n          blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n          groupId,\n          parallelKey,\n          retryable,\n          message: resolveTaskErrorMessage(payload),\n        })\n      }\n    }\n    return runEvents\n  }\n\n  if (lifecycleType === TASK_EVENT_TYPE.COMPLETED) {\n    if (stepId) {\n      runEvents.push({\n        runId,\n        event: 'step.complete',\n        ts,\n        status: 'completed',\n        stepId,\n        stepAttempt,\n        stepTitle,\n        stepIndex,\n        stepTotal,\n        dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n        blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n        groupId,\n        parallelKey,\n        retryable,\n        text,\n        reasoning,\n        message,\n      })\n    }\n    runEvents.push({\n      runId,\n      event: 'run.complete',\n      ts,\n      status: 'completed',\n      message,\n      payload: extractTerminalPayload(payload),\n    })\n    return runEvents\n  }\n\n  if (lifecycleType === TASK_EVENT_TYPE.FAILED) {\n    const errorMessage = resolveTaskErrorMessage(payload)\n    if (stepId) {\n      runEvents.push({\n        runId,\n        event: 'step.error',\n        ts,\n        status: 'failed',\n        stepId,\n        stepAttempt,\n        stepTitle,\n        stepIndex,\n        stepTotal,\n        dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n        blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n        groupId,\n        parallelKey,\n        retryable,\n        message: errorMessage,\n      })\n    }\n    runEvents.push({\n      runId,\n      event: 'run.error',\n      ts,\n      status: 'failed',\n      message: errorMessage,\n      payload,\n    })\n  }\n\n  return runEvents\n}\n\nexport function toTerminalRunResult(event: RunStreamEvent): RunResult | null {\n  if (event.event !== 'run.complete' && event.event !== 'run.error') return null\n\n  const summaryFromPayload =\n    event.payload &&\n    typeof event.payload.summary === 'object' &&\n    event.payload.summary\n      ? (event.payload.summary as Record<string, unknown>)\n      : null\n\n  return {\n    runId: event.runId,\n    status: event.event === 'run.complete' ? 'completed' : 'failed',\n    summary:\n      event.event === 'run.complete'\n        ? summaryFromPayload || event.payload || null\n        : summaryFromPayload,\n    payload: event.payload || null,\n    errorMessage: event.event === 'run.error' ? event.message || 'run failed' : '',\n  }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/recovered-run-subscription.ts",
    "content": "import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'\nimport { toTerminalRunResult } from './event-parser'\nimport { fetchRunEventsPage, toRunStreamEventFromRunApi } from './run-event-adapter'\nimport { apiFetch } from '@/lib/api-fetch'\n\nconst POLL_INTERVAL_MS = 1500\nconst RUN_TERMINAL_RECONCILE_EMPTY_POLLS = 2\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nasync function reconcileRunTerminalState(runId: string): Promise<{\n  status: 'completed' | 'failed'\n  message?: string\n  payload?: Record<string, unknown>\n} | null> {\n  const response = await apiFetch(`/api/runs/${runId}`, {\n    method: 'GET',\n    cache: 'no-store',\n  })\n  if (!response.ok) return null\n\n  const snapshot = await response.json().catch(() => null)\n  const root = toObject(snapshot)\n  const run = toObject(root.run)\n  const status = readText(run.status)\n  if (status === 'completed') {\n    const output = toObject(run.output)\n    return {\n      status: 'completed',\n      payload: Object.keys(output).length > 0 ? output : run,\n    }\n  }\n  if (status === 'failed' || status === 'canceled') {\n    return {\n      status: 'failed',\n      message: readText(run.errorMessage) || `run ${status}`,\n      payload: run,\n    }\n  }\n  return null\n}\n\ntype SubscribeRecoveredRunArgs = {\n  runId: string\n  taskStreamTimeoutMs: number\n  applyAndCapture: (event: RunStreamEvent) => void\n  onSettled: () => void\n}\n\ntype Cleanup = () => void\n\nexport function subscribeRecoveredRun(args: SubscribeRecoveredRunArgs): Cleanup {\n  let settled = false\n  let polling = false\n  let afterSeq = 0\n  let emptyPollCount = 0\n  let idleTimeoutTimer: ReturnType<typeof setTimeout> | null = null\n  let pollTimer: ReturnType<typeof setInterval> | null = null\n\n  function cleanup() {\n    if (idleTimeoutTimer) {\n      clearTimeout(idleTimeoutTimer)\n      idleTimeoutTimer = null\n    }\n    if (pollTimer) {\n      clearInterval(pollTimer)\n      pollTimer = null\n    }\n  }\n\n  function settle() {\n    if (settled) return\n    settled = true\n    cleanup()\n    args.onSettled()\n  }\n\n  function scheduleIdleTimeout() {\n    if (idleTimeoutTimer) {\n      clearTimeout(idleTimeoutTimer)\n    }\n    idleTimeoutTimer = setTimeout(() => {\n      if (settled) return\n      const timeoutMessage = `run stream timeout: ${args.runId}`\n      args.applyAndCapture({\n        runId: args.runId,\n        event: 'run.error',\n        ts: new Date().toISOString(),\n        status: 'failed',\n        message: timeoutMessage,\n      })\n      settle()\n    }, args.taskStreamTimeoutMs)\n  }\n\n  async function pollRunEvents() {\n    if (settled || polling) return\n    polling = true\n    try {\n      const rows = await fetchRunEventsPage({\n        runId: args.runId,\n        afterSeq,\n      })\n\n      let sawNewEvent = false\n      for (const row of rows) {\n        if (row.seq <= afterSeq) continue\n\n        sawNewEvent = true\n        if (row.seq > afterSeq + 1) {\n          const gapRows = await fetchRunEventsPage({\n            runId: args.runId,\n            afterSeq,\n          })\n          for (const gapRow of gapRows) {\n            if (gapRow.seq <= afterSeq) continue\n            scheduleIdleTimeout()\n            afterSeq = gapRow.seq\n            const gapEvent = toRunStreamEventFromRunApi({\n              runId: args.runId,\n              event: gapRow,\n            })\n            if (!gapEvent) continue\n            args.applyAndCapture(gapEvent)\n            if (toTerminalRunResult(gapEvent)) {\n              settle()\n              return\n            }\n          }\n          continue\n        }\n\n        scheduleIdleTimeout()\n        afterSeq = row.seq\n        const streamEvent = toRunStreamEventFromRunApi({\n          runId: args.runId,\n          event: row,\n        })\n        if (!streamEvent) continue\n\n        args.applyAndCapture(streamEvent)\n        if (toTerminalRunResult(streamEvent)) {\n          settle()\n          return\n        }\n      }\n\n      if (sawNewEvent) {\n        emptyPollCount = 0\n      } else {\n        emptyPollCount += 1\n        if (emptyPollCount >= RUN_TERMINAL_RECONCILE_EMPTY_POLLS) {\n          const reconciled = await reconcileRunTerminalState(args.runId)\n          if (reconciled) {\n            if (reconciled.status === 'completed') {\n              args.applyAndCapture({\n                runId: args.runId,\n                event: 'run.complete',\n                ts: new Date().toISOString(),\n                status: 'completed',\n                payload: reconciled.payload,\n              })\n            } else {\n              args.applyAndCapture({\n                runId: args.runId,\n                event: 'run.error',\n                ts: new Date().toISOString(),\n                status: 'failed',\n                message: reconciled.message || 'run failed',\n                payload: reconciled.payload,\n              })\n            }\n            settle()\n            return\n          }\n          emptyPollCount = 0\n        }\n      }\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error)\n      args.applyAndCapture({\n        runId: args.runId,\n        event: 'run.error',\n        ts: new Date().toISOString(),\n        status: 'failed',\n        message,\n      })\n      settle()\n      return\n    } finally {\n      polling = false\n    }\n  }\n\n  scheduleIdleTimeout()\n\n  pollTimer = setInterval(() => {\n    void pollRunEvents()\n  }, POLL_INTERVAL_MS)\n\n  void pollRunEvents()\n\n  return cleanup\n}\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/run-event-adapter.ts",
    "content": "import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'\nimport { apiFetch } from '@/lib/api-fetch'\n\ntype JsonRecord = Record<string, unknown>\n\nexport type RunApiEvent = {\n  seq: number\n  eventType: string\n  stepKey?: string | null\n  attempt?: number | null\n  lane?: string | null\n  payload?: JsonRecord | null\n  createdAt?: string\n}\n\nfunction toObject(value: unknown): JsonRecord {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as JsonRecord\n}\n\nfunction readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nfunction readStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  const rows: string[] = []\n  for (const item of value) {\n    if (typeof item !== 'string') continue\n    const trimmed = item.trim()\n    if (!trimmed) continue\n    rows.push(trimmed)\n  }\n  return rows\n}\n\nfunction readBool(value: unknown): boolean | undefined {\n  if (value === true) return true\n  if (value === false) return false\n  return undefined\n}\n\nfunction resolveErrorMessage(payload: JsonRecord, fallback: string): string {\n  const direct = readText(payload.message)\n  if (direct) return direct\n  const nested = readText(toObject(payload.error).message)\n  return nested || fallback\n}\n\nexport function parseRunApiEventsPayload(payload: unknown): RunApiEvent[] {\n  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []\n  const root = payload as JsonRecord\n  if (!Array.isArray(root.events)) return []\n\n  const rows: RunApiEvent[] = []\n  for (const item of root.events) {\n    const row = toObject(item)\n    const seq = typeof row.seq === 'number' && Number.isFinite(row.seq)\n      ? Math.max(1, Math.floor(row.seq))\n      : 0\n    if (seq <= 0) continue\n\n    rows.push({\n      seq,\n      eventType: readText(row.eventType),\n      stepKey: readText(row.stepKey) || null,\n      attempt:\n        typeof row.attempt === 'number' && Number.isFinite(row.attempt)\n          ? Math.max(1, Math.floor(row.attempt))\n          : null,\n      lane: readText(row.lane) || null,\n      payload: toObject(row.payload),\n      createdAt: readText(row.createdAt) || undefined,\n    })\n  }\n\n  return rows\n}\n\nexport function toRunStreamEventFromRunApi(params: {\n  runId: string\n  event: RunApiEvent\n}): RunStreamEvent | null {\n  const payload = toObject(params.event.payload)\n  const stepId = typeof params.event.stepKey === 'string' ? params.event.stepKey : undefined\n  const stepAttempt =\n    typeof params.event.attempt === 'number' && Number.isFinite(params.event.attempt)\n      ? Math.max(1, Math.floor(params.event.attempt))\n      : undefined\n  const stepTitle = readText(payload.stepTitle) || undefined\n  const stepIndex =\n    typeof payload.stepIndex === 'number' && Number.isFinite(payload.stepIndex)\n      ? Math.max(1, Math.floor(payload.stepIndex))\n      : undefined\n  const stepTotal =\n    typeof payload.stepTotal === 'number' && Number.isFinite(payload.stepTotal)\n      ? Math.max(stepIndex || 1, Math.floor(payload.stepTotal))\n      : undefined\n  const ts = readText(params.event.createdAt) || new Date().toISOString()\n  const message = readText(payload.message) || undefined\n  const dependsOn = readStringArray(payload.dependsOn)\n  const blockedBy = readStringArray(payload.blockedBy)\n  const groupId = readText(payload.groupId) || undefined\n  const parallelKey = readText(payload.parallelKey) || undefined\n  const retryable = readBool(payload.retryable)\n  const stale = readBool(payload.stale)\n\n  if (params.event.eventType === 'run.start') {\n    return {\n      runId: params.runId,\n      event: 'run.start',\n      ts,\n      status: 'running',\n      message,\n      payload,\n    }\n  }\n\n  if (params.event.eventType === 'run.complete') {\n    return {\n      runId: params.runId,\n      event: 'run.complete',\n      ts,\n      status: 'completed',\n      message,\n      payload,\n    }\n  }\n\n  if (params.event.eventType === 'run.error') {\n    return {\n      runId: params.runId,\n      event: 'run.error',\n      ts,\n      status: 'failed',\n      message: resolveErrorMessage(payload, 'run failed'),\n      payload,\n    }\n  }\n\n  if (params.event.eventType === 'run.canceled') {\n    return {\n      runId: params.runId,\n      event: 'run.error',\n      ts,\n      status: 'failed',\n      message: resolveErrorMessage(payload, 'run canceled'),\n      payload,\n    }\n  }\n\n  if (params.event.eventType === 'step.start') {\n    if (!stepId) return null\n    return {\n      runId: params.runId,\n      event: 'step.start',\n      ts,\n      status: 'running',\n      stepId,\n      stepAttempt,\n      stepTitle,\n      stepIndex,\n      stepTotal,\n      dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n      blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n      groupId,\n      parallelKey,\n      retryable,\n      message,\n    }\n  }\n\n  if (params.event.eventType === 'step.chunk') {\n    if (!stepId) return null\n    const stream = toObject(payload.stream)\n    const lane =\n      params.event.lane === 'reasoning' || stream.lane === 'reasoning' || stream.kind === 'reasoning'\n        ? 'reasoning'\n        : 'text'\n    const delta = readText(stream.delta)\n    if (!delta) return null\n    const laneSeq =\n      typeof stream.seq === 'number' && Number.isFinite(stream.seq)\n        ? Math.max(1, Math.floor(stream.seq))\n        : Math.max(1, Math.floor(params.event.seq))\n\n    return {\n      runId: params.runId,\n      event: 'step.chunk',\n      ts,\n      status: 'running',\n      stepId,\n      stepAttempt,\n      stepTitle,\n      stepIndex,\n      stepTotal,\n      dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n      blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n      groupId,\n      parallelKey,\n      retryable,\n      lane,\n      seq: laneSeq,\n      textDelta: lane === 'text' ? delta : undefined,\n      reasoningDelta: lane === 'reasoning' ? delta : undefined,\n      message,\n    }\n  }\n\n  if (params.event.eventType === 'step.complete') {\n    if (!stepId) return null\n    const text = readText(payload.text) || readText(payload.output) || undefined\n    const reasoning = readText(payload.reasoning) || undefined\n    return {\n      runId: params.runId,\n      event: 'step.complete',\n      ts,\n      stepId,\n      stepAttempt,\n      stepTitle,\n      stepIndex,\n      stepTotal,\n      status: stale ? 'stale' : 'completed',\n      dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n      blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n      groupId,\n      parallelKey,\n      retryable,\n      text,\n      reasoning,\n      message,\n    }\n  }\n\n  if (params.event.eventType === 'step.error') {\n    if (!stepId) return null\n    return {\n      runId: params.runId,\n      event: 'step.error',\n      ts,\n      status: 'failed',\n      stepId,\n      stepAttempt,\n      stepTitle,\n      stepIndex,\n      stepTotal,\n      dependsOn: dependsOn.length > 0 ? dependsOn : undefined,\n      blockedBy: blockedBy.length > 0 ? blockedBy : undefined,\n      groupId,\n      parallelKey,\n      retryable,\n      message: resolveErrorMessage(payload, 'step failed'),\n      payload,\n    }\n  }\n\n  return null\n}\n\nexport async function fetchRunEventsPage(params: {\n  runId: string\n  afterSeq: number\n  limit?: number\n}): Promise<RunApiEvent[]> {\n  const safeAfterSeq = Number.isFinite(params.afterSeq)\n    ? Math.max(0, Math.floor(params.afterSeq))\n    : 0\n  const safeLimit = Number.isFinite(params.limit || 500)\n    ? Math.min(Math.max(Math.floor(params.limit || 500), 1), 2000)\n    : 500\n\n  const response = await apiFetch(\n    `/api/runs/${params.runId}/events?afterSeq=${safeAfterSeq}&limit=${safeLimit}`,\n    {\n      method: 'GET',\n      cache: 'no-store',\n    },\n  )\n  if (!response.ok) {\n    const errorJson = await response.clone().json().catch(() => null)\n    const errorRoot = toObject(errorJson)\n    const errorMessage =\n      readText(toObject(errorRoot.error).message) ||\n      readText(errorRoot.message) ||\n      (await response.text().catch(() => ''))\n\n    if (errorMessage) {\n      throw new Error(`run events fetch failed (HTTP ${response.status}): ${errorMessage}`)\n    }\n    throw new Error(`run events fetch failed (HTTP ${response.status})`)\n  }\n\n  const payload = await response.json().catch(() => null)\n  return parseRunApiEventsPayload(payload)\n}\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/run-request-executor.ts",
    "content": "import type { MutableRefObject } from 'react'\nimport type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'\nimport { isAsyncTaskResponse } from '@/lib/task/client'\nimport { resolveTaskErrorMessage } from '@/lib/task/error-message'\nimport { toObject, toTerminalRunResult } from './event-parser'\nimport { streamSSEBody } from './run-stream-sse-body'\nimport { fetchRunEventsPage, toRunStreamEventFromRunApi } from './run-event-adapter'\nimport type { RunResult } from './types'\nimport { apiFetch } from '@/lib/api-fetch'\n\ntype RunRequestExecutorArgs = {\n  endpointUrl: string\n  requestBody: Record<string, unknown>\n  controller: AbortController\n  taskStreamTimeoutMs: number\n  applyAndCapture: (streamEvent: RunStreamEvent) => void\n  finalResultRef: MutableRefObject<RunResult | null>\n}\n\nconst POLL_INTERVAL_MS = 1500\nconst RUN_EVENTS_LIMIT = 500\nconst RUN_TERMINAL_RECONCILE_EMPTY_POLLS = 2\n\nfunction readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nasync function reconcileRunTerminalState(runId: string): Promise<RunResult | null> {\n  const response = await apiFetch(`/api/runs/${runId}`, {\n    method: 'GET',\n    cache: 'no-store',\n  })\n  if (!response.ok) return null\n\n  const snapshot = await response.json().catch(() => null)\n  const root = toObject(snapshot)\n  const run = toObject(root.run)\n  const status = readText(run.status)\n  if (status === 'completed') {\n    const output = toObject(run.output)\n    return {\n      runId,\n      status: 'completed',\n      summary: Object.keys(output).length > 0 ? output : null,\n      payload: Object.keys(output).length > 0 ? output : run,\n      errorMessage: '',\n    }\n  }\n  if (status === 'failed' || status === 'canceled') {\n    const errorMessage = readText(run.errorMessage) || `run ${status}`\n    return buildFailedResult(runId, errorMessage)\n  }\n  return null\n}\n\nfunction buildFailedResult(runId: string, errorMessage: string): RunResult {\n  return {\n    runId,\n    status: 'failed',\n    summary: null,\n    payload: null,\n    errorMessage,\n  }\n}\n\nasync function waitRunEventsTerminal(args: {\n  runId: string\n  controller: AbortController\n  taskStreamTimeoutMs: number\n  applyAndCapture: (streamEvent: RunStreamEvent) => void\n}): Promise<RunResult> {\n  let lastEventAt = Date.now()\n  let afterSeq = 0\n  let emptyPollCount = 0\n\n  while (true) {\n    if (args.controller.signal.aborted) {\n      return buildFailedResult(args.runId, 'aborted')\n    }\n    if (Date.now() - lastEventAt > args.taskStreamTimeoutMs) {\n      const timeoutMessage = `run stream timeout: ${args.runId}`\n      args.applyAndCapture({\n        runId: args.runId,\n        event: 'run.error',\n        ts: new Date().toISOString(),\n        status: 'failed',\n        message: timeoutMessage,\n      })\n      return buildFailedResult(args.runId, timeoutMessage)\n    }\n\n    const rows = await fetchRunEventsPage({\n      runId: args.runId,\n      afterSeq,\n      limit: RUN_EVENTS_LIMIT,\n    })\n\n    let sawNewEvent = false\n    for (const row of rows) {\n      if (row.seq <= afterSeq) continue\n\n      sawNewEvent = true\n      if (row.seq > afterSeq + 1) {\n        const gapRows = await fetchRunEventsPage({\n          runId: args.runId,\n          afterSeq,\n          limit: RUN_EVENTS_LIMIT,\n        })\n        if (gapRows.length > 0) {\n          for (const gapRow of gapRows) {\n            if (gapRow.seq <= afterSeq) continue\n            lastEventAt = Date.now()\n            afterSeq = gapRow.seq\n            const gapEvent = toRunStreamEventFromRunApi({\n              runId: args.runId,\n              event: gapRow,\n            })\n            if (!gapEvent) continue\n            args.applyAndCapture(gapEvent)\n            const gapTerminal = toTerminalRunResult(gapEvent)\n            if (gapTerminal) {\n              return { ...gapTerminal, runId: args.runId }\n            }\n          }\n        }\n        continue\n      }\n\n      lastEventAt = Date.now()\n      afterSeq = row.seq\n      const streamEvent = toRunStreamEventFromRunApi({\n        runId: args.runId,\n        event: row,\n      })\n      if (!streamEvent) continue\n      args.applyAndCapture(streamEvent)\n      const terminalResult = toTerminalRunResult(streamEvent)\n      if (terminalResult) {\n        return {\n          ...terminalResult,\n          runId: args.runId,\n        }\n      }\n    }\n\n    if (sawNewEvent) {\n      emptyPollCount = 0\n    } else {\n      emptyPollCount += 1\n      if (emptyPollCount >= RUN_TERMINAL_RECONCILE_EMPTY_POLLS) {\n        const reconciled = await reconcileRunTerminalState(args.runId)\n        if (reconciled) {\n          if (reconciled.status === 'completed') {\n            args.applyAndCapture({\n              runId: args.runId,\n              event: 'run.complete',\n              ts: new Date().toISOString(),\n              status: 'completed',\n              payload: reconciled.payload || reconciled.summary || undefined,\n            })\n          } else {\n            args.applyAndCapture({\n              runId: args.runId,\n              event: 'run.error',\n              ts: new Date().toISOString(),\n              status: 'failed',\n              message: reconciled.errorMessage,\n            })\n          }\n          return reconciled\n        }\n        emptyPollCount = 0\n      }\n    }\n\n    await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))\n  }\n}\n\nexport async function executeRunRequest(args: RunRequestExecutorArgs): Promise<RunResult> {\n  try {\n    const response = await apiFetch(args.endpointUrl, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(args.requestBody),\n      signal: args.controller.signal,\n    })\n\n    if (!response.ok) {\n      const jsonPayload = await response.clone().json().catch(() => null)\n      if (jsonPayload && typeof jsonPayload === 'object') {\n        throw new Error(resolveTaskErrorMessage(jsonPayload as Record<string, unknown>, `HTTP ${response.status}`))\n      }\n      const message = await response.text().catch(() => '')\n      throw new Error(message || `HTTP ${response.status}`)\n    }\n\n    const contentType = response.headers.get('content-type') || ''\n    if (contentType.includes('text/event-stream') && response.body) {\n      await streamSSEBody({\n        responseBody: response.body,\n        applyAndCapture: args.applyAndCapture,\n      })\n    } else {\n      const data = await response.json().catch(() => null)\n      if (isAsyncTaskResponse(data)) {\n        const asyncPayload = toObject(data)\n        const runId =\n          typeof asyncPayload.runId === 'string' && asyncPayload.runId.trim()\n            ? asyncPayload.runId.trim()\n            : ''\n        if (!runId) {\n          throw new Error('async task response missing runId')\n        }\n\n        const result = await waitRunEventsTerminal({\n          runId,\n          controller: args.controller,\n          taskStreamTimeoutMs: args.taskStreamTimeoutMs,\n          applyAndCapture: args.applyAndCapture,\n        })\n\n        args.finalResultRef.current = result\n        return result\n      }\n\n      const payload = toObject(data)\n      const success = payload.success !== false\n      const runId = typeof payload.runId === 'string' ? payload.runId : ''\n      const result: RunResult = {\n        runId,\n        status: success ? 'completed' : 'failed',\n        summary: payload,\n        payload,\n        errorMessage: success ? '' : (typeof payload.message === 'string' ? payload.message : 'run failed'),\n      }\n      args.finalResultRef.current = result\n      return result\n    }\n  } catch (error) {\n    if ((error as Error).name === 'AbortError') {\n      const aborted = args.finalResultRef.current || buildFailedResult('', 'aborted')\n      args.finalResultRef.current = aborted\n      return aborted\n    }\n\n    const message = error instanceof Error ? error.message : String(error)\n    args.finalResultRef.current = buildFailedResult('', message)\n    throw error\n  }\n\n  const fallback = args.finalResultRef.current || buildFailedResult('', 'stream closed without terminal event')\n  args.finalResultRef.current = fallback\n  return fallback\n}\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/run-stream-sse-body.ts",
    "content": "import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'\nimport { parseSSEBlock } from './event-parser'\n\nfunction toStreamEvent(data: Record<string, unknown>, parsedEvent: string): RunStreamEvent {\n  const dependsOn = Array.isArray(data.dependsOn)\n    ? data.dependsOn.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)\n    : undefined\n  const blockedBy = Array.isArray(data.blockedBy)\n    ? data.blockedBy.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)\n    : undefined\n  return {\n    runId: typeof data.runId === 'string' ? data.runId : '',\n    event: parsedEvent as RunStreamEvent['event'],\n    ts: typeof data.ts === 'string' ? data.ts : new Date().toISOString(),\n    status: (data.status as RunStreamEvent['status']) || undefined,\n    stepId: typeof data.stepId === 'string' ? data.stepId : undefined,\n    stepAttempt: typeof data.stepAttempt === 'number' ? Math.max(1, Math.floor(data.stepAttempt)) : undefined,\n    stepTitle: typeof data.stepTitle === 'string' ? data.stepTitle : undefined,\n    stepIndex: typeof data.stepIndex === 'number' ? data.stepIndex : undefined,\n    stepTotal: typeof data.stepTotal === 'number' ? data.stepTotal : undefined,\n    lane: data.lane === 'reasoning' ? 'reasoning' : data.lane === 'text' ? 'text' : undefined,\n    seq: typeof data.seq === 'number' ? data.seq : undefined,\n    textDelta: typeof data.textDelta === 'string' ? data.textDelta : undefined,\n    reasoningDelta: typeof data.reasoningDelta === 'string' ? data.reasoningDelta : undefined,\n    text: typeof data.text === 'string' ? data.text : undefined,\n    reasoning: typeof data.reasoning === 'string' ? data.reasoning : undefined,\n    message: typeof data.message === 'string' ? data.message : undefined,\n    dependsOn,\n    blockedBy,\n    groupId: typeof data.groupId === 'string' ? data.groupId : undefined,\n    parallelKey: typeof data.parallelKey === 'string' ? data.parallelKey : undefined,\n    retryable: typeof data.retryable === 'boolean' ? data.retryable : undefined,\n    payload: (() => {\n      const payload =\n        typeof data.payload === 'object' && data.payload ? (data.payload as Record<string, unknown>) : null\n      const summary =\n        typeof data.summary === 'object' && data.summary ? (data.summary as Record<string, unknown>) : null\n      if (!summary) return payload\n      return {\n        ...(payload || {}),\n        summary,\n      }\n    })(),\n  }\n}\n\nexport async function streamSSEBody(args: {\n  responseBody: ReadableStream<Uint8Array>\n  applyAndCapture: (streamEvent: RunStreamEvent) => void\n}) {\n  const reader = args.responseBody.getReader()\n  const decoder = new TextDecoder()\n  let buffer = ''\n\n  while (true) {\n    const { value, done } = await reader.read()\n    if (done) break\n\n    buffer += decoder.decode(value, { stream: true })\n    while (true) {\n      const idx = buffer.indexOf('\\n\\n')\n      if (idx === -1) break\n      const block = buffer.slice(0, idx)\n      buffer = buffer.slice(idx + 2)\n\n      const parsed = parseSSEBlock(block)\n      if (!parsed) continue\n\n      let data: Record<string, unknown> = {}\n      try {\n        data = JSON.parse(parsed.data) as Record<string, unknown>\n      } catch {\n        continue\n      }\n\n      args.applyAndCapture(toStreamEvent(data, parsed.event))\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/run-stream-state-runtime.ts",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'\nimport { applyRunStreamEvent } from './state-machine'\nimport { clearRunSnapshot, loadRunSnapshot, saveRunSnapshot } from './snapshot'\nimport { subscribeRecoveredRun } from './recovered-run-subscription'\nimport { executeRunRequest } from './run-request-executor'\nimport { deriveRunStreamView } from './run-stream-view'\nimport type { RunResult, RunState, RunStreamView, UseRunStreamStateOptions } from './types'\nimport { apiFetch } from '@/lib/api-fetch'\n\nexport type {\n  RunResult,\n  RunState,\n  RunStepState,\n  UseRunStreamStateOptions,\n} from './types'\n\nconst TASK_STREAM_TIMEOUT_MS = 1000 * 60 * 30\nconst PROBE_COOLDOWN_MS = 60_000\nconst probedScopes = new Map<string, number>()\n\nexport function useRunStreamState<TParams extends Record<string, unknown>>(\n  options: UseRunStreamStateOptions<TParams>,\n): RunStreamView {\n  const {\n    projectId,\n    endpoint,\n    storageKeyPrefix,\n    storageScopeKey,\n    buildRequestBody,\n    validateParams,\n    resolveActiveRunId,\n  } = options\n  const [runState, setRunState] = useState<RunState | null>(null)\n  const runStateRef = useRef<RunState | null>(null)\n  const [clock, setClock] = useState(() => Date.now())\n  const [isLiveRunning, setIsLiveRunning] = useState(false)\n  const [isRecoveredRunning, setIsRecoveredRunning] = useState(false)\n  const abortRef = useRef<AbortController | null>(null)\n  const finalResultRef = useRef<RunResult | null>(null)\n  const hydratedStorageKeyRef = useRef<string | null>(null)\n  const resolveActiveRunIdRef = useRef(resolveActiveRunId)\n  const storageKey = useMemo(() => {\n    if (storageScopeKey) {\n      return `${storageKeyPrefix}:${projectId}:${storageScopeKey}`\n    }\n    return `${storageKeyPrefix}:${projectId}`\n  }, [projectId, storageKeyPrefix, storageScopeKey])\n\n  const applyEvent = useCallback((event: RunStreamEvent) => {\n    setRunState((prev) => applyRunStreamEvent(prev, event))\n  }, [])\n\n  useEffect(() => {\n    runStateRef.current = runState\n  }, [runState])\n\n  useEffect(() => {\n    resolveActiveRunIdRef.current = resolveActiveRunId\n  }, [resolveActiveRunId])\n\n  useEffect(() => {\n    if (!projectId) return\n    if (hydratedStorageKeyRef.current === storageKey) return\n    hydratedStorageKeyRef.current = storageKey\n    const snapshotRunState = loadRunSnapshot(storageKey)\n    if (!snapshotRunState) return\n    setRunState(snapshotRunState)\n    if (snapshotRunState.status === 'running') {\n      setIsRecoveredRunning(true)\n    }\n  }, [projectId, storageKey])\n\n  useEffect(() => {\n    if (!projectId || !resolveActiveRunIdRef.current) return\n\n    const lastProbed = probedScopes.get(storageKey)\n    if (lastProbed && Date.now() - lastProbed < PROBE_COOLDOWN_MS) return\n    probedScopes.set(storageKey, Date.now())\n\n    if (runStateRef.current) return\n    const existingSnapshot = loadRunSnapshot(storageKey)\n    if (existingSnapshot) return\n\n    let cancelled = false\n    void (async () => {\n      const activeRunId = await resolveActiveRunIdRef.current?.({\n        projectId,\n        storageScopeKey,\n      }).catch(() => null)\n      if (cancelled || !activeRunId) return\n      const now = Date.now()\n      setRunState((prev) => {\n        if (prev) return prev\n        return {\n          runId: activeRunId,\n          status: 'running',\n          startedAt: now,\n          updatedAt: now,\n          terminalAt: null,\n          errorMessage: '',\n          summary: null,\n          payload: null,\n          stepsById: {},\n          stepOrder: [],\n          activeStepId: null,\n          selectedStepId: null,\n        }\n      })\n      setIsRecoveredRunning(true)\n    })()\n\n    return () => {\n      cancelled = true\n    }\n  }, [projectId, storageKey, storageScopeKey])\n\n  useEffect(() => {\n    if (!projectId || !isRecoveredRunning || isLiveRunning) return\n    const runId = runState?.runId || ''\n    if (!runId || runState?.status !== 'running') return\n\n    return subscribeRecoveredRun({\n      runId,\n      taskStreamTimeoutMs: TASK_STREAM_TIMEOUT_MS,\n      applyAndCapture: applyEvent,\n      onSettled: () => {\n        setIsRecoveredRunning(false)\n      },\n    })\n  }, [\n    applyEvent,\n    isLiveRunning,\n    isRecoveredRunning,\n    projectId,\n    runState?.runId,\n    runState?.status,\n  ])\n\n  useEffect(() => {\n    if (!isRecoveredRunning) return\n    if (!runState) {\n      setIsRecoveredRunning(false)\n      return\n    }\n    if (runState.status === 'completed' || runState.status === 'failed') {\n      setIsRecoveredRunning(false)\n    }\n  }, [isRecoveredRunning, runState, runState?.status])\n\n  useEffect(() => {\n    if (!projectId) return\n    saveRunSnapshot(storageKey, runState)\n  }, [projectId, runState, storageKey])\n\n  const run = useCallback(\n    async (params: TParams): Promise<RunResult> => {\n      if (!projectId) {\n        throw new Error('projectId is required')\n      }\n      validateParams?.(params)\n\n      abortRef.current?.abort()\n      setIsRecoveredRunning(false)\n      setIsLiveRunning(true)\n      const controller = new AbortController()\n      abortRef.current = controller\n      finalResultRef.current = null\n\n      try {\n        const requestBody = buildRequestBody(params)\n        return await executeRunRequest({\n          endpointUrl: endpoint(projectId),\n          requestBody,\n          controller,\n          taskStreamTimeoutMs: TASK_STREAM_TIMEOUT_MS,\n          applyAndCapture: applyEvent,\n          finalResultRef,\n        })\n      } finally {\n        if (abortRef.current === controller) {\n          abortRef.current = null\n        }\n        setIsLiveRunning(false)\n      }\n    },\n    [\n      applyEvent,\n      buildRequestBody,\n      endpoint,\n      projectId,\n      validateParams,\n    ],\n  )\n\n  const retryStep = useCallback(async (params: {\n    stepId: string\n    modelOverride?: string\n    reason?: string\n  }): Promise<RunResult> => {\n    const runId = runStateRef.current?.runId || ''\n    if (!runId) {\n      throw new Error('runId is required')\n    }\n    const stepId = params.stepId.trim()\n    if (!stepId) {\n      throw new Error('stepId is required')\n    }\n\n    const response = await apiFetch(\n      `/api/runs/${runId}/steps/${encodeURIComponent(stepId)}/retry`,\n      {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          modelOverride: params.modelOverride || undefined,\n          reason: params.reason || undefined,\n        }),\n      },\n    )\n    const payload = await response.json().catch(() => null)\n    if (!response.ok) {\n      const errorMessage =\n        payload && typeof payload === 'object' && typeof (payload as { error?: { message?: unknown } }).error?.message === 'string'\n          ? (payload as { error: { message: string } }).error.message\n          : 'retry step failed'\n      throw new Error(errorMessage)\n    }\n\n    applyEvent({\n      runId,\n      event: 'run.start',\n      ts: new Date().toISOString(),\n      status: 'running',\n      message: 'retrying failed step',\n    })\n    setIsRecoveredRunning(true)\n    return {\n      runId,\n      status: 'running',\n      summary: null,\n      payload: payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : null,\n      errorMessage: '',\n    }\n  }, [applyEvent])\n\n  const stop = useCallback(() => {\n    const runningRunId = runState?.status === 'running' ? runState.runId : ''\n    if (runningRunId) {\n      void apiFetch(`/api/runs/${runningRunId}/cancel`, {\n        method: 'POST',\n      }).catch(() => null)\n      applyEvent({\n        runId: runningRunId,\n        event: 'run.error',\n        ts: new Date().toISOString(),\n        status: 'failed',\n        message: 'aborted',\n      })\n    }\n    abortRef.current?.abort()\n    abortRef.current = null\n    setIsLiveRunning(false)\n  }, [applyEvent, runState?.runId, runState?.status])\n\n  const reset = useCallback(() => {\n    stop()\n    setRunState(null)\n    finalResultRef.current = null\n    setIsRecoveredRunning(false)\n    clearRunSnapshot(storageKey)\n  }, [storageKey, stop])\n\n  useEffect(() => {\n    const timer = window.setInterval(() => setClock(Date.now()), 500)\n    return () => window.clearInterval(timer)\n  }, [])\n\n  const view = useMemo(() => {\n    return deriveRunStreamView({\n      runState,\n      isLiveRunning,\n      clock,\n    })\n  }, [clock, isLiveRunning, runState])\n\n  const selectStep = useCallback((stepId: string) => {\n    setRunState((prev) => {\n      if (!prev || !prev.stepsById[stepId]) return prev\n      return {\n        ...prev,\n        selectedStepId: stepId,\n      }\n    })\n  }, [])\n\n  return {\n    runState,\n    runId: runState?.runId || '',\n    status: runState?.status || 'idle',\n    isRunning: isLiveRunning,\n    isRecoveredRunning,\n    isVisible: view.isVisible,\n    errorMessage: runState?.errorMessage || '',\n    summary: runState?.summary || null,\n    payload: runState?.payload || null,\n    stages: view.stages,\n    orderedSteps: view.orderedSteps,\n    activeStepId: view.activeStepId,\n    selectedStep: view.selectedStep,\n    outputText: view.outputText,\n    overallProgress: view.overallProgress,\n    activeMessage: view.activeMessage,\n    run: run as (params: Record<string, unknown>) => Promise<RunResult>,\n    retryStep,\n    stop,\n    reset,\n    selectStep,\n  }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/run-stream-view.ts",
    "content": "import { getStageOutput, toStageViewStatus } from './state-machine'\nimport type { RunStageView, RunState, RunStepState } from './types'\n\nexport type DerivedRunStreamView = {\n  orderedSteps: RunStepState[]\n  activeStepId: string | null\n  selectedStep: RunStepState | null\n  outputText: string\n  stages: RunStageView[]\n  overallProgress: number\n  activeMessage: string\n  isVisible: boolean\n}\n\nexport function deriveRunStreamView(args: {\n  runState: RunState | null\n  isLiveRunning: boolean\n  clock: number\n}): DerivedRunStreamView {\n  const { runState, isLiveRunning, clock } = args\n  const orderedSteps = runState\n    ? runState.stepOrder\n        .map((id) => runState.stepsById[id])\n        .filter((item): item is RunStepState => !!item)\n    : []\n\n  const activeStepId = runState?.activeStepId || orderedSteps[orderedSteps.length - 1]?.id || null\n  const activeStep =\n    activeStepId && runState?.stepsById[activeStepId]\n      ? runState.stepsById[activeStepId]\n      : orderedSteps[orderedSteps.length - 1] || null\n  const selectedStepId = runState?.selectedStepId || activeStepId\n  const selectedStep =\n    selectedStepId && runState?.stepsById[selectedStepId]\n      ? runState.stepsById[selectedStepId]\n      : orderedSteps[orderedSteps.length - 1] || null\n\n  const outputText = (() => {\n    const stepOutput = getStageOutput(selectedStep)\n    if (stepOutput) return stepOutput\n    if (runState?.status === 'failed' && runState.errorMessage) {\n      return `【错误】\\n${runState.errorMessage}`\n    }\n    return ''\n  })()\n\n  const stages: RunStageView[] = orderedSteps.map((step) => ({\n    id: step.id,\n    title: step.title,\n    subtitle: (() => {\n      const relationText =\n        step.status === 'blocked' && step.blockedBy.length > 0\n          ? `等待: ${step.blockedBy.join(', ')}`\n          : step.dependsOn.length > 0\n            ? `依赖: ${step.dependsOn.join(', ')}`\n            : ''\n      const parallelText = step.groupId && step.parallelKey\n        ? `并行组: ${step.groupId}/${step.parallelKey}`\n        : ''\n      const parts = [relationText, parallelText, step.message || ''].filter(Boolean)\n      return parts.length > 0 ? parts.join(' | ') : undefined\n    })(),\n    status: toStageViewStatus(step.status),\n    attempt: step.attempt,\n    retryable: step.retryable,\n    progress:\n      step.status === 'completed'\n        ? 100\n        : step.status === 'stale'\n          ? 100\n          : step.status === 'blocked'\n            ? 0\n        : step.status === 'running'\n          ? Math.max(2, Math.min(99, step.textLength > 0 || step.reasoningLength > 0 ? 15 : 2))\n          : 0,\n  }))\n\n  const overallProgress =\n    stages.length === 0\n      ? 0\n      : stages.reduce((sum, stage) => {\n          if (stage.status === 'completed') return sum + 100\n          if (stage.status === 'stale') return sum + 100\n          if (stage.status === 'blocked') return sum\n          if (stage.status === 'failed') return sum\n          return sum + (stage.progress || 0)\n        }, 0) / stages.length\n\n  const activeMessage = !activeStep\n    ? runState?.status === 'failed'\n      ? runState.errorMessage\n      : 'progress.runtime.waitingExecution'\n    : activeStep.errorMessage\n      ? activeStep.errorMessage\n      : activeStep.status === 'completed'\n        ? 'progress.runtime.llm.completed'\n        : activeStep.status === 'failed'\n          ? 'progress.runtime.llm.failed'\n          : activeStep.status === 'blocked'\n            ? activeStep.blockedBy.length > 0\n              ? `等待依赖步骤: ${activeStep.blockedBy.join(', ')}`\n              : 'progress.runtime.waitingExecution'\n            : activeStep.status === 'stale'\n              ? '结果已过期，请按需重试'\n          : activeStep.message || 'progress.runtime.llm.processing'\n\n  void clock\n  const isVisible = !!runState && (\n    isLiveRunning ||\n    runState.status === 'running' ||\n    runState.status === 'failed'\n  )\n\n  return {\n    orderedSteps,\n    activeStepId,\n    selectedStep,\n    outputText,\n    stages,\n    overallProgress,\n    activeMessage,\n    isVisible,\n  }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/snapshot.ts",
    "content": "import type { RunState } from './types'\n\nexport const SNAPSHOT_TTL_MS = 1000 * 60 * 60 * 6\n\ntype RunSnapshot = {\n  savedAt: number\n  runState: RunState\n}\n\nexport function loadRunSnapshot(storageKey: string): RunState | null {\n  if (typeof window === 'undefined') return null\n  try {\n    const raw = window.sessionStorage.getItem(storageKey)\n    if (!raw) return null\n    const parsed = JSON.parse(raw) as RunSnapshot\n    if (!parsed || typeof parsed !== 'object') {\n      window.sessionStorage.removeItem(storageKey)\n      return null\n    }\n    if (typeof parsed.savedAt !== 'number' || Date.now() - parsed.savedAt > SNAPSHOT_TTL_MS) {\n      window.sessionStorage.removeItem(storageKey)\n      return null\n    }\n    const snapshotRunState = parsed.runState\n    if (!snapshotRunState || typeof snapshotRunState !== 'object' || typeof snapshotRunState.runId !== 'string') {\n      window.sessionStorage.removeItem(storageKey)\n      return null\n    }\n    return snapshotRunState\n  } catch {\n    try {\n      window.sessionStorage.removeItem(storageKey)\n    } catch { }\n    return null\n  }\n}\n\nexport function saveRunSnapshot(storageKey: string, runState: RunState | null) {\n  if (typeof window === 'undefined') return\n  try {\n    if (!runState) {\n      window.sessionStorage.removeItem(storageKey)\n      return\n    }\n    const snapshot: RunSnapshot = {\n      savedAt: Date.now(),\n      runState,\n    }\n    window.sessionStorage.setItem(storageKey, JSON.stringify(snapshot))\n  } catch { }\n}\n\nexport function clearRunSnapshot(storageKey: string) {\n  if (typeof window === 'undefined') return\n  try {\n    window.sessionStorage.removeItem(storageKey)\n  } catch { }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/state-machine.ts",
    "content": "import type {\n  RunStepStatus,\n  RunStreamEvent,\n  RunStreamLane,\n  RunStreamStatus,\n} from '@/lib/novel-promotion/run-stream/types'\nimport type {\n  RunState,\n  RunStepState,\n  StageViewStatus,\n} from './types'\n\nexport function toTimestamp(ts: string | undefined, fallback: number): number {\n  if (!ts) return fallback\n  const parsed = Date.parse(ts)\n  return Number.isFinite(parsed) ? parsed : fallback\n}\n\nfunction rankStepStatus(status: RunStepStatus): number {\n  if (status === 'pending' || status === 'blocked') return 0\n  if (status === 'running') return 1\n  if (status === 'completed' || status === 'stale') return 2\n  return 3\n}\n\nfunction rankRunStatus(status: RunStreamStatus): number {\n  if (status === 'idle') return 0\n  if (status === 'running') return 1\n  if (status === 'completed') return 2\n  return 3\n}\n\nfunction lockForwardStepStatus(prev: RunStepStatus, next: RunStepStatus): RunStepStatus {\n  if (prev === 'failed') return prev\n  if (prev === 'completed' && next !== 'stale') return prev\n  if (prev === 'stale' && next !== 'failed') return prev\n  return rankStepStatus(next) >= rankStepStatus(prev) ? next : prev\n}\n\nfunction lockForwardRunStatus(prev: RunStreamStatus, next: RunStreamStatus): RunStreamStatus {\n  if (prev === 'completed' || prev === 'failed') return prev\n  return rankRunStatus(next) >= rankRunStatus(prev) ? next : prev\n}\n\nfunction normalizeLane(value: unknown): RunStreamLane {\n  return value === 'reasoning' ? 'reasoning' : 'text'\n}\n\nfunction splitThinkTaggedContent(input: string): { text: string; reasoning: string } {\n  const thinkTagPattern = /<(think|thinking)\\b[^>]*>([\\s\\S]*?)<\\/\\1>/gi\n  const reasoningParts: string[] = []\n  let matched = false\n\n  const stripped = input.replace(thinkTagPattern, (_fullMatch, _tagName: string, inner: string) => {\n    matched = true\n    const trimmed = inner.trim()\n    if (trimmed) reasoningParts.push(trimmed)\n    return ''\n  })\n\n  if (!matched) {\n    return {\n      text: input,\n      reasoning: '',\n    }\n  }\n\n  return {\n    text: stripped.trim(),\n    reasoning: reasoningParts.join('\\n\\n').trim(),\n  }\n}\n\nfunction mergeReasoningText(current: string, incoming: string): string {\n  const next = incoming.trim()\n  if (!next) return current\n  const prev = current.trim()\n  if (!prev) return next\n  if (prev.includes(next)) return current\n  return `${prev}\\n\\n${next}`\n}\n\nfunction normalizeThinkTaggedStepOutput(step: RunStepState) {\n  if (!step.textOutput) return\n  const parsed = splitThinkTaggedContent(step.textOutput)\n  if (!parsed.reasoning) return\n  step.textOutput = parsed.text\n  step.reasoningOutput = mergeReasoningText(step.reasoningOutput, parsed.reasoning)\n}\n\nfunction parseStepIdentity(rawStepId: string): {\n  canonicalStepId: string\n  attempt: number\n} {\n  const matched = rawStepId.match(/^(.*)_r([0-9]+)$/)\n  if (!matched) {\n    return {\n      canonicalStepId: rawStepId,\n      attempt: 1,\n    }\n  }\n  const baseStepId = matched[1]?.trim()\n  const attempt = Number.parseInt(matched[2] || '1', 10)\n  if (!baseStepId || !Number.isFinite(attempt) || attempt < 2) {\n    return {\n      canonicalStepId: rawStepId,\n      attempt: 1,\n    }\n  }\n  return {\n    canonicalStepId: baseStepId,\n    attempt,\n  }\n}\n\nfunction normalizeStepStatus(value: unknown): RunStepStatus {\n  if (\n    value === 'running' ||\n    value === 'completed' ||\n    value === 'failed' ||\n    value === 'blocked' ||\n    value === 'stale'\n  ) {\n    return value\n  }\n  return 'pending'\n}\n\nfunction normalizeRunStatus(value: unknown): RunStreamStatus {\n  if (value === 'running' || value === 'completed' || value === 'failed') return value\n  return 'idle'\n}\n\nexport function toStageViewStatus(status: RunStepStatus): StageViewStatus {\n  if (status === 'running') return 'processing'\n  if (status === 'pending') return 'pending'\n  if (status === 'blocked') return 'blocked'\n  if (status === 'stale') return 'stale'\n  if (status === 'completed') return 'completed'\n  return 'failed'\n}\n\nfunction readStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  const rows: string[] = []\n  for (const item of value) {\n    if (typeof item !== 'string') continue\n    const trimmed = item.trim()\n    if (!trimmed) continue\n    rows.push(trimmed)\n  }\n  return rows\n}\n\nfunction mergeStringArray(base: string[], incoming: string[]): string[] {\n  if (incoming.length === 0) return base\n  const seen = new Set<string>()\n  const next: string[] = []\n  for (const item of incoming) {\n    if (seen.has(item)) continue\n    seen.add(item)\n    next.push(item)\n  }\n  return next\n}\n\nfunction readBool(value: unknown): boolean | null {\n  if (value === true) return true\n  if (value === false) return false\n  return null\n}\n\nfunction buildDefaultStep(event: RunStreamEvent, now: number): RunStepState {\n  const stepId = event.stepId || 'unknown_step'\n  const stepAttempt =\n    typeof event.stepAttempt === 'number' && Number.isFinite(event.stepAttempt)\n      ? Math.max(1, Math.floor(event.stepAttempt))\n      : 1\n  const stepTitle = typeof event.stepTitle === 'string' && event.stepTitle.trim() ? event.stepTitle : stepId\n  const stepIndex =\n    typeof event.stepIndex === 'number' && Number.isFinite(event.stepIndex)\n      ? Math.max(1, Math.floor(event.stepIndex))\n      : 1\n  const stepTotal =\n    typeof event.stepTotal === 'number' && Number.isFinite(event.stepTotal)\n      ? Math.max(stepIndex, Math.floor(event.stepTotal))\n      : stepIndex\n\n  return {\n    id: stepId,\n    attempt: stepAttempt,\n    title: stepTitle,\n    stepIndex,\n    stepTotal,\n    status: 'pending',\n    dependsOn: [],\n    blockedBy: [],\n    groupId: null,\n    parallelKey: null,\n    retryable: true,\n    textOutput: '',\n    reasoningOutput: '',\n    textLength: 0,\n    reasoningLength: 0,\n    message: '',\n    errorMessage: '',\n    updatedAt: now,\n    seqByLane: {\n      text: 0,\n      reasoning: 0,\n    },\n  }\n}\n\nfunction resetStepForRetry(step: RunStepState, attempt: number) {\n  step.attempt = attempt\n  step.status = 'pending'\n  step.textOutput = ''\n  step.reasoningOutput = ''\n  step.textLength = 0\n  step.reasoningLength = 0\n  step.message = ''\n  step.errorMessage = ''\n  step.blockedBy = []\n  step.seqByLane = {\n    text: 0,\n    reasoning: 0,\n  }\n}\n\nfunction createInitialRunState(runId: string, now: number): RunState {\n  return {\n    runId,\n    status: 'running',\n    startedAt: now,\n    updatedAt: now,\n    terminalAt: null,\n    errorMessage: '',\n    summary: null,\n    payload: null,\n    stepsById: {},\n    stepOrder: [],\n    activeStepId: null,\n    selectedStepId: null,\n  }\n}\n\nfunction computeStepDependencyLevel(params: {\n  stepId: string\n  stepsById: Record<string, RunStepState>\n  memo: Map<string, number>\n  visiting: Set<string>\n}): number {\n  const { stepId, stepsById, memo, visiting } = params\n  const cached = memo.get(stepId)\n  if (typeof cached === 'number') return cached\n  const step = stepsById[stepId]\n  if (!step) return 0\n  if (visiting.has(stepId)) {\n    // Unexpected cycle: keep deterministic order without recursion loop.\n    return Math.max(0, step.stepIndex - 1)\n  }\n  visiting.add(stepId)\n  let level = 0\n  for (const dep of step.dependsOn) {\n    const depLevel = computeStepDependencyLevel({\n      stepId: dep,\n      stepsById,\n      memo,\n      visiting,\n    })\n    level = Math.max(level, depLevel + 1)\n  }\n  visiting.delete(stepId)\n  memo.set(stepId, level)\n  return level\n}\n\nexport function applyRunStreamEvent(prev: RunState | null, event: RunStreamEvent): RunState | null {\n  const now = toTimestamp(event.ts, Date.now())\n  const runId = event.runId || prev?.runId || ''\n  if (!runId) return prev\n\n  const base: RunState =\n    prev && prev.runId === runId\n      ? { ...prev }\n      : createInitialRunState(runId, now)\n  const prevActiveStepId = base.activeStepId\n\n  base.updatedAt = now\n\n  if (event.event === 'run.start') {\n    const nextStatus = normalizeRunStatus(event.status)\n    base.status = lockForwardRunStatus(base.status, nextStatus === 'idle' ? 'running' : nextStatus)\n    if (event.payload && typeof event.payload === 'object') {\n      base.payload = event.payload\n    }\n    return base\n  }\n\n  if (event.event === 'run.complete') {\n    base.status = lockForwardRunStatus(base.status, 'completed')\n    base.summary =\n      event.payload?.summary && typeof event.payload.summary === 'object'\n        ? (event.payload.summary as Record<string, unknown>)\n        : event.payload || base.summary\n    base.payload = event.payload || base.payload\n    const finalizedSteps: Record<string, RunStepState> = { ...base.stepsById }\n    for (const stepId of base.stepOrder) {\n      const currentStep = finalizedSteps[stepId]\n      if (!currentStep) continue\n      if (currentStep.status === 'completed' || currentStep.status === 'failed' || currentStep.status === 'stale') continue\n      finalizedSteps[stepId] = {\n        ...currentStep,\n        status: 'completed',\n        updatedAt: now,\n      }\n    }\n    base.stepsById = finalizedSteps\n    base.terminalAt = now\n    return base\n  }\n\n  if (event.event === 'run.error') {\n    base.status = lockForwardRunStatus(base.status, 'failed')\n    const runErrorMessage = typeof event.message === 'string' ? event.message : base.errorMessage\n    base.errorMessage = runErrorMessage\n    // When only run.error is emitted (without step.error), mark unfinished steps failed\n    // so the UI does not keep showing \"processing\" forever.\n    const nextStepsById: Record<string, RunStepState> = { ...base.stepsById }\n    for (const stepId of base.stepOrder) {\n      const currentStep = nextStepsById[stepId]\n      if (!currentStep) continue\n      if (currentStep.status === 'completed' || currentStep.status === 'failed') continue\n      nextStepsById[stepId] = {\n        ...currentStep,\n        status: 'failed',\n        errorMessage: currentStep.errorMessage || runErrorMessage,\n        updatedAt: now,\n      }\n    }\n    base.stepsById = nextStepsById\n    base.terminalAt = now\n    return base\n  }\n\n  const rawStepId = event.stepId\n  if (!rawStepId) return base\n  const stepIdentity = parseStepIdentity(rawStepId)\n  const stepId = stepIdentity.canonicalStepId\n  const incomingAttempt =\n    typeof event.stepAttempt === 'number' && Number.isFinite(event.stepAttempt)\n      ? Math.max(1, Math.floor(event.stepAttempt))\n      : stepIdentity.attempt\n  const existingStep = base.stepsById[stepId]\n  const step = existingStep\n    ? { ...existingStep }\n    : buildDefaultStep({ ...event, stepId, stepAttempt: incomingAttempt }, now)\n  if (!Number.isFinite(step.attempt) || step.attempt < 1) {\n    step.attempt = 1\n  }\n\n  if (incomingAttempt < step.attempt) {\n    return base\n  }\n\n  if (incomingAttempt > step.attempt) {\n    resetStepForRetry(step, incomingAttempt)\n    base.errorMessage = ''\n  }\n\n  step.updatedAt = now\n  if (typeof event.stepTitle === 'string' && event.stepTitle.trim()) {\n    step.title = event.stepTitle.trim()\n  }\n  if (typeof event.stepIndex === 'number' && Number.isFinite(event.stepIndex)) {\n    step.stepIndex = Math.max(1, Math.floor(event.stepIndex))\n  }\n  if (typeof event.stepTotal === 'number' && Number.isFinite(event.stepTotal)) {\n    step.stepTotal = Math.max(step.stepIndex, Math.floor(event.stepTotal))\n  }\n  if (Array.isArray(event.dependsOn)) {\n    step.dependsOn = mergeStringArray(step.dependsOn, readStringArray(event.dependsOn))\n  }\n  if (Array.isArray(event.blockedBy)) {\n    step.blockedBy = mergeStringArray(step.blockedBy, readStringArray(event.blockedBy))\n  }\n  if (typeof event.groupId === 'string' && event.groupId.trim()) {\n    step.groupId = event.groupId.trim()\n  }\n  if (typeof event.parallelKey === 'string' && event.parallelKey.trim()) {\n    step.parallelKey = event.parallelKey.trim()\n  }\n  if (typeof event.retryable === 'boolean') {\n    step.retryable = event.retryable\n  }\n\n  if (event.event === 'step.start') {\n    if (event.blockedBy && event.blockedBy.length > 0) {\n      step.status = lockForwardStepStatus(step.status, 'blocked')\n    } else {\n      step.blockedBy = []\n      step.status = lockForwardStepStatus(step.status, 'running')\n      base.status = lockForwardRunStatus(base.status, 'running')\n    }\n  }\n\n  if (event.event === 'step.chunk') {\n    const lane = normalizeLane(event.lane)\n    const seq =\n      typeof event.seq === 'number' && Number.isFinite(event.seq)\n        ? Math.max(1, Math.floor(event.seq))\n        : null\n    const lastSeq = step.seqByLane[lane]\n    if (seq === null || seq > lastSeq) {\n      if (step.status === 'completed') {\n        // Late chunks can arrive after a premature step.complete event.\n        // Reopen the step so UI does not show \"completed\" while output is still growing.\n        step.status = 'running'\n      }\n      if (seq !== null) {\n        step.seqByLane = {\n          ...step.seqByLane,\n          [lane]: seq,\n        }\n      }\n\n      if (lane === 'reasoning') {\n        const delta =\n          typeof event.reasoningDelta === 'string'\n            ? event.reasoningDelta\n            : typeof event.textDelta === 'string'\n              ? event.textDelta\n              : ''\n        if (delta) step.reasoningOutput += delta\n      } else {\n        const delta =\n          typeof event.textDelta === 'string'\n            ? event.textDelta\n            : typeof event.reasoningDelta === 'string'\n              ? event.reasoningDelta\n              : ''\n        if (delta) {\n          step.textOutput += delta\n          normalizeThinkTaggedStepOutput(step)\n        }\n      }\n    }\n\n    if (event.blockedBy && event.blockedBy.length > 0) {\n      step.status = lockForwardStepStatus(step.status, 'blocked')\n    } else {\n      step.blockedBy = []\n      step.status = lockForwardStepStatus(step.status, 'running')\n      base.status = lockForwardRunStatus(base.status, 'running')\n    }\n    step.textLength = step.textOutput.length\n    step.reasoningLength = step.reasoningOutput.length\n  }\n\n  if (event.event === 'step.complete') {\n    if (typeof event.text === 'string' && event.text.length >= step.textOutput.length) {\n      step.textOutput = event.text\n      normalizeThinkTaggedStepOutput(step)\n    }\n    if (typeof event.reasoning === 'string' && event.reasoning.length >= step.reasoningOutput.length) {\n      step.reasoningOutput = event.reasoning\n    }\n    step.textLength = step.textOutput.length\n    step.reasoningLength = step.reasoningOutput.length\n    const normalizedStatus = normalizeStepStatus(event.status)\n    if (normalizedStatus === 'stale') {\n      step.status = lockForwardStepStatus(step.status, 'stale')\n    } else {\n      step.status = lockForwardStepStatus(\n        step.status,\n        normalizedStatus === 'failed' ? 'failed' : 'completed',\n      )\n    }\n  }\n\n  if (event.event === 'step.error') {\n    step.status = lockForwardStepStatus(step.status, 'failed')\n    step.errorMessage = typeof event.message === 'string' ? event.message : step.errorMessage\n    base.errorMessage = step.errorMessage || base.errorMessage\n  }\n\n  if (typeof event.message === 'string' && event.message) {\n    step.message = event.message\n  }\n  const staleByPayload = readBool(event.payload?.stale)\n  if (staleByPayload === true) {\n    step.status = 'stale'\n  }\n  const blockedByFromEvent = Array.isArray(event.blockedBy) ? readStringArray(event.blockedBy) : []\n  const blockedByPayload = readStringArray(event.payload?.blockedBy)\n  const blockedBy = blockedByPayload.length > 0 ? blockedByPayload : blockedByFromEvent\n  if (blockedBy.length > 0) {\n    step.blockedBy = blockedBy\n    if (step.status !== 'failed' && step.status !== 'completed') {\n      step.status = 'blocked'\n    }\n  } else if (event.event === 'step.start' || event.event === 'step.chunk') {\n    step.blockedBy = []\n  }\n\n  base.stepsById = {\n    ...base.stepsById,\n    [stepId]: step,\n  }\n  if (!base.stepOrder.includes(stepId)) {\n    base.stepOrder = [...base.stepOrder, stepId]\n  }\n  const levelMemo = new Map<string, number>()\n  base.stepOrder = [...base.stepOrder].sort((a, b) => {\n    const sa = base.stepsById[a]\n    const sb = base.stepsById[b]\n    if (!sa || !sb) return 0\n    const levelA = computeStepDependencyLevel({\n      stepId: a,\n      stepsById: base.stepsById,\n      memo: levelMemo,\n      visiting: new Set<string>(),\n    })\n    const levelB = computeStepDependencyLevel({\n      stepId: b,\n      stepsById: base.stepsById,\n      memo: levelMemo,\n      visiting: new Set<string>(),\n    })\n    if (levelA !== levelB) return levelA - levelB\n    if (sa.stepIndex !== sb.stepIndex) return sa.stepIndex - sb.stepIndex\n    return sa.id.localeCompare(sb.id)\n  })\n\n  const runningSteps = Object.values(base.stepsById).filter((item) => item.status === 'running')\n  if (runningSteps.length > 0) {\n    const maxRunningStepIndex = Math.max(...runningSteps.map((item) => item.stepIndex))\n    const topCandidates = runningSteps\n      .filter((item) => item.stepIndex === maxRunningStepIndex)\n      .sort((a, b) => {\n        if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt\n        return a.id.localeCompare(b.id)\n      })\n    const keepCurrentActive =\n      base.activeStepId && topCandidates.some((item) => item.id === base.activeStepId)\n        ? base.activeStepId\n        : null\n    base.activeStepId = keepCurrentActive || topCandidates[0]?.id || null\n  } else {\n    const allSteps = Object.values(base.stepsById)\n    if (allSteps.length === 0) {\n      base.activeStepId = null\n    } else {\n      const maxStepIndex = Math.max(...allSteps.map((item) => item.stepIndex))\n      const topCandidates = allSteps\n        .filter((item) => item.stepIndex === maxStepIndex)\n        .sort((a, b) => {\n          if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt\n          return a.id.localeCompare(b.id)\n        })\n      base.activeStepId = topCandidates[0]?.id || null\n    }\n  }\n\n  if (\n    !base.selectedStepId ||\n    !base.stepsById[base.selectedStepId] ||\n    base.selectedStepId === prevActiveStepId\n  ) {\n    base.selectedStepId = base.activeStepId\n  }\n\n  return base\n}\n\nexport function getStageOutput(step: RunStepState | null) {\n  if (!step) return ''\n  if (step.reasoningOutput && step.textOutput) {\n    return `【思考过程】\\n${step.reasoningOutput}\\n\\n【最终结果】\\n${step.textOutput}`\n  }\n  if (step.reasoningOutput) return `【思考过程】\\n${step.reasoningOutput}`\n  if (step.textOutput) return `【最终结果】\\n${step.textOutput}`\n  if (step.status === 'failed' && step.errorMessage) return `【错误】\\n${step.errorMessage}`\n  return ''\n}\n"
  },
  {
    "path": "src/lib/query/hooks/run-stream/types.ts",
    "content": "import type { RunStepStatus, RunStreamLane, RunStreamStatus } from '@/lib/novel-promotion/run-stream/types'\n\nexport type RunStepState = {\n  id: string\n  attempt: number\n  title: string\n  stepIndex: number\n  stepTotal: number\n  status: RunStepStatus\n  dependsOn: string[]\n  blockedBy: string[]\n  groupId: string | null\n  parallelKey: string | null\n  retryable: boolean\n  textOutput: string\n  reasoningOutput: string\n  textLength: number\n  reasoningLength: number\n  message: string\n  errorMessage: string\n  updatedAt: number\n  seqByLane: Record<RunStreamLane, number>\n}\n\nexport type RunState = {\n  runId: string\n  status: RunStreamStatus\n  startedAt: number\n  updatedAt: number\n  terminalAt: number | null\n  errorMessage: string\n  summary: Record<string, unknown> | null\n  payload: Record<string, unknown> | null\n  stepsById: Record<string, RunStepState>\n  stepOrder: string[]\n  activeStepId: string | null\n  selectedStepId: string | null\n}\n\nexport type RunResult = {\n  runId: string\n  status: RunStreamStatus\n  summary: Record<string, unknown> | null\n  payload: Record<string, unknown> | null\n  errorMessage: string\n}\n\nexport type StageViewStatus = 'pending' | 'queued' | 'processing' | 'completed' | 'failed' | 'blocked' | 'stale'\n\nexport type RunStageView = {\n  id: string\n  title: string\n  subtitle?: string\n  status: StageViewStatus\n  progress: number\n  attempt?: number\n  retryable?: boolean\n}\n\nexport type UseRunStreamStateOptions<TParams extends Record<string, unknown>> = {\n  projectId: string\n  endpoint: (projectId: string) => string\n  storageKeyPrefix: string\n  storageScopeKey?: string\n  buildRequestBody: (params: TParams) => Record<string, unknown>\n  validateParams?: (params: TParams) => void\n  resolveActiveRunId?: (context: { projectId: string; storageScopeKey?: string }) => Promise<string | null>\n}\n\nexport type RunStreamView = {\n  runState: RunState | null\n  runId: string\n  status: RunStreamStatus | 'idle'\n  isRunning: boolean\n  isRecoveredRunning: boolean\n  isVisible: boolean\n  errorMessage: string\n  summary: Record<string, unknown> | null\n  payload: Record<string, unknown> | null\n  stages: RunStageView[]\n  orderedSteps: RunStepState[]\n  activeStepId: string | null\n  selectedStep: RunStepState | null\n  outputText: string\n  overallProgress: number\n  activeMessage: string\n  run: (params: Record<string, unknown>) => Promise<RunResult>\n  retryStep: (params: { stepId: string; modelOverride?: string; reason?: string }) => Promise<RunResult>\n  stop: () => void\n  reset: () => void\n  selectStep: (stepId: string) => void\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useGlobalAssets.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { useTaskTargetStateMap } from './useTaskTargetStateMap'\nimport { resolveTaskErrorMessage } from '@/lib/task/error-message'\nimport type { MediaRef } from '@/types/project'\nimport { apiFetch } from '@/lib/api-fetch'\n\n// ============ 类型定义 ============\nexport interface GlobalCharacterAppearance {\n    id: string\n    appearanceIndex: number\n    changeReason: string\n    artStyle: string | null\n    description: string | null\n    descriptionSource: string | null\n    imageUrl: string | null\n    media?: MediaRef | null\n    imageUrls: string[]\n    imageMedias?: MediaRef[]\n    selectedIndex: number | null\n    previousImageUrl: string | null\n    previousMedia?: MediaRef | null\n    previousImageUrls: string[]\n    previousImageMedias?: MediaRef[]\n    imageTaskRunning: boolean\n    lastError?: { code: string; message: string } | null\n}\n\nexport interface GlobalCharacter {\n    id: string\n    name: string\n    folderId: string | null\n    customVoiceUrl: string | null\n    media?: MediaRef | null\n    appearances: GlobalCharacterAppearance[]\n}\n\nexport interface GlobalLocationImage {\n    id: string\n    imageIndex: number\n    description: string | null\n    imageUrl: string | null\n    media?: MediaRef | null\n    previousImageUrl: string | null\n    previousMedia?: MediaRef | null\n    isSelected: boolean\n    imageTaskRunning: boolean\n    lastError?: { code: string; message: string } | null\n}\n\nexport interface GlobalLocation {\n    id: string\n    name: string\n    summary: string | null\n    artStyle: string | null\n    folderId: string | null\n    images: GlobalLocationImage[]\n}\n\nexport interface GlobalVoice {\n    id: string\n    name: string\n    description: string | null\n    voiceId: string | null\n    voiceType: string\n    customVoiceUrl: string | null\n    media?: MediaRef | null\n    voicePrompt: string | null\n    gender: string | null\n    language: string\n    folderId: string | null\n}\n\nexport interface GlobalFolder {\n    id: string\n    name: string\n}\n\nconst GLOBAL_ASSET_PROJECT_ID = 'global-asset-hub'\nconst GLOBAL_IMAGE_TASK_TYPES = ['asset_hub_image']\nconst GLOBAL_MODIFY_TASK_TYPES = ['asset_hub_modify']\n\nfunction isRunningPhase(phase: string | null | undefined) {\n    return phase === 'queued' || phase === 'processing'\n}\n\n// ============ 查询 Hooks ============\n\n/**\n * 获取中心资产库角色列表\n */\nexport function useGlobalCharacters(folderId?: string | null) {\n    const charactersQuery = useQuery({\n        queryKey: queryKeys.globalAssets.characters(folderId),\n        queryFn: async () => {\n            const params = new URLSearchParams()\n            if (folderId) params.set('folderId', folderId)\n            const res = await apiFetch(`/api/asset-hub/characters?${params}`)\n            if (!res.ok) throw new Error('Failed to fetch characters')\n            const data = await res.json()\n            return data.characters as GlobalCharacter[]\n        },\n    })\n    const taskTargets = useMemo(() => {\n        const characters = charactersQuery.data || []\n        const targets: Array<{ targetType: string; targetId: string; types: string[] }> = []\n        for (const character of characters) {\n            targets.push({\n                targetType: 'GlobalCharacter',\n                targetId: character.id,\n                types: GLOBAL_IMAGE_TASK_TYPES,\n            })\n            for (const appearance of character.appearances || []) {\n                targets.push({\n                    targetType: 'GlobalCharacterAppearance',\n                    targetId: appearance.id,\n                    types: GLOBAL_MODIFY_TASK_TYPES,\n                })\n                const imageCount = Math.max(1, appearance.imageUrls?.length || 0)\n                for (let index = 0; index < imageCount; index += 1) {\n                    targets.push({\n                        targetType: 'GlobalCharacterAppearance',\n                        targetId: `${character.id}:${appearance.appearanceIndex}:${index}`,\n                        types: GLOBAL_MODIFY_TASK_TYPES,\n                    })\n                }\n            }\n        }\n        return targets\n    }, [charactersQuery.data])\n\n    const taskStatesQuery = useTaskTargetStateMap(GLOBAL_ASSET_PROJECT_ID, taskTargets, {\n        enabled: taskTargets.length > 0,\n    })\n\n    const data = useMemo(() => {\n        const characters = charactersQuery.data\n        if (!characters) return characters\n        const byKey = taskStatesQuery.byKey\n        const getState = (targetType: string, targetId: string) =>\n            byKey.get(`${targetType}:${targetId}`) || null\n        return characters.map((character) => ({\n            ...character,\n            appearances: (character.appearances || []).map((appearance) => {\n                const imageCount = Math.max(1, appearance.imageUrls?.length || 0)\n                let hasAppearanceTask = isRunningPhase(\n                    getState('GlobalCharacterAppearance', appearance.id)?.phase,\n                )\n                let appearanceError: { code: string; message: string } | null = null\n                for (let index = 0; index < imageCount; index += 1) {\n                    const indexState = getState(\n                        'GlobalCharacterAppearance',\n                        `${character.id}:${appearance.appearanceIndex}:${index}`,\n                    )\n                    if (!hasAppearanceTask && isRunningPhase(indexState?.phase)) {\n                        hasAppearanceTask = true\n                    }\n                    if (!appearanceError && indexState?.lastError) {\n                        appearanceError = indexState.lastError\n                    }\n                }\n                const characterState = getState('GlobalCharacter', character.id)\n                const hasCharacterTask = isRunningPhase(characterState?.phase)\n                // 优先取子索引级的错误，其次取 appearance 级，最后取 character 级\n                const lastError = appearanceError\n                    || getState('GlobalCharacterAppearance', appearance.id)?.lastError\n                    || characterState?.lastError\n                    || null\n                return {\n                    ...appearance,\n                    imageTaskRunning: hasCharacterTask || hasAppearanceTask,\n                    lastError,\n                }\n            }),\n        }))\n    }, [charactersQuery.data, taskStatesQuery.byKey])\n\n    return {\n        ...charactersQuery,\n        data,\n        isFetching: charactersQuery.isFetching || taskStatesQuery.isFetching,\n    }\n}\n\n/**\n * 获取中心资产库场景列表\n */\nexport function useGlobalLocations(folderId?: string | null) {\n    const locationsQuery = useQuery({\n        queryKey: queryKeys.globalAssets.locations(folderId),\n        queryFn: async () => {\n            const params = new URLSearchParams()\n            if (folderId) params.set('folderId', folderId)\n            const res = await apiFetch(`/api/asset-hub/locations?${params}`)\n            if (!res.ok) throw new Error('Failed to fetch locations')\n            const data = await res.json()\n            return data.locations as GlobalLocation[]\n        },\n    })\n    const taskTargets = useMemo(() => {\n        const locations = locationsQuery.data || []\n        const targets: Array<{ targetType: string; targetId: string; types: string[] }> = []\n        for (const location of locations) {\n            targets.push({\n                targetType: 'GlobalLocation',\n                targetId: location.id,\n                types: GLOBAL_IMAGE_TASK_TYPES,\n            })\n            for (const image of location.images || []) {\n                targets.push({\n                    targetType: 'GlobalLocationImage',\n                    targetId: image.id,\n                    types: GLOBAL_MODIFY_TASK_TYPES,\n                })\n                targets.push({\n                    targetType: 'GlobalLocationImage',\n                    targetId: `${location.id}:${image.imageIndex}`,\n                    types: GLOBAL_MODIFY_TASK_TYPES,\n                })\n            }\n        }\n        return targets\n    }, [locationsQuery.data])\n\n    const taskStatesQuery = useTaskTargetStateMap(GLOBAL_ASSET_PROJECT_ID, taskTargets, {\n        enabled: taskTargets.length > 0,\n    })\n\n    const data = useMemo(() => {\n        const locations = locationsQuery.data\n        if (!locations) return locations\n        const byKey = taskStatesQuery.byKey\n        const getState = (targetType: string, targetId: string) =>\n            byKey.get(`${targetType}:${targetId}`) || null\n        return locations.map((location) => ({\n            ...location,\n            images: (location.images || []).map((image) => {\n                const locationState = getState('GlobalLocation', location.id)\n                const imageState = getState('GlobalLocationImage', image.id)\n                const indexState = getState('GlobalLocationImage', `${location.id}:${image.imageIndex}`)\n                const hasLocationTask = isRunningPhase(locationState?.phase)\n                const hasImageTask =\n                    isRunningPhase(imageState?.phase) ||\n                    isRunningPhase(indexState?.phase)\n                const lastError = indexState?.lastError\n                    || imageState?.lastError\n                    || locationState?.lastError\n                    || null\n                return {\n                    ...image,\n                    imageTaskRunning: hasLocationTask || hasImageTask,\n                    lastError,\n                }\n            }),\n        }))\n    }, [locationsQuery.data, taskStatesQuery.byKey])\n\n    return {\n        ...locationsQuery,\n        data,\n        isFetching: locationsQuery.isFetching || taskStatesQuery.isFetching,\n    }\n}\n\n/**\n * 获取中心资产库音色列表\n */\nexport function useGlobalVoices(folderId?: string | null) {\n    return useQuery({\n        queryKey: queryKeys.globalAssets.voices(folderId),\n        queryFn: async () => {\n            const params = new URLSearchParams()\n            if (folderId) params.set('folderId', folderId)\n            const res = await apiFetch(`/api/asset-hub/voices?${params}`)\n            if (!res.ok) throw new Error('Failed to fetch voices')\n            const data = await res.json()\n            return data.voices as GlobalVoice[]\n        },\n    })\n}\n\n/**\n * 获取中心资产库文件夹列表\n */\nexport function useGlobalFolders() {\n    return useQuery({\n        queryKey: queryKeys.globalAssets.folders(),\n        queryFn: async () => {\n            const res = await apiFetch('/api/asset-hub/folders')\n            if (!res.ok) throw new Error('Failed to fetch folders')\n            const data = await res.json()\n            return data.folders as GlobalFolder[]\n        },\n    })\n}\n\n// ============ 文件夹 Mutation Hooks ============\n\n/**\n * 创建文件夹\n */\nexport function useCreateFolder() {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ name }: { name: string }) => {\n            const res = await apiFetch('/api/asset-hub/folders', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ name }),\n            })\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to create folder'))\n            }\n            return res.json()\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.folders() })\n        },\n    })\n}\n\n/**\n * 更新文件夹\n */\nexport function useUpdateFolder() {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ folderId, name }: { folderId: string; name: string }) => {\n            const res = await apiFetch('/api/asset-hub/folders', {\n                method: 'PUT',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ folderId, name }),\n            })\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to update folder'))\n            }\n            return res.json()\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.folders() })\n        },\n    })\n}\n\n/**\n * 删除文件夹\n */\nexport function useDeleteFolder() {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ folderId }: { folderId: string }) => {\n            const res = await apiFetch(`/api/asset-hub/folders?folderId=${folderId}`, {\n                method: 'DELETE',\n            })\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to delete folder'))\n            }\n            return res.json()\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.folders() })\n            queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.all() })\n        },\n    })\n}\n\n/**\n * 刷新所有中心资产库数据\n */\nexport function useRefreshGlobalAssets() {\n    const queryClient = useQueryClient()\n\n    return () => {\n        queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.all() })\n    }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useProjectAssets.ts",
    "content": "'use client'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { apiFetch } from '@/lib/api-fetch'\n\nimport { useMemo } from 'react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { useTaskTargetStateMap } from './useTaskTargetStateMap'\nimport type { Character, Location } from '@/types/project'\n\n// ============ 类型定义 ============\nexport interface ProjectAssetsData {\n    characters: Character[]\n    locations: Location[]\n}\n\nconst CHARACTER_TASK_TYPES = ['image_character', 'modify_asset_image', 'regenerate_group']\nconst CHARACTER_PROFILE_TASK_TYPES = ['character_profile_confirm', 'character_profile_batch_confirm']\nconst LOCATION_TASK_TYPES = ['image_location', 'modify_asset_image', 'regenerate_group']\n\nfunction isRunningPhase(phase: string | null | undefined) {\n    return phase === 'queued' || phase === 'processing'\n}\n\n// ============ 查询 Hooks ============\n\n/**\n * 获取项目资产（角色 + 场景）\n */\nexport function useProjectAssets(projectId: string | null) {\n    const assetsQuery = useQuery({\n        queryKey: queryKeys.projectAssets.all(projectId || ''),\n        queryFn: async () => {\n            if (!projectId) throw new Error('Project ID is required')\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/assets`)\n            if (!res.ok) throw new Error('Failed to fetch project assets')\n            const data = await res.json()\n            return data as ProjectAssetsData\n        },\n        enabled: !!projectId,\n        staleTime: 5000,\n    })\n\n    const taskTargets = useMemo(() => {\n        const assets = assetsQuery.data\n        if (!assets) return []\n\n        const targets: Array<{ targetType: string; targetId: string; types: string[] }> = []\n\n        for (const character of assets.characters || []) {\n            targets.push({\n                targetType: 'CharacterAppearance',\n                targetId: character.id,\n                types: CHARACTER_TASK_TYPES,\n            })\n            // 🔥 注册角色档案确认任务的跟踪（使 profileConfirmTaskRunning 在刷新后仍可恢复）\n            targets.push({\n                targetType: 'NovelPromotionCharacter',\n                targetId: character.id,\n                types: CHARACTER_PROFILE_TASK_TYPES,\n            })\n            for (const appearance of character.appearances || []) {\n                targets.push({\n                    targetType: 'CharacterAppearance',\n                    targetId: appearance.id,\n                    types: CHARACTER_TASK_TYPES,\n                })\n            }\n        }\n\n        for (const location of assets.locations || []) {\n            targets.push({\n                targetType: 'LocationImage',\n                targetId: location.id,\n                types: LOCATION_TASK_TYPES,\n            })\n            for (const image of location.images || []) {\n                targets.push({\n                    targetType: 'LocationImage',\n                    targetId: image.id,\n                    types: LOCATION_TASK_TYPES,\n                })\n            }\n        }\n\n        return targets\n    }, [assetsQuery.data])\n\n    const taskStatesQuery = useTaskTargetStateMap(projectId, taskTargets, {\n        enabled: !!projectId && taskTargets.length > 0,\n    })\n\n    const data = useMemo(() => {\n        const assets = assetsQuery.data\n        if (!assets) return assets\n        const byKey = taskStatesQuery.byKey\n        const getState = (targetType: string, targetId: string) =>\n            byKey.get(`${targetType}:${targetId}`) || null\n\n        return {\n            ...assets,\n            characters: (assets.characters || []).map((character) => {\n                const characterState = getState('CharacterAppearance', character.id)\n                // 🔥 获取角色档案确认任务状态\n                const profileState = getState('NovelPromotionCharacter', character.id)\n                return {\n                    ...character,\n                    profileConfirmTaskRunning: isRunningPhase(profileState?.phase),\n                    appearances: (character.appearances || []).map((appearance) => {\n                        const appearanceState = getState('CharacterAppearance', appearance.id)\n                        const lastError = appearanceState?.lastError\n                            || characterState?.lastError\n                            || null\n                        return {\n                            ...appearance,\n                            imageTaskRunning:\n                                isRunningPhase(appearanceState?.phase) ||\n                                isRunningPhase(characterState?.phase),\n                            lastError,\n                        }\n                    }),\n                }\n            }),\n            locations: (assets.locations || []).map((location) => {\n                const locationState = getState('LocationImage', location.id)\n                return {\n                    ...location,\n                    images: (location.images || []).map((image) => {\n                        const imageState = getState('LocationImage', image.id)\n                        const lastError = imageState?.lastError\n                            || locationState?.lastError\n                            || null\n                        return {\n                            ...image,\n                            imageTaskRunning:\n                                isRunningPhase(imageState?.phase) ||\n                                isRunningPhase(locationState?.phase),\n                            lastError,\n                        }\n                    }),\n                }\n            }),\n        } as ProjectAssetsData\n    }, [assetsQuery.data, taskStatesQuery.byKey])\n\n    return {\n        ...assetsQuery,\n        data,\n        isFetching: assetsQuery.isFetching || taskStatesQuery.isFetching,\n    }\n}\n\n/**\n * 获取项目角色\n */\nexport function useProjectCharacters(projectId: string | null) {\n    return useQuery({\n        queryKey: queryKeys.projectAssets.characters(projectId || ''),\n        queryFn: async () => {\n            if (!projectId) throw new Error('Project ID is required')\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/characters`)\n            if (!res.ok) throw new Error('Failed to fetch characters')\n            const data = await res.json()\n            return data.characters as Character[]\n        },\n        enabled: !!projectId,\n    })\n}\n\n/**\n * 获取项目场景\n */\nexport function useProjectLocations(projectId: string | null) {\n    return useQuery({\n        queryKey: queryKeys.projectAssets.locations(projectId || ''),\n        queryFn: async () => {\n            if (!projectId) throw new Error('Project ID is required')\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/locations`)\n            if (!res.ok) throw new Error('Failed to fetch locations')\n            const data = await res.json()\n            return data.locations as Location[]\n        },\n        enabled: !!projectId,\n    })\n}\n\n/**\n * 刷新项目资产\n * 🔥 同时刷新 projectAssets 和 projectData 两个缓存\n *    - projectAssets: 用于直接订阅 useProjectAssets 的组件\n *    - projectData: 用于 NovelPromotionWorkspace（通过 useProjectData 获取 characters/locations）\n */\nexport function useRefreshProjectAssets(projectId: string | null) {\n    const queryClient = useQueryClient()\n\n    return () => {\n        if (projectId) {\n            _ulogInfo('[刷新资产] 同时刷新 projectAssets / projectData / tasks 缓存')\n            queryClient.invalidateQueries({ queryKey: queryKeys.projectAssets.all(projectId) })\n            queryClient.invalidateQueries({ queryKey: queryKeys.projectData(projectId) })\n            queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all(projectId), exact: false })\n        }\n    }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useProjectData.ts",
    "content": "'use client'\n\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { resolveTaskErrorMessage } from '@/lib/task/error-message'\nimport type { Project, MediaRef } from '@/types/project'\nimport { apiFetch } from '@/lib/api-fetch'\n\n// ============ 项目数据 Hook ============\n\ninterface ProjectDataResponse {\n    project: Project\n}\n\n/**\n * 获取项目基础数据\n * 替代原有的 useProject hook\n */\nexport function useProjectData(projectId: string | null) {\n    return useQuery({\n        queryKey: queryKeys.projectData(projectId || ''),\n        queryFn: async () => {\n            if (!projectId) throw new Error('Project ID is required')\n            const res = await apiFetch(`/api/projects/${projectId}/data`)\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to load project'))\n            }\n            const data: ProjectDataResponse = await res.json()\n            return data.project\n        },\n        enabled: !!projectId,\n        staleTime: 5000,\n    })\n}\n\n/**\n * 刷新项目数据\n */\nexport function useRefreshProjectData(projectId: string | null) {\n    const queryClient = useQueryClient()\n\n    return () => {\n        if (projectId) {\n            queryClient.invalidateQueries({ queryKey: queryKeys.projectData(projectId) })\n        }\n    }\n}\n\n// ============ 剧集数据 Hook ============\n\nexport interface Episode {\n    id: string\n    episodeNumber: number\n    name: string\n    description?: string | null\n    novelText?: string | null\n    audioUrl?: string | null\n    media?: MediaRef | null\n    srtContent?: string | null\n    createdAt: string\n    // 剧集详情数据\n    voiceLines?: VoiceLine[]\n    storyboardData?: StoryboardData\n}\n\ninterface VoiceLine {\n    id: string\n    text: string\n    speakerId: string\n    audioUrl?: string | null\n    media?: MediaRef | null\n    lineTaskRunning?: boolean\n}\n\ninterface StoryboardData {\n    panels: unknown[]\n}\n\n/**\n * 获取剧集详情\n */\nexport function useEpisodeData(projectId: string | null, episodeId: string | null) {\n    return useQuery({\n        queryKey: queryKeys.episodeData(projectId || '', episodeId || ''),\n        queryFn: async () => {\n            if (!projectId || !episodeId) throw new Error('Project ID and Episode ID are required')\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/episodes/${episodeId}`)\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to load episode'))\n            }\n            const data = await res.json()\n            return data.episode as Episode\n        },\n        enabled: !!projectId && !!episodeId,\n        staleTime: 5000,\n    })\n}\n\n/**\n * 获取项目的剧集列表（从项目数据中提取）\n */\nexport function useEpisodes(projectId: string | null) {\n    const { data: project } = useProjectData(projectId)\n\n    const episodes = project?.novelPromotionData?.episodes || []\n    return { episodes, isLoading: !project }\n}\n\n/**\n * 刷新剧集数据\n */\nexport function useRefreshEpisodeData(projectId: string | null, episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return () => {\n        if (projectId && episodeId) {\n            queryClient.invalidateQueries({\n                queryKey: queryKeys.episodeData(projectId, episodeId)\n            })\n        }\n    }\n}\n\n/**\n * 刷新所有相关数据（项目 + 当前剧集）\n */\nexport function useRefreshAll(projectId: string | null, episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return () => {\n        if (projectId) {\n            queryClient.invalidateQueries({ queryKey: queryKeys.projectData(projectId) })\n            queryClient.invalidateQueries({ queryKey: queryKeys.projectAssets.all(projectId) })\n        }\n        if (projectId && episodeId) {\n            queryClient.invalidateQueries({\n                queryKey: queryKeys.episodeData(projectId, episodeId)\n            })\n            queryClient.invalidateQueries({\n                queryKey: queryKeys.storyboards.all(episodeId)\n            })\n        }\n    }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useRunStreamState.ts",
    "content": "export * from './run-stream/run-stream-state-runtime'\n"
  },
  {
    "path": "src/lib/query/hooks/useSSE.ts",
    "content": "'use client'\nimport { logError as _ulogError } from '@/lib/logging/core'\n\nimport { useEffect, useMemo, useRef } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types'\nimport { applyTaskLifecycleToOverlay } from '../task-target-overlay'\nimport { isTaskIntent, resolveTaskIntent } from '@/lib/task/intent'\n\ntype UseSSEOptions = {\n  projectId?: string | null\n  episodeId?: string | null\n  enabled?: boolean\n  onEvent?: (event: SSEEvent) => void\n}\n\nexport function useSSE({ projectId, episodeId, enabled = true, onEvent }: UseSSEOptions) {\n  const queryClient = useQueryClient()\n  const sourceRef = useRef<EventSource | null>(null)\n  const targetStatesInvalidateTimerRef = useRef<number | null>(null)\n  const isGlobalAssetProject = projectId === 'global-asset-hub'\n\n  const url = useMemo(() => {\n    if (!projectId) return null\n    const params = new URLSearchParams({ projectId })\n    if (episodeId) params.set('episodeId', episodeId)\n    return `/api/sse?${params}`\n  }, [projectId, episodeId])\n\n  useEffect(() => {\n    if (!enabled || !url || !projectId) return\n\n    const source = new EventSource(url)\n    sourceRef.current = source\n\n    const invalidateEpisodeScoped = (resolvedEpisodeId: string | null) => {\n      if (!resolvedEpisodeId) return\n      queryClient.invalidateQueries({ queryKey: queryKeys.episodeData(projectId, resolvedEpisodeId) })\n      queryClient.invalidateQueries({ queryKey: queryKeys.storyboards.all(resolvedEpisodeId) })\n      queryClient.invalidateQueries({ queryKey: queryKeys.voiceLines.all(resolvedEpisodeId) })\n      queryClient.invalidateQueries({ queryKey: queryKeys.voiceLines.matched(projectId, resolvedEpisodeId) })\n    }\n\n    const invalidateByTarget = (targetType: string | null, resolvedEpisodeId: string | null) => {\n      if (isGlobalAssetProject) {\n        if (targetType?.startsWith('GlobalCharacter')) {\n          queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })\n          return\n        }\n        if (targetType?.startsWith('GlobalLocation')) {\n          queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.locations() })\n          return\n        }\n        if (targetType?.startsWith('GlobalVoice')) {\n          queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.voices() })\n          return\n        }\n        queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.all() })\n        return\n      }\n\n      if (targetType === 'CharacterAppearance' || targetType === 'NovelPromotionCharacter') {\n        queryClient.invalidateQueries({ queryKey: queryKeys.projectAssets.characters(projectId) })\n        queryClient.invalidateQueries({ queryKey: queryKeys.projectAssets.all(projectId) })\n        return\n      }\n      if (targetType === 'LocationImage' || targetType === 'NovelPromotionLocation') {\n        queryClient.invalidateQueries({ queryKey: queryKeys.projectAssets.locations(projectId) })\n        queryClient.invalidateQueries({ queryKey: queryKeys.projectAssets.all(projectId) })\n        return\n      }\n      if (targetType === 'NovelPromotionVoiceLine') {\n        invalidateEpisodeScoped(resolvedEpisodeId)\n        return\n      }\n      if (\n        targetType === 'NovelPromotionPanel' ||\n        targetType === 'NovelPromotionStoryboard' ||\n        targetType === 'NovelPromotionShot'\n      ) {\n        invalidateEpisodeScoped(resolvedEpisodeId)\n        return\n      }\n      if (targetType === 'NovelPromotionEpisode') {\n        invalidateEpisodeScoped(resolvedEpisodeId)\n        queryClient.invalidateQueries({ queryKey: queryKeys.projectData(projectId) })\n        return\n      }\n\n      queryClient.invalidateQueries({ queryKey: queryKeys.projectData(projectId) })\n    }\n\n    const handleEvent = (event: MessageEvent) => {\n      try {\n        const payload = JSON.parse(event.data || '{}')\n        if (!payload || !payload.type) return\n        onEvent?.(payload as SSEEvent)\n        const eventType = payload.type as string\n        const targetType = typeof payload.targetType === 'string'\n          ? payload.targetType\n          : typeof payload?.payload?.targetType === 'string'\n            ? payload.payload.targetType\n            : null\n        const targetId = typeof payload.targetId === 'string'\n          ? payload.targetId\n          : typeof payload?.payload?.targetId === 'string'\n            ? payload.payload.targetId\n            : null\n        const eventEpisodeId = typeof payload.episodeId === 'string'\n          ? payload.episodeId\n          : typeof payload?.payload?.episodeId === 'string'\n            ? payload.payload.episodeId\n            : null\n        const resolvedEpisodeId = eventEpisodeId || episodeId || null\n\n        const eventPayload = payload?.payload && typeof payload.payload === 'object'\n          ? (payload.payload as Record<string, unknown>)\n          : null\n        const rawLifecycleType =\n          eventType === TASK_SSE_EVENT_TYPE.LIFECYCLE\n            ? typeof eventPayload?.lifecycleType === 'string'\n              ? eventPayload.lifecycleType\n              : null\n            : null\n        const normalizedLifecycleType =\n          rawLifecycleType === TASK_EVENT_TYPE.PROGRESS\n            ? TASK_EVENT_TYPE.PROCESSING\n            : rawLifecycleType\n        const isLifecycleEvent = eventType === TASK_SSE_EVENT_TYPE.LIFECYCLE\n        const shouldInvalidateTasksList =\n          normalizedLifecycleType === TASK_EVENT_TYPE.CREATED ||\n          normalizedLifecycleType === TASK_EVENT_TYPE.COMPLETED ||\n          normalizedLifecycleType === TASK_EVENT_TYPE.FAILED ||\n          (normalizedLifecycleType === TASK_EVENT_TYPE.PROCESSING &&\n            typeof eventPayload?.progress !== 'number')\n        const shouldInvalidateTargetStates =\n          normalizedLifecycleType === TASK_EVENT_TYPE.COMPLETED ||\n          normalizedLifecycleType === TASK_EVENT_TYPE.FAILED\n\n        if (isLifecycleEvent && shouldInvalidateTasksList) {\n          queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all(projectId) })\n        }\n        if (isLifecycleEvent && shouldInvalidateTargetStates) {\n          if (targetStatesInvalidateTimerRef.current === null) {\n            targetStatesInvalidateTimerRef.current = window.setTimeout(() => {\n              queryClient.invalidateQueries({ queryKey: queryKeys.tasks.targetStatesAll(projectId), exact: false })\n              targetStatesInvalidateTimerRef.current = null\n            }, 800)\n          }\n        }\n\n        const payloadIntent = isTaskIntent(eventPayload?.intent)\n          ? eventPayload.intent\n          : resolveTaskIntent(typeof payload.taskType === 'string' ? payload.taskType : null)\n        const payloadUi =\n          eventPayload?.ui && typeof eventPayload.ui === 'object' && !Array.isArray(eventPayload.ui)\n            ? (eventPayload.ui as Record<string, unknown>)\n            : null\n        const hasOutputAtStart =\n          typeof payloadUi?.hasOutputAtStart === 'boolean'\n            ? payloadUi.hasOutputAtStart\n            : null\n\n        applyTaskLifecycleToOverlay(queryClient, {\n          projectId,\n          lifecycleType: normalizedLifecycleType,\n          targetType,\n          targetId,\n          taskId: typeof payload.taskId === 'string' ? payload.taskId : null,\n          taskType: typeof payload.taskType === 'string' ? payload.taskType : null,\n          intent: payloadIntent,\n          hasOutputAtStart,\n          progress: typeof eventPayload?.progress === 'number' ? Math.floor(eventPayload.progress) : null,\n          stage: typeof eventPayload?.stage === 'string' ? eventPayload.stage : null,\n          stageLabel: typeof eventPayload?.stageLabel === 'string' ? eventPayload.stageLabel : null,\n          eventTs: typeof payload.ts === 'string' ? payload.ts : null,\n        })\n\n        if (\n          normalizedLifecycleType === TASK_EVENT_TYPE.CREATED ||\n          normalizedLifecycleType === TASK_EVENT_TYPE.PROCESSING\n        ) {\n          return\n        }\n\n        if (\n          normalizedLifecycleType === TASK_EVENT_TYPE.COMPLETED ||\n          normalizedLifecycleType === TASK_EVENT_TYPE.FAILED\n        ) {\n          invalidateByTarget(targetType, resolvedEpisodeId)\n        }\n      } catch (error) {\n        _ulogError('[useSSE] failed to parse event', error)\n      }\n    }\n\n    source.onmessage = handleEvent\n    const namedEvents = [\n      TASK_SSE_EVENT_TYPE.LIFECYCLE,\n      TASK_SSE_EVENT_TYPE.STREAM,\n    ] as const\n    const listeners: Array<{ type: string; handler: EventListener }> = []\n    for (const type of namedEvents) {\n      const handler: EventListener = (event) => handleEvent(event as MessageEvent)\n      source.addEventListener(type, handler)\n      listeners.push({ type, handler })\n    }\n    source.onerror = (error) => {\n      _ulogError('[useSSE] stream error', error)\n    }\n\n    return () => {\n      if (targetStatesInvalidateTimerRef.current !== null) {\n        window.clearTimeout(targetStatesInvalidateTimerRef.current)\n        targetStatesInvalidateTimerRef.current = null\n      }\n      for (const listener of listeners) {\n        source.removeEventListener(listener.type, listener.handler)\n      }\n      source.close()\n      sourceRef.current = null\n    }\n  }, [enabled, url, projectId, episodeId, queryClient, isGlobalAssetProject, onEvent])\n\n  return {\n    connected: !!sourceRef.current && sourceRef.current.readyState === EventSource.OPEN,\n  }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useScriptToStoryboardRunStream.ts",
    "content": "'use client'\n\nimport { useRunStreamState, type RunResult } from './useRunStreamState'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { apiFetch } from '@/lib/api-fetch'\n\nexport type ScriptToStoryboardRunParams = {\n  episodeId: string\n  model?: string\n  temperature?: number\n  reasoning?: boolean\n  reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'\n}\n\nexport type ScriptToStoryboardRunResult = RunResult\n\ntype UseScriptToStoryboardRunStreamOptions = {\n  projectId: string\n  episodeId?: string | null\n}\n\nexport function useScriptToStoryboardRunStream({ projectId, episodeId }: UseScriptToStoryboardRunStreamOptions) {\n  return useRunStreamState<ScriptToStoryboardRunParams>({\n    projectId,\n    endpoint: (pid) => `/api/novel-promotion/${pid}/script-to-storyboard-stream`,\n    storageKeyPrefix: 'novel-promotion:script-to-storyboard-run',\n    storageScopeKey: episodeId || undefined,\n    resolveActiveRunId: async ({ projectId: pid, storageScopeKey }) => {\n      if (!storageScopeKey) return null\n      const search = new URLSearchParams({\n        projectId: pid,\n        workflowType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n        targetType: 'NovelPromotionEpisode',\n        targetId: storageScopeKey,\n        episodeId: storageScopeKey,\n        limit: '20',\n      })\n      search.append('status', 'queued')\n      search.append('status', 'running')\n      search.append('status', 'canceling')\n      search.set('_v', '2')\n      const response = await apiFetch(`/api/runs?${search.toString()}`, {\n        method: 'GET',\n        cache: 'no-store',\n      })\n      if (!response.ok) return null\n      const data = await response.json().catch(() => null)\n      const runs = data && typeof data === 'object' && Array.isArray((data as { runs?: unknown[] }).runs)\n        ? (data as { runs: Array<{ id?: unknown; targetType?: unknown; targetId?: unknown; status?: unknown }> }).runs\n        : []\n      for (const run of runs) {\n        if (!run || typeof run.id !== 'string' || !run.id) continue\n        return run.id\n      }\n      return null\n    },\n    validateParams: (params) => {\n      if (!params.episodeId) {\n        throw new Error('episodeId is required')\n      }\n    },\n    buildRequestBody: (params) => ({\n      episodeId: params.episodeId,\n      model: params.model || undefined,\n      temperature: params.temperature,\n      reasoning: params.reasoning,\n      reasoningEffort: params.reasoningEffort,\n      async: true,\n      displayMode: 'detail',\n    }),\n  })\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useStoryToScriptRunStream.ts",
    "content": "'use client'\n\nimport { useRunStreamState, type RunResult } from './useRunStreamState'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { apiFetch } from '@/lib/api-fetch'\n\nexport type StoryToScriptRunParams = {\n  episodeId: string\n  content: string\n  model?: string\n  temperature?: number\n  reasoning?: boolean\n  reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'\n}\n\nexport type StoryToScriptRunResult = RunResult\n\ntype UseStoryToScriptRunStreamOptions = {\n  projectId: string\n  episodeId?: string | null\n}\n\nexport function useStoryToScriptRunStream({ projectId, episodeId }: UseStoryToScriptRunStreamOptions) {\n  return useRunStreamState<StoryToScriptRunParams>({\n    projectId,\n    endpoint: (pid) => `/api/novel-promotion/${pid}/story-to-script-stream`,\n    storageKeyPrefix: 'novel-promotion:story-to-script-run',\n    storageScopeKey: episodeId || undefined,\n    resolveActiveRunId: async ({ projectId: pid, storageScopeKey }) => {\n      if (!storageScopeKey) return null\n      const search = new URLSearchParams({\n        projectId: pid,\n        workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n        targetType: 'NovelPromotionEpisode',\n        targetId: storageScopeKey,\n        episodeId: storageScopeKey,\n        limit: '20',\n      })\n      search.append('status', 'queued')\n      search.append('status', 'running')\n      search.append('status', 'canceling')\n      search.set('_v', '2')\n      const response = await apiFetch(`/api/runs?${search.toString()}`, {\n        method: 'GET',\n        cache: 'no-store',\n      })\n      if (!response.ok) return null\n      const data = await response.json().catch(() => null)\n      const runs = data && typeof data === 'object' && Array.isArray((data as { runs?: unknown[] }).runs)\n        ? (data as { runs: Array<{ id?: unknown; targetType?: unknown; targetId?: unknown; status?: unknown }> }).runs\n        : []\n      for (const run of runs) {\n        if (!run || typeof run.id !== 'string' || !run.id) continue\n        return run.id\n      }\n      return null\n    },\n    validateParams: (params) => {\n      if (!params.episodeId) {\n        throw new Error('episodeId is required')\n      }\n      if (!params.content.trim()) {\n        throw new Error('content is required')\n      }\n    },\n    buildRequestBody: (params) => ({\n      episodeId: params.episodeId,\n      content: params.content,\n      model: params.model || undefined,\n      temperature: params.temperature,\n      reasoning: params.reasoning,\n      reasoningEffort: params.reasoningEffort,\n      async: true,\n      displayMode: 'detail',\n    }),\n  })\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useStoryboards.ts",
    "content": "'use client'\n\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { checkApiResponse } from '@/lib/error-handler'\nimport { resolveTaskErrorMessage } from '@/lib/task/error-message'\nimport { clearTaskTargetOverlay, upsertTaskTargetOverlay } from '../task-target-overlay'\nimport type { MediaRef } from '@/types/project'\nimport { apiFetch } from '@/lib/api-fetch'\n\n// ============ 类型定义 ============\nexport interface PanelCandidate {\n    id: string\n    imageUrl: string | null\n    media?: MediaRef | null\n    isSelected: boolean\n    taskRunning: boolean\n}\n\nexport interface StoryboardPanel {\n    id: string\n    shotId: string\n    stageIndex: number\n    shotIndex: number\n    imageUrl: string | null\n    media?: MediaRef | null\n    motionPrompt: string | null\n    voiceText: string | null\n    voiceUrl: string | null\n    voiceMedia?: MediaRef | null\n    videoUrl: string | null\n    videoGenerationMode?: 'normal' | 'firstlastframe' | null\n    videoMedia?: MediaRef | null\n    imageTaskRunning?: boolean\n    videoTaskRunning?: boolean\n    lipSyncTaskRunning?: boolean\n    errorMessage: string | null\n    candidates: PanelCandidate[]\n    pendingCandidateCount: number\n}\n\nexport interface StoryboardGroup {\n    id: string\n    stageIndex: number\n    panels: StoryboardPanel[]\n}\n\nexport interface StoryboardData {\n    groups: StoryboardGroup[]\n}\n\ntype VideoGenerationOptionValue = string | number | boolean\ntype VideoGenerationOptions = Record<string, VideoGenerationOptionValue>\n\ninterface BatchVideoGenerationParams {\n    videoModel: string\n    generationOptions?: VideoGenerationOptions\n}\n\n// ============ 查询 Hooks ============\n\n/**\n * 获取分镜数据\n */\nexport function useStoryboards(episodeId: string | null) {\n    return useQuery({\n        queryKey: queryKeys.storyboards.all(episodeId || ''),\n        queryFn: async () => {\n            if (!episodeId) throw new Error('Episode ID is required')\n            const res = await apiFetch(`/api/novel-promotion/episodes/${episodeId}/storyboards`)\n            if (!res.ok) throw new Error('Failed to fetch storyboards')\n            const data = await res.json()\n            return data as StoryboardData\n        },\n        enabled: !!episodeId,\n    })\n}\n\n// ============ Mutation Hooks ============\n\n/**\n * 重新生成分镜图片\n */\nexport function useRegeneratePanelImage(projectId: string | null, episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ panelId }: { panelId: string }) => {\n            if (!projectId) throw new Error('Project ID is required')\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/regenerate-panel-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ panelId }),\n            })\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to regenerate'))\n            }\n            return res.json()\n        },\n        onMutate: async () => {\n            if (!projectId) return\n            await queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all(projectId), exact: false })\n        },\n        onSettled: () => {\n            if (episodeId) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.storyboards.all(episodeId) })\n            }\n        },\n    })\n}\n\n/**\n * 修改分镜图片\n */\nexport function useModifyPanelImage(projectId: string | null, episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async (params: {\n            panelId: string\n            modifyPrompt: string\n            extraImageUrls?: string[]\n        }) => {\n            if (!projectId) throw new Error('Project ID is required')\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/modify-panel-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(params),\n            })\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to modify'))\n            }\n            return res.json()\n        },\n        onMutate: async () => {\n            if (!projectId) return\n            await queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all(projectId), exact: false })\n        },\n        onSettled: () => {\n            if (episodeId) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.storyboards.all(episodeId) })\n            }\n        },\n    })\n}\n\n/**\n * 生成视频\n */\nexport function useGenerateVideo(projectId: string | null, episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async (params: {\n            storyboardId: string\n            panelIndex: number\n            panelId?: string\n            videoModel: string\n            generationOptions?: VideoGenerationOptions\n            firstLastFrame?: {\n                lastFrameStoryboardId: string\n                lastFramePanelIndex: number\n                flModel: string\n                customPrompt?: string\n            }\n        }) => {\n            if (!projectId) throw new Error('Project ID is required')\n\n            // 构建请求体\n            const requestBody: {\n                storyboardId: string\n                panelIndex: number\n                firstLastFrame?: {\n                    lastFrameStoryboardId: string\n                    lastFramePanelIndex: number\n                    flModel: string\n                    customPrompt?: string\n                }\n                videoModel: string\n                generationOptions?: VideoGenerationOptions\n            } = {\n                storyboardId: params.storyboardId,\n                panelIndex: params.panelIndex,\n                videoModel: params.videoModel,\n            }\n\n            // 如果是首尾帧模式\n            if (params.firstLastFrame) {\n                requestBody.firstLastFrame = params.firstLastFrame\n            }\n\n            if (params.generationOptions && typeof params.generationOptions === 'object') {\n                requestBody.generationOptions = params.generationOptions\n            }\n\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/generate-video`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(requestBody),\n            })\n            // 🔥 使用统一错误处理\n            await checkApiResponse(res)\n            return res.json()\n        },\n        onMutate: async ({ panelId }) => {\n            if (!projectId) return\n            await queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all(projectId), exact: false })\n            if (!panelId) return\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'NovelPromotionPanel',\n                targetId: panelId,\n                intent: 'generate',\n            })\n        },\n        onError: (_error, { panelId }) => {\n            if (!projectId || !panelId) return\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'NovelPromotionPanel',\n                targetId: panelId,\n            })\n        },\n        onSettled: () => {\n            // 🔥 刷新缓存获取最新状态\n            if (episodeId && projectId) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.episodeData(projectId, episodeId) })\n            }\n        },\n    })\n}\n\n/**\n * 批量生成视频\n *\n * 后端为每个需要生成的 panel 创建独立的 Panel 级任务，\n * 与单个生成走完全相同的 SSE → overlay → UI 流程。\n */\nexport function useBatchGenerateVideos(projectId: string | null, episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async (params: BatchVideoGenerationParams) => {\n            if (!projectId) throw new Error('Project ID is required')\n            if (!episodeId) throw new Error('Episode ID is required')\n\n            const requestBody: {\n                all: boolean\n                episodeId: string\n                videoModel: string\n                generationOptions?: VideoGenerationOptions\n            } = {\n                all: true,\n                episodeId,\n                videoModel: params.videoModel,\n            }\n            if (params.generationOptions && typeof params.generationOptions === 'object') {\n                requestBody.generationOptions = params.generationOptions\n            }\n\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/generate-video`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(requestBody),\n            })\n            // 🔥 使用统一错误处理\n            await checkApiResponse(res)\n            return res.json()\n        },\n        onMutate: async () => {\n            if (!projectId) return\n            await queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all(projectId), exact: false })\n        },\n        onSettled: () => {\n            // 🔥 刷新缓存获取最新状态\n            if (episodeId && projectId) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.episodeData(projectId, episodeId) })\n            }\n        },\n    })\n}\n\n/**\n * 选择分镜候选图\n */\nexport function useSelectPanelCandidate(episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ panelId, candidateId }: { panelId: string; candidateId: string }) => {\n            const res = await apiFetch(`/api/novel-promotion/panels/${panelId}/select-candidate`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ candidateId }),\n            })\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to select candidate'))\n            }\n            return res.json()\n        },\n        onSettled: () => {\n            if (episodeId) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.storyboards.all(episodeId) })\n            }\n        },\n    })\n}\n\n/**\n * 刷新分镜数据\n */\nexport function useRefreshStoryboards(episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return () => {\n        if (episodeId) {\n            queryClient.invalidateQueries({ queryKey: queryKeys.storyboards.all(episodeId) })\n        }\n    }\n}\n\n/**\n * 🔥 口型同步生成（乐观更新）\n */\nexport function useLipSync(projectId: string | null, episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async (params: {\n            storyboardId: string\n            panelIndex: number\n            voiceLineId: string\n            panelId?: string\n        }) => {\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/lip-sync`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    storyboardId: params.storyboardId,\n                    panelIndex: params.panelIndex,\n                    voiceLineId: params.voiceLineId\n                })\n            })\n\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Lip sync failed'))\n            }\n\n            return res.json()\n        },\n        onMutate: async ({ panelId }) => {\n            if (!projectId) return\n            await queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all(projectId), exact: false })\n            if (!panelId) return\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'NovelPromotionPanel',\n                targetId: panelId,\n                intent: 'generate',\n            })\n        },\n        onError: (_error, { panelId }) => {\n            if (!projectId || !panelId) return\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'NovelPromotionPanel',\n                targetId: panelId,\n            })\n        },\n        onSettled: () => {\n            // 请求完成后刷新数据\n            if (projectId && episodeId) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.episodeData(projectId, episodeId) })\n            }\n        }\n    })\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useTaskPresentation.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport {\n  useTaskTargetStateMap,\n  type TaskTargetState,\n  type TaskTargetStateQuery,\n} from './useTaskTargetStateMap'\nimport {\n  resolveTaskPresentationState,\n  type TaskPresentationResource,\n  type TaskPresentationState,\n} from '@/lib/task/presentation'\n\nexport type TaskPresentationTarget = {\n  key: string\n  targetType: string\n  targetId: string\n  types?: string[]\n  resource: TaskPresentationResource\n  hasOutput: boolean\n}\n\ntype TaskPresentationOptions =\n  | boolean\n  | {\n      enabled?: boolean\n      staleTime?: number\n    }\n  | undefined\n\ntype TaskPresentationResult = {\n  statesByKey: Map<string, TaskPresentationState>\n  taskStatesByKey: Map<string, TaskTargetState>\n  getState: (key: string) => TaskPresentationState | null\n  getTaskState: (key: string) => TaskTargetState | null\n  isFetching: boolean\n}\n\nfunction useTaskPresentationInternal(\n  projectId: string | null | undefined,\n  targets: TaskPresentationTarget[],\n  options: TaskPresentationOptions = true,\n): TaskPresentationResult {\n  const resolvedOptions = typeof options === 'boolean'\n    ? { enabled: options }\n    : (options || {})\n  const enabled = resolvedOptions.enabled ?? true\n\n  const targetQueries = useMemo<TaskTargetStateQuery[]>(\n    () =>\n      targets.map((target) => ({\n        targetType: target.targetType,\n        targetId: target.targetId,\n        ...(target.types && target.types.length > 0 ? { types: target.types } : {}),\n      })),\n    [targets],\n  )\n\n  const taskStates = useTaskTargetStateMap(projectId, targetQueries, {\n    enabled: enabled && !!projectId && targetQueries.length > 0,\n    staleTime: resolvedOptions.staleTime,\n  })\n\n  const taskStatesByKey = useMemo(() => {\n    const map = new Map<string, TaskTargetState>()\n    for (const target of targets) {\n      const state = taskStates.byKey.get(`${target.targetType}:${target.targetId}`) || null\n      if (!state) continue\n      map.set(target.key, state)\n    }\n    return map\n  }, [targets, taskStates.byKey])\n\n  const statesByKey = useMemo(() => {\n    const map = new Map<string, TaskPresentationState>()\n    for (const target of targets) {\n      const state = taskStatesByKey.get(target.key)\n      if (!state) continue\n      map.set(\n        target.key,\n        resolveTaskPresentationState({\n          phase: state.phase,\n          intent: state.intent,\n          resource: target.resource,\n          hasOutput: target.hasOutput || !!state.hasOutputAtStart,\n        }),\n      )\n    }\n    return map\n  }, [targets, taskStatesByKey])\n\n  const getState = useMemo(\n    () => (key: string) => statesByKey.get(key) || null,\n    [statesByKey],\n  )\n  const getTaskState = useMemo(\n    () => (key: string) => taskStatesByKey.get(key) || null,\n    [taskStatesByKey],\n  )\n\n  return useMemo(\n    () => ({\n      statesByKey,\n      taskStatesByKey,\n      getState,\n      getTaskState,\n      isFetching: taskStates.isFetching,\n    }),\n    [statesByKey, taskStatesByKey, getState, getTaskState, taskStates.isFetching],\n  )\n}\n\nexport function useAssetTaskPresentation(\n  projectId: string | null | undefined,\n  targets: TaskPresentationTarget[],\n  options: TaskPresentationOptions = true,\n) {\n  return useTaskPresentationInternal(projectId, targets, options)\n}\n\nexport function useStoryboardTaskPresentation(\n  projectId: string | null | undefined,\n  targets: TaskPresentationTarget[],\n  options: TaskPresentationOptions = true,\n) {\n  return useTaskPresentationInternal(projectId, targets, options)\n}\n\nexport function useVideoTaskPresentation(\n  projectId: string | null | undefined,\n  targets: TaskPresentationTarget[],\n  options: TaskPresentationOptions = true,\n) {\n  return useTaskPresentationInternal(projectId, targets, options)\n}\n\nexport function useVoiceTaskPresentation(\n  projectId: string | null | undefined,\n  targets: TaskPresentationTarget[],\n  options: TaskPresentationOptions = true,\n) {\n  return useTaskPresentationInternal(projectId, targets, options)\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useTaskStatus.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { useQuery } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { apiFetch } from '@/lib/api-fetch'\n\nexport type TaskItem = {\n  id: string\n  type: string\n  targetType: string\n  targetId: string\n  episodeId?: string | null\n  status: string\n  progress?: number | null\n  errorCode?: string | null\n  errorMessage?: string | null\n  error?: {\n    code: string\n    message: string\n    retryable: boolean\n    category: string\n    userMessageKey: string\n    details?: Record<string, unknown> | null\n  } | null\n  createdAt: string\n  updatedAt: string\n}\n\nconst ACTIVE_STATUS = ['queued', 'processing'] as const\nconst SNAPSHOT_STATUS = ['queued', 'processing', 'completed', 'failed'] as const\n\nfunction buildTaskSearch(params: {\n  projectId: string\n  targetType?: string\n  targetId?: string\n  type?: string[]\n  statuses: readonly string[]\n  limit?: number\n}) {\n  const search = new URLSearchParams()\n  search.set('projectId', params.projectId)\n  if (params.targetType) search.set('targetType', params.targetType)\n  if (params.targetId) search.set('targetId', params.targetId)\n  for (const status of params.statuses) {\n    search.append('status', status)\n  }\n  if (typeof params.limit === 'number') {\n    search.set('limit', String(params.limit))\n  }\n  for (const taskType of params.type || []) {\n    search.append('type', taskType)\n  }\n  return search\n}\n\nexport function useTaskList(params: {\n  projectId?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  type?: string[]\n  statuses?: string[]\n  limit?: number\n  enabled?: boolean\n}) {\n  const enabled = (params.enabled ?? true) && !!params.projectId\n  const statusKey = (params.statuses || []).slice().sort().join(',')\n  const typeKey = (params.type || []).slice().sort().join(',')\n  const queryKey = [\n    ...queryKeys.tasks.all(params.projectId || ''),\n    params.targetType || '',\n    params.targetId || '',\n    statusKey,\n    typeKey,\n    params.limit ?? '',\n  ] as const\n\n  return useQuery({\n    queryKey,\n    enabled,\n    staleTime: 5000,\n    queryFn: async () => {\n      const search = buildTaskSearch({\n        projectId: params.projectId!,\n        targetType: params.targetType || undefined,\n        targetId: params.targetId || undefined,\n        type: params.type,\n        statuses: (params.statuses || SNAPSHOT_STATUS),\n        limit: params.limit,\n      })\n      const res = await apiFetch(`/api/tasks?${search}`)\n      if (!res.ok) throw new Error('Failed to fetch tasks')\n      const data = await res.json()\n      return (data.tasks || []) as TaskItem[]\n    },\n  })\n}\n\nexport function useActiveTasks(params: {\n  projectId?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  type?: string[]\n  enabled?: boolean\n}) {\n  const enabled = (params.enabled ?? true) && !!params.projectId\n  const typeKey = (params.type || []).slice().sort().join(',')\n  const queryKey = params.targetType && params.targetId\n    ? [...queryKeys.tasks.target(params.projectId || '', params.targetType, params.targetId), typeKey] as const\n    : [...queryKeys.tasks.all(params.projectId || ''), typeKey] as const\n\n  return useQuery({\n    queryKey,\n    enabled,\n    staleTime: 5000,\n    queryFn: async () => {\n      const search = buildTaskSearch({\n        projectId: params.projectId!,\n        targetType: params.targetType || undefined,\n        targetId: params.targetId || undefined,\n        type: params.type,\n        statuses: ACTIVE_STATUS,\n      })\n      const res = await apiFetch(`/api/tasks?${search}`)\n      if (!res.ok) throw new Error('Failed to fetch active tasks')\n      const data = await res.json()\n      return (data.tasks || []) as TaskItem[]\n    },\n  })\n}\n\nexport function useTaskSnapshot(params: {\n  projectId?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  enabled?: boolean\n  type?: string[]\n}) {\n  const enabled = (params.enabled ?? true) && !!params.projectId && !!params.targetType && !!params.targetId\n  const typeKey = (params.type || []).slice().sort().join(',')\n\n  return useQuery({\n    queryKey: queryKeys.tasks.snapshot(params.projectId || '', params.targetType || '', params.targetId || '', typeKey),\n    enabled,\n    staleTime: 5000,\n    queryFn: async () => {\n      const search = buildTaskSearch({\n        projectId: params.projectId!,\n        targetType: params.targetType || undefined,\n        targetId: params.targetId || undefined,\n        type: params.type,\n        statuses: SNAPSHOT_STATUS,\n        limit: 1,\n      })\n      const res = await apiFetch(`/api/tasks?${search}`)\n      if (!res.ok) throw new Error('Failed to fetch task snapshot')\n      const data = await res.json()\n      const tasks = (data.tasks || []) as TaskItem[]\n      return tasks[0] || null\n    },\n  })\n}\n\nexport function useTaskStatus(params: {\n  projectId?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  enabled?: boolean\n  type?: string[]\n}) {\n  const query = useActiveTasks({\n    projectId: params.projectId,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    enabled: params.enabled,\n    type: params.type,\n  })\n  const snapshotQuery = useTaskSnapshot({\n    projectId: params.projectId,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    enabled: params.enabled,\n    type: params.type,\n  })\n\n  const data = useMemo(() => {\n    const tasks = query.data || []\n    const latest = snapshotQuery.data || tasks[0] || null\n    const lastFailed = latest?.status === 'failed' || latest?.status === 'canceled'\n      ? (latest.error || null)\n      : null\n    return {\n      active: tasks,\n      hasActive: tasks.length > 0,\n      latest,\n      lastFailed,\n      lastTerminal: lastFailed,\n      // Backward compatibility: keep lastError but only represent FAILED.\n      lastError: lastFailed,\n    }\n  }, [query.data, snapshotQuery.data])\n\n  return {\n    ...query,\n    isFetching: query.isFetching || snapshotQuery.isFetching,\n    isError: query.isError || snapshotQuery.isError,\n    error: query.error || snapshotQuery.error,\n    data,\n  }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useTaskTargetStateMap.ts",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { useQuery } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport type { TaskIntent } from '@/lib/task/intent'\nimport type { TaskTargetOverlayMap } from '../task-target-overlay'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport { apiFetch } from '@/lib/api-fetch'\n\nexport type TaskTargetStateQuery = {\n  targetType: string\n  targetId: string\n  types?: string[]\n}\n\nexport type TaskTargetState = {\n  targetType: string\n  targetId: string\n  phase: 'idle' | 'queued' | 'processing' | 'completed' | 'failed'\n  runningTaskId: string | null\n  runningTaskType: string | null\n  intent: TaskIntent\n  hasOutputAtStart: boolean | null\n  progress: number | null\n  stage: string | null\n  stageLabel: string | null\n  lastError: {\n    code: string\n    message: string\n  } | null\n  updatedAt: string | null\n}\n\ntype TaskTargetStateBatchSubscriber = {\n  targets: TaskTargetStateQuery[]\n  resolve: (states: TaskTargetState[]) => void\n  reject: (error: unknown) => void\n}\n\ntype TaskTargetStateBatch = {\n  targetsByKey: Map<string, TaskTargetStateQuery>\n  subscribers: TaskTargetStateBatchSubscriber[]\n  timer: ReturnType<typeof setTimeout> | null\n}\n\nconst TARGET_STATE_BATCH_WINDOW_MS = 120\nconst TARGET_STATE_CHUNK_SIZE = 500\nconst pendingTaskTargetStateBatches = new Map<string, TaskTargetStateBatch>()\nconst mergeTraceSignatureByKey = new Map<string, string>()\nconst taskTargetStateLogger = createScopedLogger({\n  module: 'query.use-task-target-state-map',\n})\n\nfunction traceFrontend(event: string, details: Record<string, unknown>) {\n  if (typeof window === 'undefined') return\n  console.info(`[FE_TASK_TRACE] ${event}`, details)\n}\n\nfunction stateKey(targetType: string, targetId: string) {\n  return `${targetType}:${targetId}`\n}\n\nfunction targetQueryKey(target: TaskTargetStateQuery) {\n  const types = (target.types || []).filter(Boolean).sort()\n  return `${target.targetType}:${target.targetId}:${types.join(',')}`\n}\n\nfunction normalizeTargets(targets: TaskTargetStateQuery[]) {\n  const deduped = new Map<string, TaskTargetStateQuery>()\n  for (const target of targets) {\n    if (!target.targetType || !target.targetId) continue\n    const types = (target.types || []).filter(Boolean).sort()\n    const key = `${target.targetType}:${target.targetId}:${types.join(',')}`\n    deduped.set(key, {\n      targetType: target.targetType,\n      targetId: target.targetId,\n      ...(types.length ? { types } : {}),\n    })\n  }\n  return Array.from(deduped.values()).sort((a, b) => {\n    const aTypes = (a.types || []).join(',')\n    const bTypes = (b.types || []).join(',')\n    if (a.targetType !== b.targetType) return a.targetType.localeCompare(b.targetType)\n    if (a.targetId !== b.targetId) return a.targetId.localeCompare(b.targetId)\n    return aTypes.localeCompare(bTypes)\n  })\n}\n\nfunction buildIdleState(target: TaskTargetStateQuery): TaskTargetState {\n  return {\n    targetType: target.targetType,\n    targetId: target.targetId,\n    phase: 'idle',\n    runningTaskId: null,\n    runningTaskType: null,\n    intent: 'process',\n    hasOutputAtStart: null,\n    progress: null,\n    stage: null,\n    stageLabel: null,\n    lastError: null,\n    updatedAt: null,\n  }\n}\n\nfunction matchesTaskTypeWhitelist(\n  whitelist: string[] | undefined,\n  runningTaskType: string | null,\n): boolean {\n  if (!whitelist || whitelist.length === 0) return true\n  if (!runningTaskType) return true\n  const normalized = runningTaskType.toLowerCase()\n  return whitelist.some((type) => type.toLowerCase() === normalized)\n}\n\nfunction shouldTraceMergeTarget(targetType: string) {\n  return targetType === 'NovelPromotionPanel'\n}\n\nfunction logMergeDecision(params: {\n  projectId: string | null | undefined\n  key: string\n  decision:\n  | 'overlay_applied'\n  | 'overlay_expired'\n  | 'overlay_phase_ignored'\n  | 'overlay_task_type_mismatch'\n  | 'server_processing_authoritative'\n  runtimePhase: string | null\n  runtimeTaskId: string | null\n  runtimeTaskType: string | null\n  currentPhase: string | null\n  whitelist: string[]\n}) {\n  const signature = [\n    params.decision,\n    params.runtimePhase || '',\n    params.runtimeTaskId || '',\n    params.runtimeTaskType || '',\n    params.currentPhase || '',\n    params.whitelist.join(','),\n  ].join('|')\n  const last = mergeTraceSignatureByKey.get(params.key)\n  if (last === signature) return\n  mergeTraceSignatureByKey.set(params.key, signature)\n  taskTargetStateLogger.info({\n    action: 'task-state.merge.decision',\n    message: 'task state merge decision',\n    details: {\n      projectId: params.projectId || null,\n      key: params.key,\n      decision: params.decision,\n      runtimePhase: params.runtimePhase,\n      runtimeTaskId: params.runtimeTaskId,\n      runtimeTaskType: params.runtimeTaskType,\n      currentPhase: params.currentPhase,\n      whitelist: params.whitelist,\n    },\n  })\n  traceFrontend('task-state.merge.decision', {\n    projectId: params.projectId || null,\n    key: params.key,\n    decision: params.decision,\n    runtimePhase: params.runtimePhase,\n    runtimeTaskId: params.runtimeTaskId,\n    runtimeTaskType: params.runtimeTaskType,\n    currentPhase: params.currentPhase,\n    whitelist: params.whitelist,\n  })\n}\n\n/** 将数组分成固定大小的块 */\nfunction chunkArray<T>(arr: T[], size: number): T[][] {\n  const chunks: T[][] = []\n  for (let i = 0; i < arr.length; i += size) {\n    chunks.push(arr.slice(i, i + size))\n  }\n  return chunks\n}\n\n/** 发送单个 targets 请求（targets 长度必须 <= TARGET_STATE_CHUNK_SIZE） */\nasync function fetchTargetStatesChunk(\n  projectId: string,\n  targets: TaskTargetStateQuery[],\n): Promise<TaskTargetState[]> {\n  const response = await apiFetch('/api/task-target-states', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ projectId, targets }),\n  })\n  if (!response.ok) {\n    throw new Error('Failed to fetch task target states')\n  }\n  const payload = await response.json()\n  return (payload?.states || []) as TaskTargetState[]\n}\n\nasync function flushTaskTargetStateBatch(projectId: string) {\n  const batch = pendingTaskTargetStateBatches.get(projectId)\n  if (!batch) return\n\n  pendingTaskTargetStateBatches.delete(projectId)\n  const mergedTargets = Array.from(batch.targetsByKey.values())\n  const subscribers = batch.subscribers.slice()\n\n  try {\n    // 将 targets 按 TARGET_STATE_CHUNK_SIZE 分片，并行请求\n    const chunks = chunkArray(mergedTargets, TARGET_STATE_CHUNK_SIZE)\n    const chunkResults = await Promise.all(\n      chunks.map((chunk) => fetchTargetStatesChunk(projectId, chunk)),\n    )\n\n    // 合并所有分片的结果到统一索引\n    // 用 targetQueryKey（含 types）做精确索引，避免同一 (targetType, targetId)\n    // 的不同 types 的状态互相覆盖（例如 image 的 processing 被 lip_sync 的 idle 覆盖）\n    const byTargetQueryKey = new Map<string, TaskTargetState>()\n    for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {\n      const chunkTargets = chunks[chunkIdx]\n      const chunkStates = chunkResults[chunkIdx]\n      for (let i = 0; i < chunkTargets.length && i < chunkStates.length; i++) {\n        byTargetQueryKey.set(targetQueryKey(chunkTargets[i]), chunkStates[i])\n      }\n    }\n\n    for (const subscriber of subscribers) {\n      const subset: TaskTargetState[] = []\n      for (const target of subscriber.targets) {\n        const state = byTargetQueryKey.get(targetQueryKey(target))\n        if (state) subset.push(state)\n      }\n      subscriber.resolve(subset)\n    }\n  } catch (error) {\n    for (const subscriber of subscribers) {\n      subscriber.reject(error)\n    }\n  }\n}\n\nfunction fetchTaskTargetStatesBatched(\n  projectId: string,\n  targets: TaskTargetStateQuery[],\n) {\n  return new Promise<TaskTargetState[]>((resolve, reject) => {\n    const batchKey = projectId\n    let batch = pendingTaskTargetStateBatches.get(batchKey)\n    if (!batch) {\n      batch = {\n        targetsByKey: new Map<string, TaskTargetStateQuery>(),\n        subscribers: [],\n        timer: null,\n      }\n      pendingTaskTargetStateBatches.set(batchKey, batch)\n    }\n\n    for (const target of targets) {\n      batch.targetsByKey.set(targetQueryKey(target), target)\n    }\n    batch.subscribers.push({\n      targets,\n      resolve,\n      reject,\n    })\n\n    if (!batch.timer) {\n      batch.timer = setTimeout(() => {\n        void flushTaskTargetStateBatch(batchKey)\n      }, TARGET_STATE_BATCH_WINDOW_MS)\n    }\n  })\n}\n\nexport function useTaskTargetStateMap(\n  projectId: string | null | undefined,\n  targets: TaskTargetStateQuery[],\n  options: {\n    enabled?: boolean\n    staleTime?: number\n  } = {},\n) {\n  const normalizedTargets = useMemo(() => normalizeTargets(targets), [targets])\n  const serializedTargets = useMemo(\n    () => JSON.stringify(normalizedTargets),\n    [normalizedTargets],\n  )\n  const enabled = (options.enabled ?? true) && !!projectId && normalizedTargets.length > 0\n\n  const query = useQuery({\n    queryKey: queryKeys.tasks.targetStates(projectId || '', serializedTargets),\n    enabled,\n    staleTime: options.staleTime ?? 15000,\n    refetchInterval: (state) => {\n      const data = state.state.data as TaskTargetState[] | undefined\n      if (!data) return false\n      return data.some((item) => item.phase === 'queued' || item.phase === 'processing') ? 2000 : false\n    },\n    refetchOnMount: false,\n    refetchOnWindowFocus: true,\n    refetchOnReconnect: true,\n    queryFn: async () => {\n      return fetchTaskTargetStatesBatched(projectId || '', normalizedTargets)\n    },\n  })\n\n  const overlayQuery = useQuery<TaskTargetOverlayMap>({\n    queryKey: queryKeys.tasks.targetStateOverlay(projectId || ''),\n    enabled: false,\n    initialData: {},\n    queryFn: async () => ({}),\n  })\n\n  const mergedByKey = useMemo(() => {\n    const map = new Map<string, TaskTargetState>()\n    for (const state of query.data || []) {\n      map.set(stateKey(state.targetType, state.targetId), state)\n    }\n\n    const overlay = overlayQuery.data || {}\n    const now = Date.now()\n    for (const target of normalizedTargets) {\n      const key = stateKey(target.targetType, target.targetId)\n      const runtime = overlay[key]\n      if (!runtime) continue\n      if (runtime.expiresAt && runtime.expiresAt <= now) {\n        if (shouldTraceMergeTarget(target.targetType)) {\n          logMergeDecision({\n            projectId,\n            key,\n            decision: 'overlay_expired',\n            runtimePhase: runtime.phase,\n            runtimeTaskId: runtime.runningTaskId,\n            runtimeTaskType: runtime.runningTaskType,\n            currentPhase: map.get(key)?.phase || null,\n            whitelist: target.types || [],\n          })\n        }\n        continue\n      }\n      if (runtime.phase !== 'queued' && runtime.phase !== 'processing') {\n        if (shouldTraceMergeTarget(target.targetType)) {\n          logMergeDecision({\n            projectId,\n            key,\n            decision: 'overlay_phase_ignored',\n            runtimePhase: runtime.phase,\n            runtimeTaskId: runtime.runningTaskId,\n            runtimeTaskType: runtime.runningTaskType,\n            currentPhase: map.get(key)?.phase || null,\n            whitelist: target.types || [],\n          })\n        }\n        continue\n      }\n      // Skip overlay if the target has a types whitelist and the task type doesn't match\n      if (!matchesTaskTypeWhitelist(target.types, runtime.runningTaskType)) {\n        if (shouldTraceMergeTarget(target.targetType)) {\n          logMergeDecision({\n            projectId,\n            key,\n            decision: 'overlay_task_type_mismatch',\n            runtimePhase: runtime.phase,\n            runtimeTaskId: runtime.runningTaskId,\n            runtimeTaskType: runtime.runningTaskType,\n            currentPhase: map.get(key)?.phase || null,\n            whitelist: target.types || [],\n          })\n        }\n        continue\n      }\n\n      const current = map.get(key)\n      if (current) {\n        // Server-side processing state is authoritative.\n        if (current.phase === 'processing') {\n          if (shouldTraceMergeTarget(target.targetType)) {\n            logMergeDecision({\n              projectId,\n              key,\n              decision: 'server_processing_authoritative',\n              runtimePhase: runtime.phase,\n              runtimeTaskId: runtime.runningTaskId,\n              runtimeTaskType: runtime.runningTaskType,\n              currentPhase: current.phase,\n              whitelist: target.types || [],\n            })\n          }\n          continue\n        }\n      }\n      map.set(key, {\n        ...(current || buildIdleState(target)),\n        ...runtime,\n        phase: runtime.phase,\n        targetType: target.targetType,\n        targetId: target.targetId,\n        lastError: null,\n      })\n      if (shouldTraceMergeTarget(target.targetType)) {\n        logMergeDecision({\n          projectId,\n          key,\n          decision: 'overlay_applied',\n          runtimePhase: runtime.phase,\n          runtimeTaskId: runtime.runningTaskId,\n          runtimeTaskType: runtime.runningTaskType,\n          currentPhase: current?.phase || null,\n          whitelist: target.types || [],\n        })\n      }\n    }\n    return map\n  }, [normalizedTargets, overlayQuery.data, query.data])\n\n  const mergedData = useMemo(() => {\n    return normalizedTargets.map((target) =>\n      mergedByKey.get(stateKey(target.targetType, target.targetId)) || buildIdleState(target),\n    )\n  }, [mergedByKey, normalizedTargets])\n\n  const byKey = useMemo(() => {\n    const map = new Map<string, TaskTargetState>()\n    for (const state of mergedData) {\n      map.set(stateKey(state.targetType, state.targetId), state)\n    }\n    return map\n  }, [mergedData])\n\n  const getState = useMemo(() => {\n    return (targetType: string, targetId: string) =>\n      byKey.get(stateKey(targetType, targetId)) || null\n  }, [byKey])\n\n  return {\n    ...query,\n    data: mergedData,\n    byKey,\n    getState,\n  }\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useUserModels.ts",
    "content": "'use client'\n\nimport { useQuery } from '@tanstack/react-query'\nimport type { ModelCapabilities } from '@/lib/model-config-contract'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\nimport { queryKeys } from '../keys'\nimport { apiFetch } from '@/lib/api-fetch'\n\nexport interface UserModelOption {\n    value: string\n    label: string\n    provider?: string\n    providerName?: string\n    capabilities?: ModelCapabilities\n    videoPricingTiers?: VideoPricingTier[]\n}\n\nexport interface UserModelsPayload {\n    llm: UserModelOption[]\n    image: UserModelOption[]\n    video: UserModelOption[]\n    audio: UserModelOption[]\n    lipsync: UserModelOption[]\n}\n\nexport function useUserModels() {\n    return useQuery({\n        queryKey: queryKeys.userModels.all(),\n        queryFn: async () => {\n            const response = await apiFetch('/api/user/models')\n            if (!response.ok) {\n                throw new Error('Failed to fetch user models')\n            }\n            const data = await response.json()\n            return {\n                llm: Array.isArray(data?.llm) ? data.llm : [],\n                image: Array.isArray(data?.image) ? data.image : [],\n                video: Array.isArray(data?.video) ? data.video : [],\n                audio: Array.isArray(data?.audio) ? data.audio : [],\n                lipsync: Array.isArray(data?.lipsync) ? data.lipsync : [],\n            } as UserModelsPayload\n        },\n    })\n}\n"
  },
  {
    "path": "src/lib/query/hooks/useVoiceLines.ts",
    "content": "'use client'\n\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { resolveTaskErrorMessage } from '@/lib/task/error-message'\nimport { apiFetch } from '@/lib/api-fetch'\n\n// ============ 类型定义 ============\nexport interface VoiceLine {\n    id: string\n    panelId: string\n    text: string\n    characterId: string | null\n    characterName: string | null\n    audioUrl: string | null\n    lineTaskRunning: boolean\n    errorMessage: string | null\n}\n\nexport interface VoiceLinesData {\n    lines: VoiceLine[]\n}\n\nexport interface MatchedVoiceLine {\n    id: string\n    lineIndex: number\n    speaker: string\n    content: string\n    audioUrl: string | null\n    audioDuration?: number | null\n    matchedStoryboardId: string | null\n    matchedPanelIndex: number | null\n}\n\nexport interface MatchedVoiceLinesData {\n    voiceLines: MatchedVoiceLine[]\n}\n\n// ============ 查询 Hooks ============\n\n/**\n * 获取语音数据\n */\nexport function useVoiceLines(episodeId: string | null) {\n    return useQuery({\n        queryKey: queryKeys.voiceLines.all(episodeId || ''),\n        queryFn: async () => {\n            if (!episodeId) throw new Error('Episode ID is required')\n            const res = await apiFetch(`/api/novel-promotion/episodes/${episodeId}/voice-lines`)\n            if (!res.ok) throw new Error('Failed to fetch voice lines')\n            const data = await res.json()\n            return data as VoiceLinesData\n        },\n        enabled: !!episodeId,\n    })\n}\n\n/**\n * 获取项目剧集配音与镜头匹配数据\n */\nexport function useMatchedVoiceLines(projectId: string | null, episodeId: string | null) {\n    return useQuery({\n        queryKey: queryKeys.voiceLines.matched(projectId || '', episodeId || ''),\n        queryFn: async () => {\n            if (!projectId || !episodeId) throw new Error('Project ID and Episode ID are required')\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/voice-lines?episodeId=${episodeId}`)\n            if (!res.ok) throw new Error('Failed to fetch matched voice lines')\n            const data = await res.json()\n            return data as MatchedVoiceLinesData\n        },\n        enabled: !!projectId && !!episodeId,\n    })\n}\n\n// ============ Mutation Hooks ============\n\n/**\n * 生成单条语音\n */\nexport function useGenerateVoice(projectId: string | null, episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ lineId }: { lineId: string }) => {\n            if (!projectId) throw new Error('Project ID is required')\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/generate-voice`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ lineId }),\n            })\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to generate voice'))\n            }\n            return res.json()\n        },\n        onSettled: () => {\n            if (episodeId) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.voiceLines.all(episodeId) })\n            }\n        },\n    })\n}\n\n/**\n * 批量生成语音\n */\nexport function useBatchGenerateVoices(projectId: string | null, episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ lineIds }: { lineIds: string[] }) => {\n            if (!projectId) throw new Error('Project ID is required')\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/batch-generate-voices`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ lineIds }),\n            })\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to batch generate voices'))\n            }\n            return res.json()\n        },\n        onSettled: () => {\n            if (episodeId) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.voiceLines.all(episodeId) })\n            }\n        },\n    })\n}\n\n/**\n * 更新语音文本\n */\nexport function useUpdateVoiceText(episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ lineId, text }: { lineId: string; text: string }) => {\n            const res = await apiFetch(`/api/novel-promotion/voice-lines/${lineId}`, {\n                method: 'PATCH',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ text }),\n            })\n            if (!res.ok) {\n                const error = await res.json()\n                throw new Error(resolveTaskErrorMessage(error, 'Failed to update voice text'))\n            }\n            return res.json()\n        },\n        // 乐观更新\n        onMutate: async ({ lineId, text }) => {\n            if (!episodeId) return\n\n            queryClient.setQueryData<VoiceLinesData>(\n                queryKeys.voiceLines.all(episodeId),\n                (old) => {\n                    if (!old) return old\n                    return {\n                        ...old,\n                        lines: old.lines.map(line =>\n                            line.id === lineId ? { ...line, text } : line\n                        )\n                    }\n                }\n            )\n        },\n        onSettled: () => {\n            if (episodeId) {\n                queryClient.invalidateQueries({ queryKey: queryKeys.voiceLines.all(episodeId) })\n            }\n        },\n    })\n}\n\n/**\n * 刷新语音数据\n */\nexport function useRefreshVoiceLines(episodeId: string | null) {\n    const queryClient = useQueryClient()\n\n    return () => {\n        if (episodeId) {\n            queryClient.invalidateQueries({ queryKey: queryKeys.voiceLines.all(episodeId) })\n        }\n    }\n}\n"
  },
  {
    "path": "src/lib/query/keys.ts",
    "content": "/**\n * 统一的 Query Keys 定义\n * 所有缓存 key 在此集中管理，避免不一致\n */\nexport const queryKeys = {\n    // ============ 中心资产库（Asset Hub）============\n    globalAssets: {\n        all: () => ['global-assets'] as const,\n        characters: (folderId?: string | null) =>\n            folderId ? ['global-assets', 'characters', folderId] as const : ['global-assets', 'characters'] as const,\n        locations: (folderId?: string | null) =>\n            folderId ? ['global-assets', 'locations', folderId] as const : ['global-assets', 'locations'] as const,\n        voices: (folderId?: string | null) =>\n            folderId ? ['global-assets', 'voices', folderId] as const : ['global-assets', 'voices'] as const,\n        folders: () => ['global-assets', 'folders'] as const,\n    },\n\n    // ============ 项目资产 ============\n    projectAssets: {\n        all: (projectId: string) => ['project-assets', projectId] as const,\n        characters: (projectId: string) => ['project-assets', projectId, 'characters'] as const,\n        locations: (projectId: string) => ['project-assets', projectId, 'locations'] as const,\n        detail: (projectId: string) => ['project-assets', projectId, 'detail'] as const,\n    },\n\n    // ============ 分镜（Storyboard）============\n    storyboards: {\n        all: (episodeId: string) => ['storyboards', episodeId] as const,\n        panels: (episodeId: string) => ['storyboards', episodeId, 'panels'] as const,\n        groups: (episodeId: string) => ['storyboards', episodeId, 'groups'] as const,\n    },\n\n    // ============ 视频生成 ============\n    videos: {\n        all: (episodeId: string) => ['videos', episodeId] as const,\n        panels: (episodeId: string) => ['videos', episodeId, 'panels'] as const,\n    },\n\n    // ============ 语音（Voice）============\n    voiceLines: {\n        all: (episodeId: string) => ['voice-lines', episodeId] as const,\n        list: (episodeId: string) => ['voice-lines', episodeId, 'list'] as const,\n        matched: (projectId: string, episodeId: string) =>\n            ['voice-lines', projectId, episodeId, 'matched'] as const,\n    },\n\n    // ============ 用户模型 ============\n    userModels: {\n        all: () => ['user-models'] as const,\n    },\n\n    // ============ 任务轮询 ============\n    tasks: {\n        all: (projectId: string) => ['tasks', projectId] as const,\n        target: (projectId: string, targetType: string, targetId: string) =>\n            ['tasks', projectId, targetType, targetId] as const,\n        snapshot: (projectId: string, targetType: string, targetId: string, typeKey: string) =>\n            ['tasks', projectId, targetType, targetId, 'snapshot', typeKey] as const,\n        targetStatesAll: (projectId: string) =>\n            ['task-target-states', projectId] as const,\n        targetStates: (projectId: string, serializedTargets: string) =>\n            ['task-target-states', projectId, serializedTargets] as const,\n        targetStateOverlay: (projectId: string) =>\n            ['task-target-states-overlay', projectId] as const,\n        pending: (projectId: string, episodeId?: string) =>\n            episodeId\n                ? ['pending-tasks', projectId, episodeId] as const\n                : ['pending-tasks', projectId] as const,\n    },\n\n    // ============ 项目数据 ============\n    project: {\n        detail: (projectId: string) => ['project', projectId] as const,\n        episodes: (projectId: string) => ['project', projectId, 'episodes'] as const,\n        data: (projectId: string) => ['project', projectId, 'data'] as const,\n    },\n\n    // ============ 顶层便捷函数 ============\n    /**\n     * 项目基础数据\n     */\n    projectData: (projectId: string) => ['project-data', projectId] as const,\n\n    /**\n     * 剧集详情数据\n     */\n    episodeData: (projectId: string, episodeId: string) =>\n        ['episode-data', projectId, episodeId] as const,\n} as const\n\n/**\n * 类型导出，用于类型推断\n */\nexport type QueryKeys = typeof queryKeys\n"
  },
  {
    "path": "src/lib/query/mutations/asset-hub-character-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { useRef } from 'react'\nimport {\n  clearTaskTargetOverlay,\n  upsertTaskTargetOverlay,\n} from '../task-target-overlay'\nimport { queryKeys } from '../keys'\nimport type { GlobalCharacter } from '../hooks/useGlobalAssets'\nimport {\n  requestJsonWithError,\n  requestVoidWithError,\n} from './mutation-shared'\nimport {\n  GLOBAL_ASSET_PROJECT_ID,\n  invalidateGlobalCharacters,\n} from './asset-hub-mutations-shared'\n\ninterface SelectCharacterImageContext {\n  previousQueries: Array<{\n    queryKey: readonly unknown[]\n    data: GlobalCharacter[] | undefined\n  }>\n  targetKey: string\n  requestId: number\n}\n\ninterface DeleteCharacterContext {\n  previousQueries: Array<{\n    queryKey: readonly unknown[]\n    data: GlobalCharacter[] | undefined\n  }>\n}\n\nfunction applyCharacterSelection(\n  characters: GlobalCharacter[] | undefined,\n  characterId: string,\n  appearanceIndex: number,\n  imageIndex: number | null,\n): GlobalCharacter[] | undefined {\n  if (!characters) return characters\n  return characters.map((character) => {\n    if (character.id !== characterId) return character\n    return {\n      ...character,\n      appearances: (character.appearances || []).map((appearance) => {\n        if (appearance.appearanceIndex !== appearanceIndex) return appearance\n        const selectedUrl =\n          imageIndex !== null && imageIndex >= 0\n            ? (appearance.imageUrls[imageIndex] ?? null)\n            : null\n        return {\n          ...appearance,\n          selectedIndex: imageIndex,\n          imageUrl: selectedUrl ?? appearance.imageUrl ?? null,\n        }\n      }),\n    }\n  })\n}\n\nfunction captureCharacterQuerySnapshots(queryClient: ReturnType<typeof useQueryClient>) {\n  return queryClient\n    .getQueriesData<GlobalCharacter[]>({\n      queryKey: queryKeys.globalAssets.characters(),\n      exact: false,\n    })\n    .map(([queryKey, data]) => ({ queryKey, data }))\n}\n\nfunction restoreCharacterQuerySnapshots(\n  queryClient: ReturnType<typeof useQueryClient>,\n  snapshots: Array<{ queryKey: readonly unknown[]; data: GlobalCharacter[] | undefined }>,\n) {\n  snapshots.forEach((snapshot) => {\n    queryClient.setQueryData(snapshot.queryKey, snapshot.data)\n  })\n}\n\nexport function useGenerateCharacterImage() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      characterId,\n      appearanceIndex,\n      artStyle,\n      count,\n    }: {\n      characterId: string\n      appearanceIndex: number\n      artStyle?: string\n      count?: number\n    }) => {\n      return await requestJsonWithError('/api/asset-hub/generate-image', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          type: 'character',\n          id: characterId,\n          appearanceIndex,\n          artStyle,\n          count,\n        }),\n      }, 'Failed to generate image')\n    },\n    onMutate: ({ characterId }) => {\n      upsertTaskTargetOverlay(queryClient, {\n        projectId: GLOBAL_ASSET_PROJECT_ID,\n        targetType: 'GlobalCharacter',\n        targetId: characterId,\n        intent: 'generate',\n      })\n    },\n    onError: (_error, { characterId }) => {\n      clearTaskTargetOverlay(queryClient, {\n        projectId: GLOBAL_ASSET_PROJECT_ID,\n        targetType: 'GlobalCharacter',\n        targetId: characterId,\n      })\n    },\n    onSettled: invalidateCharacters,\n  })\n}\n\nexport function useModifyCharacterImage() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      characterId,\n      appearanceIndex,\n      imageIndex,\n      modifyPrompt,\n      extraImageUrls,\n    }: {\n      characterId: string\n      appearanceIndex: number\n      imageIndex: number\n      modifyPrompt: string\n      extraImageUrls?: string[]\n    }) => {\n      return await requestJsonWithError('/api/asset-hub/modify-image', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          type: 'character',\n          id: characterId,\n          appearanceIndex,\n          imageIndex,\n          modifyPrompt,\n          extraImageUrls,\n        }),\n      }, 'Failed to modify image')\n    },\n    onMutate: ({ characterId, appearanceIndex, imageIndex }) => {\n      upsertTaskTargetOverlay(queryClient, {\n        projectId: GLOBAL_ASSET_PROJECT_ID,\n        targetType: 'GlobalCharacterAppearance',\n        targetId: `${characterId}:${appearanceIndex}:${imageIndex}`,\n        intent: 'modify',\n      })\n    },\n    onError: (_error, { characterId, appearanceIndex, imageIndex }) => {\n      clearTaskTargetOverlay(queryClient, {\n        projectId: GLOBAL_ASSET_PROJECT_ID,\n        targetType: 'GlobalCharacterAppearance',\n        targetId: `${characterId}:${appearanceIndex}:${imageIndex}`,\n      })\n    },\n    onSettled: invalidateCharacters,\n  })\n}\n\nexport function useSelectCharacterImage() {\n  const queryClient = useQueryClient()\n  const latestRequestIdByTargetRef = useRef<Record<string, number>>({})\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      characterId,\n      appearanceIndex,\n      imageIndex,\n      confirm = false,\n    }: {\n      characterId: string\n      appearanceIndex: number\n      imageIndex: number | null\n      confirm?: boolean\n    }) => {\n      return await requestJsonWithError('/api/asset-hub/select-image', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          type: 'character',\n          id: characterId,\n          appearanceIndex,\n          imageIndex,\n          confirm,\n        }),\n      }, 'Failed to select image')\n    },\n    onMutate: async (variables): Promise<SelectCharacterImageContext> => {\n      const targetKey = `${variables.characterId}:${variables.appearanceIndex}`\n      const requestId = (latestRequestIdByTargetRef.current[targetKey] ?? 0) + 1\n      latestRequestIdByTargetRef.current[targetKey] = requestId\n\n      await queryClient.cancelQueries({\n        queryKey: queryKeys.globalAssets.characters(),\n        exact: false,\n      })\n      const previousQueries = captureCharacterQuerySnapshots(queryClient)\n\n      queryClient.setQueriesData<GlobalCharacter[] | undefined>(\n        {\n          queryKey: queryKeys.globalAssets.characters(),\n          exact: false,\n        },\n        (previous) => applyCharacterSelection(\n          previous,\n          variables.characterId,\n          variables.appearanceIndex,\n          variables.imageIndex,\n        ),\n      )\n\n      return {\n        previousQueries,\n        targetKey,\n        requestId,\n      }\n    },\n    onError: (_error, _variables, context) => {\n      if (!context) return\n      const latestRequestId = latestRequestIdByTargetRef.current[context.targetKey]\n      if (latestRequestId !== context.requestId) return\n      restoreCharacterQuerySnapshots(queryClient, context.previousQueries)\n    },\n    onSettled: (_data, _error, variables) => {\n      if (variables.confirm) {\n        void invalidateCharacters()\n      }\n    },\n  })\n}\n\nexport function useUndoCharacterImage() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async ({ characterId, appearanceIndex }: { characterId: string; appearanceIndex: number }) => {\n      return await requestJsonWithError('/api/asset-hub/undo-image', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          type: 'character',\n          id: characterId,\n          appearanceIndex,\n        }),\n      }, 'Failed to undo image')\n    },\n    onSuccess: invalidateCharacters,\n  })\n}\n\nexport function useUploadCharacterImage() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      file,\n      characterId,\n      appearanceIndex,\n      labelText,\n      imageIndex,\n    }: {\n      file: File\n      characterId: string\n      appearanceIndex: number\n      labelText: string\n      imageIndex?: number\n    }) => {\n      const formData = new FormData()\n      formData.append('file', file)\n      formData.append('type', 'character')\n      formData.append('id', characterId)\n      formData.append('appearanceIndex', appearanceIndex.toString())\n      formData.append('labelText', labelText)\n      if (imageIndex !== undefined) {\n        formData.append('imageIndex', imageIndex.toString())\n      }\n\n      return await requestJsonWithError('/api/asset-hub/upload-image', {\n        method: 'POST',\n        body: formData,\n      }, 'Failed to upload image')\n    },\n    onSuccess: invalidateCharacters,\n  })\n}\n\nexport function useDeleteCharacter() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async (characterId: string) => {\n      await requestVoidWithError(\n        `/api/asset-hub/characters/${characterId}`,\n        { method: 'DELETE' },\n        'Failed to delete character',\n      )\n    },\n    onMutate: async (characterId): Promise<DeleteCharacterContext> => {\n      await queryClient.cancelQueries({\n        queryKey: queryKeys.globalAssets.characters(),\n        exact: false,\n      })\n      const previousQueries = captureCharacterQuerySnapshots(queryClient)\n\n      queryClient.setQueriesData<GlobalCharacter[] | undefined>(\n        {\n          queryKey: queryKeys.globalAssets.characters(),\n          exact: false,\n        },\n        (previous) => previous?.filter((character) => character.id !== characterId),\n      )\n\n      return { previousQueries }\n    },\n    onError: (_error, _characterId, context) => {\n      if (!context) return\n      restoreCharacterQuerySnapshots(queryClient, context.previousQueries)\n    },\n    onSettled: invalidateCharacters,\n  })\n}\n\nexport function useDeleteCharacterAppearance() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async ({ characterId, appearanceIndex }: { characterId: string; appearanceIndex: number }) => {\n      await requestVoidWithError(\n        `/api/asset-hub/appearances?characterId=${characterId}&appearanceIndex=${appearanceIndex}`,\n        { method: 'DELETE' },\n        'Failed to delete appearance',\n      )\n    },\n    onSuccess: invalidateCharacters,\n  })\n}\n\nexport function useUploadCharacterVoice() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async ({ file, characterId }: { file: File; characterId: string }) => {\n      const formData = new FormData()\n      formData.append('file', file)\n      formData.append('characterId', characterId)\n\n      return await requestJsonWithError('/api/asset-hub/character-voice', {\n        method: 'POST',\n        body: formData,\n      }, 'Failed to upload voice')\n    },\n    onSuccess: invalidateCharacters,\n  })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/asset-hub-creation-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport {\n  requestJsonWithError,\n  requestTaskResponseWithError,\n} from './mutation-shared'\nimport {\n  invalidateGlobalCharacters,\n  invalidateGlobalLocations,\n} from './asset-hub-mutations-shared'\n\nexport function useAiDesignLocation() {\n  return useMutation({\n    mutationFn: async (userInstruction: string) => {\n      const response = await requestTaskResponseWithError(\n        '/api/asset-hub/ai-design-location',\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({ userInstruction }),\n        },\n        'Failed to design location',\n      )\n      return resolveTaskResponse<{ prompt?: string }>(response)\n    },\n  })\n}\n\nexport function useCreateAssetHubLocation() {\n  const queryClient = useQueryClient()\n  const invalidateLocations = () => invalidateGlobalLocations(queryClient)\n\n  return useMutation({\n    mutationFn: async (payload: {\n      name: string\n      summary: string\n      folderId: string | null\n      artStyle: string\n      count?: number\n    }) => {\n      return await requestJsonWithError('/api/asset-hub/locations', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(payload),\n      }, '创建失败')\n    },\n    onSuccess: invalidateLocations,\n  })\n}\n\nexport function useUploadAssetHubTempMedia() {\n  return useMutation({\n    mutationFn: async (payload: { imageBase64?: string; base64?: string; extension?: string; type?: string }) =>\n      await requestJsonWithError<{ success: boolean; url?: string; key?: string }>(\n        '/api/asset-hub/upload-temp',\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify(payload),\n        },\n        '上传失败',\n      ),\n  })\n}\n\nexport function useAiDesignCharacter() {\n  return useMutation({\n    mutationFn: async (userInstruction: string) => {\n      const response = await requestTaskResponseWithError(\n        '/api/asset-hub/ai-design-character',\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({ userInstruction }),\n        },\n        'Failed to design character',\n      )\n      return resolveTaskResponse<{ prompt?: string }>(response)\n    },\n  })\n}\n\nexport function useExtractAssetHubReferenceCharacterDescription() {\n  return useMutation({\n    mutationFn: async (referenceImageUrls: string[]) => {\n      const response = await requestTaskResponseWithError(\n        '/api/asset-hub/reference-to-character',\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            referenceImageUrls,\n            extractOnly: true,\n          }),\n        },\n        'Failed to extract character description',\n      )\n      return resolveTaskResponse<{ description?: string }>(response)\n    },\n  })\n}\n\nexport function useCreateAssetHubCharacter() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async (payload: {\n      name: string\n      description: string\n      folderId?: string | null\n      artStyle: string\n      generateFromReference?: boolean\n      referenceImageUrls?: string[]\n      customDescription?: string\n      count?: number\n    }) =>\n      await requestJsonWithError('/api/asset-hub/characters', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(payload),\n      }, '创建角色失败'),\n    onSuccess: invalidateCharacters,\n  })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/asset-hub-location-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { useRef } from 'react'\nimport {\n  clearTaskTargetOverlay,\n  upsertTaskTargetOverlay,\n} from '../task-target-overlay'\nimport { queryKeys } from '../keys'\nimport type { GlobalLocation } from '../hooks/useGlobalAssets'\nimport {\n  requestJsonWithError,\n  requestVoidWithError,\n} from './mutation-shared'\nimport {\n  GLOBAL_ASSET_PROJECT_ID,\n  invalidateGlobalLocations,\n} from './asset-hub-mutations-shared'\n\ninterface SelectLocationImageContext {\n  previousQueries: Array<{\n    queryKey: readonly unknown[]\n    data: GlobalLocation[] | undefined\n  }>\n  targetKey: string\n  requestId: number\n}\n\ninterface DeleteLocationContext {\n  previousQueries: Array<{\n    queryKey: readonly unknown[]\n    data: GlobalLocation[] | undefined\n  }>\n}\n\nfunction applyLocationSelection(\n  locations: GlobalLocation[] | undefined,\n  locationId: string,\n  imageIndex: number | null,\n): GlobalLocation[] | undefined {\n  if (!locations) return locations\n  return locations.map((location) => {\n    if (location.id !== locationId) return location\n    return {\n      ...location,\n      images: (location.images || []).map((image) => ({\n        ...image,\n        isSelected: imageIndex !== null && image.imageIndex === imageIndex,\n      })),\n    }\n  })\n}\n\nfunction captureLocationQuerySnapshots(queryClient: ReturnType<typeof useQueryClient>) {\n  return queryClient\n    .getQueriesData<GlobalLocation[]>({\n      queryKey: queryKeys.globalAssets.locations(),\n      exact: false,\n    })\n    .map(([queryKey, data]) => ({ queryKey, data }))\n}\n\nfunction restoreLocationQuerySnapshots(\n  queryClient: ReturnType<typeof useQueryClient>,\n  snapshots: Array<{ queryKey: readonly unknown[]; data: GlobalLocation[] | undefined }>,\n) {\n  snapshots.forEach((snapshot) => {\n    queryClient.setQueryData(snapshot.queryKey, snapshot.data)\n  })\n}\n\nexport function useGenerateLocationImage() {\n  const queryClient = useQueryClient()\n  const invalidateLocations = () => invalidateGlobalLocations(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      locationId,\n      artStyle,\n      count,\n    }: {\n      locationId: string\n      artStyle?: string\n      count?: number\n    }) => {\n      return await requestJsonWithError('/api/asset-hub/generate-image', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ type: 'location', id: locationId, artStyle, count }),\n      }, 'Failed to generate image')\n    },\n    onMutate: ({ locationId }) => {\n      upsertTaskTargetOverlay(queryClient, {\n        projectId: GLOBAL_ASSET_PROJECT_ID,\n        targetType: 'GlobalLocation',\n        targetId: locationId,\n        intent: 'generate',\n      })\n    },\n    onError: (_error, { locationId }) => {\n      clearTaskTargetOverlay(queryClient, {\n        projectId: GLOBAL_ASSET_PROJECT_ID,\n        targetType: 'GlobalLocation',\n        targetId: locationId,\n      })\n    },\n    onSettled: invalidateLocations,\n  })\n}\n\nexport function useModifyLocationImage() {\n  const queryClient = useQueryClient()\n  const invalidateLocations = () => invalidateGlobalLocations(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      locationId,\n      imageIndex,\n      modifyPrompt,\n      extraImageUrls,\n    }: {\n      locationId: string\n      imageIndex: number\n      modifyPrompt: string\n      extraImageUrls?: string[]\n    }) => {\n      return await requestJsonWithError('/api/asset-hub/modify-image', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          type: 'location',\n          id: locationId,\n          imageIndex,\n          modifyPrompt,\n          extraImageUrls,\n        }),\n      }, 'Failed to modify image')\n    },\n    onMutate: ({ locationId, imageIndex }) => {\n      upsertTaskTargetOverlay(queryClient, {\n        projectId: GLOBAL_ASSET_PROJECT_ID,\n        targetType: 'GlobalLocationImage',\n        targetId: `${locationId}:${imageIndex}`,\n        intent: 'modify',\n      })\n    },\n    onError: (_error, { locationId, imageIndex }) => {\n      clearTaskTargetOverlay(queryClient, {\n        projectId: GLOBAL_ASSET_PROJECT_ID,\n        targetType: 'GlobalLocationImage',\n        targetId: `${locationId}:${imageIndex}`,\n      })\n    },\n    onSettled: invalidateLocations,\n  })\n}\n\nexport function useSelectLocationImage() {\n  const queryClient = useQueryClient()\n  const latestRequestIdByTargetRef = useRef<Record<string, number>>({})\n  const invalidateLocations = () => invalidateGlobalLocations(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      locationId,\n      imageIndex,\n      confirm = false,\n    }: {\n      locationId: string\n      imageIndex: number | null\n      confirm?: boolean\n    }) => {\n      return await requestJsonWithError('/api/asset-hub/select-image', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          type: 'location',\n          id: locationId,\n          imageIndex,\n          confirm,\n        }),\n      }, 'Failed to select image')\n    },\n    onMutate: async (variables): Promise<SelectLocationImageContext> => {\n      const targetKey = variables.locationId\n      const requestId = (latestRequestIdByTargetRef.current[targetKey] ?? 0) + 1\n      latestRequestIdByTargetRef.current[targetKey] = requestId\n\n      await queryClient.cancelQueries({\n        queryKey: queryKeys.globalAssets.locations(),\n        exact: false,\n      })\n      const previousQueries = captureLocationQuerySnapshots(queryClient)\n\n      queryClient.setQueriesData<GlobalLocation[] | undefined>(\n        {\n          queryKey: queryKeys.globalAssets.locations(),\n          exact: false,\n        },\n        (previous) => applyLocationSelection(previous, variables.locationId, variables.imageIndex),\n      )\n\n      return {\n        previousQueries,\n        targetKey,\n        requestId,\n      }\n    },\n    onError: (_error, _variables, context) => {\n      if (!context) return\n      const latestRequestId = latestRequestIdByTargetRef.current[context.targetKey]\n      if (latestRequestId !== context.requestId) return\n      restoreLocationQuerySnapshots(queryClient, context.previousQueries)\n    },\n    onSettled: (_data, _error, variables) => {\n      if (variables.confirm) {\n        void invalidateLocations()\n      }\n    },\n  })\n}\n\nexport function useUndoLocationImage() {\n  const queryClient = useQueryClient()\n  const invalidateLocations = () => invalidateGlobalLocations(queryClient)\n\n  return useMutation({\n    mutationFn: async (locationId: string) => {\n      return await requestJsonWithError('/api/asset-hub/undo-image', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ type: 'location', id: locationId }),\n      }, 'Failed to undo image')\n    },\n    onSuccess: invalidateLocations,\n  })\n}\n\nexport function useUploadLocationImage() {\n  const queryClient = useQueryClient()\n  const invalidateLocations = () => invalidateGlobalLocations(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      file,\n      locationId,\n      labelText,\n      imageIndex,\n    }: {\n      file: File\n      locationId: string\n      labelText: string\n      imageIndex?: number\n    }) => {\n      const formData = new FormData()\n      formData.append('file', file)\n      formData.append('type', 'location')\n      formData.append('id', locationId)\n      formData.append('labelText', labelText)\n      if (imageIndex !== undefined) {\n        formData.append('imageIndex', imageIndex.toString())\n      }\n\n      return await requestJsonWithError('/api/asset-hub/upload-image', {\n        method: 'POST',\n        body: formData,\n      }, 'Failed to upload image')\n    },\n    onSuccess: invalidateLocations,\n  })\n}\n\nexport function useDeleteLocation() {\n  const queryClient = useQueryClient()\n  const invalidateLocations = () => invalidateGlobalLocations(queryClient)\n\n  return useMutation({\n    mutationFn: async (locationId: string) => {\n      await requestVoidWithError(\n        `/api/asset-hub/locations/${locationId}`,\n        { method: 'DELETE' },\n        'Failed to delete location',\n      )\n    },\n    onMutate: async (locationId): Promise<DeleteLocationContext> => {\n      await queryClient.cancelQueries({\n        queryKey: queryKeys.globalAssets.locations(),\n        exact: false,\n      })\n      const previousQueries = captureLocationQuerySnapshots(queryClient)\n\n      queryClient.setQueriesData<GlobalLocation[] | undefined>(\n        {\n          queryKey: queryKeys.globalAssets.locations(),\n          exact: false,\n        },\n        (previous) => previous?.filter((location) => location.id !== locationId),\n      )\n\n      return { previousQueries }\n    },\n    onError: (_error, _locationId, context) => {\n      if (!context) return\n      restoreLocationQuerySnapshots(queryClient, context.previousQueries)\n    },\n    onSettled: invalidateLocations,\n  })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/asset-hub-mutations-runtime.ts",
    "content": "export * from './asset-hub-character-mutations'\nexport * from './asset-hub-location-mutations'\nexport * from './asset-hub-voice-mutations'\nexport * from './asset-hub-creation-mutations'\nexport * from './asset-hub-update-mutations'\n"
  },
  {
    "path": "src/lib/query/mutations/asset-hub-mutations-shared.ts",
    "content": "import type { QueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { invalidateQueryTemplates } from './mutation-shared'\n\nexport const GLOBAL_ASSET_PROJECT_ID = 'global-asset-hub'\n\nexport function invalidateGlobalCharacters(queryClient: QueryClient) {\n  return invalidateQueryTemplates(queryClient, [queryKeys.globalAssets.characters()])\n}\n\nexport function invalidateGlobalLocations(queryClient: QueryClient) {\n  return invalidateQueryTemplates(queryClient, [queryKeys.globalAssets.locations()])\n}\n\nexport function invalidateGlobalVoices(queryClient: QueryClient) {\n  return invalidateQueryTemplates(queryClient, [queryKeys.globalAssets.voices()])\n}\n"
  },
  {
    "path": "src/lib/query/mutations/asset-hub-update-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport { apiFetch } from '@/lib/api-fetch'\nimport {\n  requestJsonWithError,\n  requestTaskResponseWithError,\n} from './mutation-shared'\nimport {\n  invalidateGlobalCharacters,\n  invalidateGlobalLocations,\n} from './asset-hub-mutations-shared'\n\nexport function useUpdateCharacterName() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async ({ characterId, name }: { characterId: string; name: string }) => {\n      const res = await requestJsonWithError(`/api/asset-hub/characters/${characterId}`, {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ name }),\n      }, 'Failed to update character name')\n\n      // 等待图片标签更新完成，确保 onSuccess invalidate 后前端能立即看到新标签\n      try {\n        await apiFetch('/api/asset-hub/update-asset-label', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({ type: 'character', id: characterId, newName: name }),\n        })\n      } catch (e) {\n        console.error('更新图片标签失败:', e)\n      }\n\n      return res\n    },\n    onSuccess: invalidateCharacters,\n  })\n}\n\nexport function useUpdateLocationName() {\n  const queryClient = useQueryClient()\n  const invalidateLocations = () => invalidateGlobalLocations(queryClient)\n\n  return useMutation({\n    mutationFn: async ({ locationId, name }: { locationId: string; name: string }) => {\n      const res = await requestJsonWithError(`/api/asset-hub/locations/${locationId}`, {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ name }),\n      }, 'Failed to update location name')\n\n      // 等待图片标签更新完成，确保 onSuccess invalidate 后前端能立即看到新标签\n      try {\n        await apiFetch('/api/asset-hub/update-asset-label', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({ type: 'location', id: locationId, newName: name }),\n        })\n      } catch (e) {\n        console.error('更新图片标签失败:', e)\n      }\n\n      return res\n    },\n    onSuccess: invalidateLocations,\n  })\n}\n\nexport function useUpdateCharacterAppearanceDescription() {\n  const queryClient = useQueryClient()\n  const invalidateCharacters = () => invalidateGlobalCharacters(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      characterId,\n      appearanceIndex,\n      description,\n    }: {\n      characterId: string\n      appearanceIndex: number\n      description: string\n    }) => {\n      return await requestJsonWithError('/api/asset-hub/appearances', {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ characterId, appearanceIndex, description }),\n      }, 'Failed to update appearance description')\n    },\n    onSuccess: invalidateCharacters,\n  })\n}\n\nexport function useUpdateLocationSummary() {\n  const queryClient = useQueryClient()\n  const invalidateLocations = () => invalidateGlobalLocations(queryClient)\n\n  return useMutation({\n    mutationFn: async ({\n      locationId,\n      summary,\n    }: {\n      locationId: string\n      summary: string\n    }) => {\n      return await requestJsonWithError(`/api/asset-hub/locations/${locationId}`, {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ summary }),\n      }, 'Failed to update location summary')\n    },\n    onSuccess: invalidateLocations,\n  })\n}\n\nexport function useAiModifyCharacterDescription() {\n  return useMutation({\n    mutationFn: async ({\n      characterId,\n      appearanceIndex,\n      currentDescription,\n      modifyInstruction,\n    }: {\n      characterId: string\n      appearanceIndex: number\n      currentDescription: string\n      modifyInstruction: string\n    }) => {\n      const response = await requestTaskResponseWithError(\n        '/api/asset-hub/ai-modify-character',\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            characterId,\n            appearanceIndex,\n            currentDescription,\n            modifyInstruction,\n          }),\n        },\n        'Failed to modify character description',\n      )\n      return resolveTaskResponse<{ modifiedDescription?: string }>(response)\n    },\n  })\n}\n\nexport function useAiModifyLocationDescription() {\n  return useMutation({\n    mutationFn: async ({\n      locationId,\n      imageIndex,\n      currentDescription,\n      modifyInstruction,\n    }: {\n      locationId: string\n      imageIndex: number\n      currentDescription: string\n      modifyInstruction: string\n    }) => {\n      const response = await requestTaskResponseWithError(\n        '/api/asset-hub/ai-modify-location',\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            locationId,\n            imageIndex,\n            currentDescription,\n            modifyInstruction,\n          }),\n        },\n        'Failed to modify location description',\n      )\n      return resolveTaskResponse<{ modifiedDescription?: string }>(response)\n    },\n  })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/asset-hub-voice-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport {\n  requestJsonWithError,\n  requestTaskResponseWithError,\n  requestVoidWithError,\n} from './mutation-shared'\nimport { invalidateGlobalVoices } from './asset-hub-mutations-shared'\n\nexport function useDeleteVoice() {\n  const queryClient = useQueryClient()\n  const invalidateVoices = () => invalidateGlobalVoices(queryClient)\n\n  return useMutation({\n    mutationFn: async (voiceId: string) => {\n      await requestVoidWithError(\n        `/api/asset-hub/voices/${voiceId}`,\n        { method: 'DELETE' },\n        'Failed to delete voice',\n      )\n    },\n    onSuccess: invalidateVoices,\n  })\n}\n\nexport function useDesignAssetHubVoice() {\n  return useMutation({\n    mutationFn: async (payload: {\n      voicePrompt: string\n      previewText: string\n      preferredName: string\n      language: 'zh'\n    }) => {\n      const response = await requestTaskResponseWithError(\n        '/api/asset-hub/voice-design',\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify(payload),\n        },\n        'Failed to design voice',\n      )\n      return await resolveTaskResponse<{\n        success?: boolean\n        voiceId?: string\n        targetModel?: string\n        audioBase64?: string\n        requestId?: string\n      }>(response)\n    },\n  })\n}\n\nexport function useSaveDesignedAssetHubVoice() {\n  const queryClient = useQueryClient()\n  const invalidateVoices = () => invalidateGlobalVoices(queryClient)\n\n  return useMutation({\n    mutationFn: async (payload: {\n      voiceId: string\n      voiceBase64: string\n      voiceName: string\n      folderId: string | null\n      voicePrompt: string\n    }) => {\n      const uploadData = await requestJsonWithError<{ key: string }>('/api/asset-hub/upload-temp', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          base64: payload.voiceBase64,\n          type: 'audio/wav',\n          extension: 'wav',\n        }),\n      }, '上传音频失败')\n      const res = await requestJsonWithError('/api/asset-hub/voices', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          name: payload.voiceName,\n          description: null,\n          folderId: payload.folderId,\n          voiceId: payload.voiceId,\n          voiceType: 'qwen-designed',\n          customVoiceUrl: uploadData.key,\n          voicePrompt: payload.voicePrompt,\n          gender: null,\n          language: 'zh',\n        }),\n      }, '保存失败')\n      return res\n    },\n    onSuccess: invalidateVoices,\n  })\n}\n\nexport function useUploadAssetHubVoice() {\n  const queryClient = useQueryClient()\n  const invalidateVoices = () => invalidateGlobalVoices(queryClient)\n\n  return useMutation({\n    mutationFn: async (payload: {\n      uploadFile: File\n      voiceName: string\n      folderId: string | null\n    }) => {\n      const formData = new FormData()\n      formData.append('file', payload.uploadFile)\n      formData.append('name', payload.voiceName)\n      if (payload.folderId) {\n        formData.append('folderId', payload.folderId)\n      }\n      return await requestJsonWithError('/api/asset-hub/voices/upload', {\n        method: 'POST',\n        body: formData,\n      }, '上传失败')\n    },\n    onSuccess: invalidateVoices,\n  })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/character-base-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport { useRef } from 'react'\nimport type { Character, Project } from '@/types/project'\nimport { queryKeys } from '../keys'\nimport type { ProjectAssetsData } from '../hooks/useProjectAssets'\nimport { apiFetch } from '@/lib/api-fetch'\nimport {\n    clearTaskTargetOverlay,\n    upsertTaskTargetOverlay,\n} from '../task-target-overlay'\nimport {\n    invalidateQueryTemplates,\n    requestJsonWithError,\n    requestVoidWithError,\n} from './mutation-shared'\n\ninterface SelectProjectCharacterImageContext {\n    previousAssets: ProjectAssetsData | undefined\n    previousProject: Project | undefined\n    targetKey: string\n    requestId: number\n}\n\ninterface DeleteProjectCharacterContext {\n    previousAssets: ProjectAssetsData | undefined\n    previousProject: Project | undefined\n}\n\nfunction applyCharacterSelectionToCharacters(\n    characters: Character[],\n    characterId: string,\n    appearanceId: string,\n    selectedIndex: number | null,\n): Character[] {\n    return characters.map((character) => {\n        if (character.id !== characterId) return character\n        return {\n            ...character,\n            appearances: (character.appearances || []).map((appearance) => {\n                if (appearance.id !== appearanceId) return appearance\n                const selectedUrl =\n                    selectedIndex !== null && selectedIndex >= 0\n                        ? (appearance.imageUrls[selectedIndex] ?? null)\n                        : null\n                return {\n                    ...appearance,\n                    selectedIndex,\n                    imageUrl: selectedUrl ?? appearance.imageUrl ?? null,\n                }\n            }),\n        }\n    })\n}\n\nfunction applyCharacterSelectionToAssets(\n    previous: ProjectAssetsData | undefined,\n    characterId: string,\n    appearanceId: string,\n    selectedIndex: number | null,\n): ProjectAssetsData | undefined {\n    if (!previous) return previous\n    return {\n        ...previous,\n        characters: applyCharacterSelectionToCharacters(previous.characters || [], characterId, appearanceId, selectedIndex),\n    }\n}\n\nfunction applyCharacterSelectionToProject(\n    previous: Project | undefined,\n    characterId: string,\n    appearanceId: string,\n    selectedIndex: number | null,\n): Project | undefined {\n    if (!previous?.novelPromotionData) return previous\n    const currentCharacters = previous.novelPromotionData.characters || []\n    return {\n        ...previous,\n        novelPromotionData: {\n            ...previous.novelPromotionData,\n            characters: applyCharacterSelectionToCharacters(currentCharacters, characterId, appearanceId, selectedIndex),\n        },\n    }\n}\n\nfunction removeCharacterFromAssets(\n    previous: ProjectAssetsData | undefined,\n    characterId: string,\n): ProjectAssetsData | undefined {\n    if (!previous) return previous\n    return {\n        ...previous,\n        characters: (previous.characters || []).filter((character) => character.id !== characterId),\n    }\n}\n\nfunction removeCharacterFromProject(\n    previous: Project | undefined,\n    characterId: string,\n): Project | undefined {\n    if (!previous?.novelPromotionData) return previous\n    const currentCharacters = previous.novelPromotionData.characters || []\n    return {\n        ...previous,\n        novelPromotionData: {\n            ...previous.novelPromotionData,\n            characters: currentCharacters.filter((character) => character.id !== characterId),\n        },\n    }\n}\n\nexport function useGenerateProjectCharacterImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            characterId,\n            appearanceId,\n            count,\n        }: {\n            characterId: string\n            appearanceId: string\n            count?: number\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/generate-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'character',\n                    id: characterId,\n                    appearanceId,\n                    count,\n                })\n            }, 'Failed to generate image')\n        },\n        onMutate: ({ appearanceId }) => {\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'CharacterAppearance',\n                targetId: appearanceId,\n                intent: 'generate',\n            })\n        },\n        onError: (_error, { appearanceId }) => {\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'CharacterAppearance',\n                targetId: appearanceId,\n            })\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 上传项目角色图片\n */\n\nexport function useUploadProjectCharacterImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            file, characterId, appearanceId, imageIndex, labelText\n        }: {\n            file: File\n            characterId: string\n            appearanceId: string\n            imageIndex?: number\n            labelText?: string\n        }) => {\n            const formData = new FormData()\n            formData.append('file', file)\n            formData.append('type', 'character')\n            formData.append('id', characterId)\n            formData.append('appearanceId', appearanceId)\n            if (imageIndex !== undefined) formData.append('imageIndex', imageIndex.toString())\n            if (labelText) formData.append('labelText', labelText)\n\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/upload-asset-image`, {\n                method: 'POST',\n                body: formData\n            }, 'Failed to upload image')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * 选择项目角色图片\n */\n\nexport function useSelectProjectCharacterImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const latestRequestIdByTargetRef = useRef<Record<string, number>>({})\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            characterId, appearanceId, imageIndex\n        }: {\n            characterId: string\n            appearanceId: string\n            imageIndex: number | null\n            confirm?: boolean\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/select-character-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    characterId,\n                    appearanceId,\n                    selectedIndex: imageIndex,\n                })\n            }, 'Failed to select image')\n        },\n        onMutate: async (variables): Promise<SelectProjectCharacterImageContext> => {\n            const targetKey = `${variables.characterId}:${variables.appearanceId}`\n            const requestId = (latestRequestIdByTargetRef.current[targetKey] ?? 0) + 1\n            latestRequestIdByTargetRef.current[targetKey] = requestId\n\n            const assetsQueryKey = queryKeys.projectAssets.all(projectId)\n            const projectQueryKey = queryKeys.projectData(projectId)\n\n            await queryClient.cancelQueries({ queryKey: assetsQueryKey })\n            await queryClient.cancelQueries({ queryKey: projectQueryKey })\n\n            const previousAssets = queryClient.getQueryData<ProjectAssetsData>(assetsQueryKey)\n            const previousProject = queryClient.getQueryData<Project>(projectQueryKey)\n\n            queryClient.setQueryData<ProjectAssetsData | undefined>(assetsQueryKey, (previous) =>\n                applyCharacterSelectionToAssets(previous, variables.characterId, variables.appearanceId, variables.imageIndex),\n            )\n            queryClient.setQueryData<Project | undefined>(projectQueryKey, (previous) =>\n                applyCharacterSelectionToProject(previous, variables.characterId, variables.appearanceId, variables.imageIndex),\n            )\n\n            return {\n                previousAssets,\n                previousProject,\n                targetKey,\n                requestId,\n            }\n        },\n        onError: (_error, _variables, context) => {\n            if (!context) return\n            const latestRequestId = latestRequestIdByTargetRef.current[context.targetKey]\n            if (latestRequestId !== context.requestId) return\n            queryClient.setQueryData(queryKeys.projectAssets.all(projectId), context.previousAssets)\n            queryClient.setQueryData(queryKeys.projectData(projectId), context.previousProject)\n        },\n        onSettled: (_data, _error, variables) => {\n            if (variables.confirm) {\n                void invalidateProjectAssets()\n            }\n        },\n    })\n}\n\n/**\n * 撤回项目角色图片\n */\n\nexport function useUndoProjectCharacterImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({ characterId, appearanceId }: { characterId: string; appearanceId: string }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/undo-regenerate`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'character',\n                    id: characterId,\n                    appearanceId\n                })\n            }, 'Failed to undo image')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * 删除项目角色\n */\n\nexport function useDeleteProjectCharacter(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async (characterId: string) => {\n            await requestVoidWithError(\n                `/api/novel-promotion/${projectId}/character?id=${encodeURIComponent(characterId)}`,\n                { method: 'DELETE' },\n                'Failed to delete character',\n            )\n        },\n        onMutate: async (characterId): Promise<DeleteProjectCharacterContext> => {\n            const assetsQueryKey = queryKeys.projectAssets.all(projectId)\n            const projectQueryKey = queryKeys.projectData(projectId)\n\n            await queryClient.cancelQueries({ queryKey: assetsQueryKey })\n            await queryClient.cancelQueries({ queryKey: projectQueryKey })\n\n            const previousAssets = queryClient.getQueryData<ProjectAssetsData>(assetsQueryKey)\n            const previousProject = queryClient.getQueryData<Project>(projectQueryKey)\n\n            queryClient.setQueryData<ProjectAssetsData | undefined>(assetsQueryKey, (previous) =>\n                removeCharacterFromAssets(previous, characterId),\n            )\n            queryClient.setQueryData<Project | undefined>(projectQueryKey, (previous) =>\n                removeCharacterFromProject(previous, characterId),\n            )\n\n            return {\n                previousAssets,\n                previousProject,\n            }\n        },\n        onError: (_error, _characterId, context) => {\n            if (!context) return\n            queryClient.setQueryData(queryKeys.projectAssets.all(projectId), context.previousAssets)\n            queryClient.setQueryData(queryKeys.projectData(projectId), context.previousProject)\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 删除项目角色形象\n */\n\nexport function useDeleteProjectAppearance(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({ characterId, appearanceId }: { characterId: string; appearanceId: string }) => {\n            await requestVoidWithError(\n                `/api/novel-promotion/${projectId}/character/appearance?characterId=${encodeURIComponent(characterId)}&appearanceId=${encodeURIComponent(appearanceId)}`,\n                { method: 'DELETE' },\n                'Failed to delete appearance',\n            )\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * 更新项目角色名字\n */\n\nexport function useUpdateProjectCharacterName(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({ characterId, name }: { characterId: string; name: string }) => {\n            const res = await requestJsonWithError(`/api/novel-promotion/${projectId}/character`, {\n                method: 'PATCH',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ characterId, name })\n            }, 'Failed to update character name')\n\n            // 等待图片标签更新完成，确保 onSuccess invalidate 后前端能立即看到新标签\n            try {\n                await apiFetch(`/api/novel-promotion/${projectId}/update-asset-label`, {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({\n                        type: 'character',\n                        id: characterId,\n                        newName: name\n                    })\n                })\n            } catch (e) {\n                _ulogError('更新图片标签失败:', e)\n            }\n\n            return res\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/character-image-ops-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { apiFetch } from '@/lib/api-fetch'\nimport {\n    clearTaskTargetOverlay,\n    upsertTaskTargetOverlay,\n} from '../task-target-overlay'\nimport {\n    invalidateQueryTemplates,\n    requestJsonWithError,\n} from './mutation-shared'\n\nexport function useModifyProjectCharacterImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssetAndProjectData = () =>\n        invalidateQueryTemplates(queryClient, [\n            queryKeys.projectAssets.all(projectId),\n            queryKeys.projectData(projectId),\n        ])\n\n    return useMutation({\n        mutationFn: async (params: {\n            characterId: string\n            appearanceId: string\n            imageIndex: number\n            modifyPrompt: string\n            extraImageUrls?: string[]\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/modify-asset-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'character',\n                    ...params,\n                }),\n            }, 'Failed to modify image')\n        },\n        onMutate: ({ appearanceId }) => {\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'CharacterAppearance',\n                targetId: appearanceId,\n                intent: 'modify',\n            })\n        },\n        onError: (_error, { appearanceId }) => {\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'CharacterAppearance',\n                targetId: appearanceId,\n            })\n        },\n        onSettled: invalidateProjectAssetAndProjectData,\n    })\n}\n\n/**\n * 修改项目场景图片\n */\n\nexport function useRegenerateCharacterGroup(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            characterId,\n            appearanceId,\n            count,\n        }: {\n            characterId: string\n            appearanceId: string\n            count?: number\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/regenerate-group`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'character',\n                    id: characterId,\n                    appearanceId,\n                    count,\n                })\n            }, 'Failed to regenerate group')\n        },\n        onMutate: ({ appearanceId }) => {\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'CharacterAppearance',\n                targetId: appearanceId,\n                intent: 'regenerate',\n            })\n        },\n        onError: (_error, { appearanceId }) => {\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'CharacterAppearance',\n                targetId: appearanceId,\n            })\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 重新生成单张角色图片\n */\n\nexport function useRegenerateSingleCharacterImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            characterId,\n            appearanceId,\n            imageIndex,\n        }: {\n            characterId: string\n            appearanceId: string\n            imageIndex: number\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/regenerate-single-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'character',\n                    id: characterId,\n                    appearanceId,\n                    imageIndex,\n                })\n            }, 'Failed to regenerate image')\n        },\n        onMutate: ({ appearanceId }) => {\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'CharacterAppearance',\n                targetId: appearanceId,\n                intent: 'regenerate',\n            })\n        },\n        onError: (_error, { appearanceId }) => {\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'CharacterAppearance',\n                targetId: appearanceId,\n            })\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 重新生成场景组图片\n */\n\nexport function useUpdateProjectAppearanceDescription(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            characterId,\n            appearanceId,\n            description,\n            descriptionIndex,\n        }: {\n            characterId: string\n            appearanceId: string\n            description: string\n            descriptionIndex?: number\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/character/appearance`, {\n                method: 'PATCH',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    characterId,\n                    appearanceId,\n                    description,\n                    descriptionIndex: typeof descriptionIndex === 'number' ? descriptionIndex : 0,\n                }),\n            }, 'Failed to update appearance description')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\nexport function useBatchGenerateCharacterImages(projectId: string) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async (items: Array<{ characterId: string; appearanceId: string }>) => {\n            const results = await Promise.allSettled(\n                items.map(item =>\n                    apiFetch(`/api/novel-promotion/${projectId}/generate-image`, {\n                        method: 'POST',\n                        headers: { 'Content-Type': 'application/json' },\n                        body: JSON.stringify({\n                            type: 'character',\n                            id: item.characterId,\n                            appearanceId: item.appearanceId\n                        })\n                    })\n                )\n            )\n            return results\n        },\n        onMutate: (items) => {\n            for (const item of items) {\n                upsertTaskTargetOverlay(queryClient, {\n                    projectId,\n                    targetType: 'CharacterAppearance',\n                    targetId: item.appearanceId,\n                    intent: 'generate',\n                })\n            }\n        },\n        onError: (_error, items) => {\n            for (const item of items) {\n                clearTaskTargetOverlay(queryClient, {\n                    projectId,\n                    targetType: 'CharacterAppearance',\n                    targetId: item.appearanceId,\n                })\n            }\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        }\n    })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/character-profile-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport {\n  invalidateQueryTemplates,\n  requestJsonWithError,\n  requestTaskResponseWithError,\n} from './mutation-shared'\n\nexport function useUpdateProjectCharacterIntroduction(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            characterId,\n            introduction,\n        }: {\n            characterId: string\n            introduction: string\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/character`, {\n                method: 'PATCH',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ characterId, introduction }),\n            }, 'Failed to update character introduction')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * AI 修改项目角色形象描述\n */\n\nexport function useAiModifyProjectAppearanceDescription(projectId: string) {\n    return useMutation({\n        mutationFn: async ({\n            characterId,\n            appearanceId,\n            currentDescription,\n            modifyInstruction,\n        }: {\n            characterId: string\n            appearanceId: string\n            currentDescription: string\n            modifyInstruction: string\n        }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/ai-modify-appearance`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({\n                        characterId,\n                        appearanceId,\n                        currentDescription,\n                        modifyInstruction,\n                    }),\n                },\n                'Failed to modify appearance description',\n            )\n            return resolveTaskResponse<{ modifiedDescription?: string }>(response)\n        },\n    })\n}\n\n/**\n * AI 修改项目场景描述\n */\n\nexport function useAiCreateProjectCharacter(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: { userInstruction: string }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/ai-create-character`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'Failed to design character',\n            )\n            return await resolveTaskResponse<{ prompt?: string }>(response)\n        },\n    })\n}\n\n/**\n * 上传临时媒体（项目）\n */\n\nexport function useUploadProjectTempMedia() {\n    return useMutation({\n        mutationFn: async (payload: { imageBase64?: string; base64?: string; extension?: string; type?: string }) => {\n            return await requestJsonWithError<{ success: boolean; url?: string; key?: string }>(\n                '/api/asset-hub/upload-temp',\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                '上传失败',\n            )\n        },\n    })\n}\n\n/**\n * 参考图提取角色描述（项目）\n */\n\nexport function useExtractProjectReferenceCharacterDescription(projectId: string) {\n    return useMutation({\n        mutationFn: async (referenceImageUrls: string[]) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/reference-to-character`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({\n                        referenceImageUrls,\n                        extractOnly: true,\n                    }),\n                },\n                'Failed to extract character description',\n            )\n            return resolveTaskResponse<{ description?: string }>(response)\n        },\n    })\n}\n\n/**\n * 创建项目角色\n */\n\nexport function useCreateProjectCharacter(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async (payload: {\n            name: string\n            description: string\n            generateFromReference?: boolean\n            referenceImageUrls?: string[]\n            customDescription?: string\n            count?: number\n        }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/character`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'Failed to create character',\n            ),\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * 为项目角色添加子形象\n */\n\nexport function useCreateProjectCharacterAppearance(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async (payload: {\n            characterId: string\n            changeReason: string\n            description: string\n        }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/character/appearance`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'Failed to create character appearance',\n            ),\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * 全局资产分析（项目）\n */\n\nexport function useConfirmProjectCharacterSelection(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n    return useMutation({\n        mutationFn: async ({ characterId, appearanceId }: { characterId: string; appearanceId: string }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/character/confirm-selection`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ characterId, appearanceId }),\n                },\n                '确认选择失败',\n            ),\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 确认场景候选图片选择\n */\n\nexport function useConfirmProjectCharacterProfile(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n    return useMutation({\n        mutationFn: async (payload: {\n            characterId: string\n            profileData?: unknown\n            generateImage?: boolean\n        }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/character-profile/confirm`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                '确认失败',\n            )\n            return await resolveTaskResponse<{\n                success?: boolean\n                character?: {\n                    id?: string\n                    profileConfirmed?: boolean\n                    appearances?: Array<{\n                        id?: number\n                        descriptions?: string[]\n                    }>\n                }\n            }>(response)\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 批量确认角色档案\n */\n\nexport function useBatchConfirmProjectCharacterProfiles(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async () => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/character-profile/batch-confirm`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                },\n                '批量确认失败',\n            )\n            return await resolveTaskResponse<{\n                success?: boolean\n                count?: number\n                message?: string\n            }>(response)\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/character-voice-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport {\n  invalidateQueryTemplates,\n  requestJsonWithError,\n} from './mutation-shared'\n\nexport function useUploadProjectCharacterVoice(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({ file, characterId }: { file: File; characterId: string }) => {\n            const formData = new FormData()\n            formData.append('file', file)\n            formData.append('characterId', characterId)\n\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/character-voice`, {\n                method: 'POST',\n                body: formData\n            }, 'Failed to upload voice')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\nexport function useUpdateProjectCharacterVoiceSettings(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n    return useMutation({\n        mutationFn: async ({\n            characterId,\n            voiceType,\n            voiceId,\n            customVoiceUrl,\n        }: {\n            characterId: string\n            voiceType: 'qwen-designed' | 'uploaded' | 'custom' | null\n            voiceId?: string\n            customVoiceUrl?: string\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/character-voice`, {\n                method: 'PATCH',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ characterId, voiceType, voiceId, customVoiceUrl }),\n            }, '更新音色失败')\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 保存 AI 设计音色到角色\n */\n\nexport function useSaveProjectDesignedVoice(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            characterId,\n            voiceId,\n            audioBase64,\n        }: {\n            characterId: string\n            voiceId: string\n            audioBase64: string\n        }) => {\n            return await requestJsonWithError<{ audioUrl?: string }>(`/api/novel-promotion/${projectId}/character-voice`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    characterId,\n                    voiceDesign: { voiceId, audioBase64 },\n                }),\n            }, '保存失败')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/index.ts",
    "content": "/**\n * Mutations 模块导出\n */\n\n// ==================== Asset Hub (全局资产) ====================\nexport {\n    // 角色相关\n    useGenerateCharacterImage,\n    useModifyCharacterImage,\n    useSelectCharacterImage,\n    useUndoCharacterImage,\n    useUploadCharacterImage,\n    useDeleteCharacter,\n    useDeleteCharacterAppearance,\n    useUploadCharacterVoice,\n    // 场景相关\n    useGenerateLocationImage,\n    useModifyLocationImage,\n    useSelectLocationImage,\n    useUndoLocationImage,\n    useUploadLocationImage,\n    useDeleteLocation,\n    // 音色相关\n    useDeleteVoice,\n    // 编辑相关\n    useUpdateCharacterName,\n    useUpdateLocationName,\n    useUpdateCharacterAppearanceDescription,\n    useUpdateLocationSummary,\n    useAiModifyCharacterDescription,\n    useAiModifyLocationDescription,\n    useUploadAssetHubTempMedia,\n    useAiDesignCharacter,\n    useExtractAssetHubReferenceCharacterDescription,\n    useCreateAssetHubCharacter,\n} from './useAssetHubMutations'\n\n// ==================== Project (项目资产) ====================\nexport * from './useCharacterMutations'\nexport * from './useLocationMutations'\nexport * from './useStoryboardMutations'\nexport * from './useVideoMutations'\nexport * from './useVoiceMutations'\nexport * from './useProjectConfigMutations'\nexport * from './useEpisodeMutations'\n"
  },
  {
    "path": "src/lib/query/mutations/location-image-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { useRef } from 'react'\nimport type { Location, Project } from '@/types/project'\nimport { queryKeys } from '../keys'\nimport type { ProjectAssetsData } from '../hooks/useProjectAssets'\nimport {\n    clearTaskTargetOverlay,\n    upsertTaskTargetOverlay,\n} from '../task-target-overlay'\nimport {\n    invalidateQueryTemplates,\n    requestJsonWithError,\n} from './mutation-shared'\n\ninterface SelectProjectLocationImageContext {\n    previousAssets: ProjectAssetsData | undefined\n    previousProject: Project | undefined\n    targetKey: string\n    requestId: number\n}\n\nfunction applyLocationSelectionToLocations(\n    locations: Location[],\n    locationId: string,\n    selectedIndex: number | null,\n): Location[] {\n    return locations.map((location) => {\n        if (location.id !== locationId) return location\n        const selectedImageId =\n            selectedIndex === null\n                ? null\n                : (location.images || []).find((image) => image.imageIndex === selectedIndex)?.id ?? null\n        return {\n            ...location,\n            selectedImageId,\n            images: (location.images || []).map((image) => ({\n                ...image,\n                isSelected: selectedIndex !== null && image.imageIndex === selectedIndex,\n            })),\n        }\n    })\n}\n\nfunction applyLocationSelectionToAssets(\n    previous: ProjectAssetsData | undefined,\n    locationId: string,\n    selectedIndex: number | null,\n): ProjectAssetsData | undefined {\n    if (!previous) return previous\n    return {\n        ...previous,\n        locations: applyLocationSelectionToLocations(previous.locations || [], locationId, selectedIndex),\n    }\n}\n\nfunction applyLocationSelectionToProject(\n    previous: Project | undefined,\n    locationId: string,\n    selectedIndex: number | null,\n): Project | undefined {\n    if (!previous?.novelPromotionData) return previous\n    const currentLocations = previous.novelPromotionData.locations || []\n    return {\n        ...previous,\n        novelPromotionData: {\n            ...previous.novelPromotionData,\n            locations: applyLocationSelectionToLocations(currentLocations, locationId, selectedIndex),\n        },\n    }\n}\n\nexport function useGenerateProjectLocationImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            locationId,\n            imageIndex,\n            artStyle,\n            count,\n        }: {\n            locationId: string\n            imageIndex?: number\n            artStyle?: string\n            count?: number\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/generate-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(buildProjectLocationGenerateImageBody({\n                    locationId,\n                    imageIndex,\n                    artStyle,\n                    count,\n                }))\n            }, 'Failed to generate image')\n        },\n        onMutate: ({ locationId }) => {\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'LocationImage',\n                targetId: locationId,\n                intent: 'generate',\n            })\n        },\n        onError: (_error, { locationId }) => {\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'LocationImage',\n                targetId: locationId,\n            })\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\nexport function buildProjectLocationGenerateImageBody(input: {\n    locationId: string\n    imageIndex?: number\n    artStyle?: string\n    count?: number\n}) {\n    return {\n        type: 'location' as const,\n        id: input.locationId,\n        imageIndex: input.imageIndex,\n        artStyle: input.artStyle,\n        count: input.count,\n    }\n}\n\n/**\n * 上传项目场景图片\n */\n\nexport function useUploadProjectLocationImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            file, locationId, imageIndex, labelText\n        }: {\n            file: File\n            locationId: string\n            imageIndex?: number\n            labelText?: string\n        }) => {\n            const formData = new FormData()\n            formData.append('file', file)\n            formData.append('type', 'location')\n            formData.append('id', locationId)\n            if (imageIndex !== undefined) formData.append('imageIndex', imageIndex.toString())\n            if (labelText) formData.append('labelText', labelText)\n\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/upload-asset-image`, {\n                method: 'POST',\n                body: formData\n            }, 'Failed to upload image')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * 修改项目角色图片\n */\n\nexport function useModifyProjectLocationImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssetAndProjectData = () =>\n        invalidateQueryTemplates(queryClient, [\n            queryKeys.projectAssets.all(projectId),\n            queryKeys.projectData(projectId),\n        ])\n\n    return useMutation({\n        mutationFn: async (params: {\n            locationId: string\n            imageIndex: number\n            modifyPrompt: string\n            extraImageUrls?: string[]\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/modify-asset-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'location',\n                    ...params,\n                }),\n            }, 'Failed to modify image')\n        },\n        onMutate: ({ locationId }) => {\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'LocationImage',\n                targetId: locationId,\n                intent: 'modify',\n            })\n        },\n        onError: (_error, { locationId }) => {\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'LocationImage',\n                targetId: locationId,\n            })\n        },\n        onSettled: invalidateProjectAssetAndProjectData,\n    })\n}\n\n/**\n * 重新生成角色组图片\n */\n\nexport function useRegenerateLocationGroup(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({ locationId, count }: { locationId: string; count?: number }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/regenerate-group`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'location',\n                    id: locationId,\n                    count,\n                })\n            }, 'Failed to regenerate group')\n        },\n        onMutate: ({ locationId }) => {\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'LocationImage',\n                targetId: locationId,\n                intent: 'regenerate',\n            })\n        },\n        onError: (_error, { locationId }) => {\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'LocationImage',\n                targetId: locationId,\n            })\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 重新生成单张场景图片\n */\n\nexport function useRegenerateSingleLocationImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({ locationId, imageIndex }: { locationId: string; imageIndex: number }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/regenerate-single-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'location',\n                    id: locationId,\n                    imageIndex,\n                })\n            }, 'Failed to regenerate image')\n        },\n        onMutate: ({ locationId }) => {\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'LocationImage',\n                targetId: locationId,\n                intent: 'regenerate',\n            })\n        },\n        onError: (_error, { locationId }) => {\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'LocationImage',\n                targetId: locationId,\n            })\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 选择项目场景图片\n */\n\nexport function useSelectProjectLocationImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const latestRequestIdByTargetRef = useRef<Record<string, number>>({})\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            locationId, imageIndex\n        }: {\n            locationId: string\n            imageIndex: number | null\n            confirm?: boolean\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/select-location-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    locationId,\n                    selectedIndex: imageIndex,\n                })\n            }, 'Failed to select image')\n        },\n        onMutate: async (variables): Promise<SelectProjectLocationImageContext> => {\n            const targetKey = variables.locationId\n            const requestId = (latestRequestIdByTargetRef.current[targetKey] ?? 0) + 1\n            latestRequestIdByTargetRef.current[targetKey] = requestId\n\n            const assetsQueryKey = queryKeys.projectAssets.all(projectId)\n            const projectQueryKey = queryKeys.projectData(projectId)\n\n            await queryClient.cancelQueries({ queryKey: assetsQueryKey })\n            await queryClient.cancelQueries({ queryKey: projectQueryKey })\n\n            const previousAssets = queryClient.getQueryData<ProjectAssetsData>(assetsQueryKey)\n            const previousProject = queryClient.getQueryData<Project>(projectQueryKey)\n\n            queryClient.setQueryData<ProjectAssetsData | undefined>(assetsQueryKey, (previous) =>\n                applyLocationSelectionToAssets(previous, variables.locationId, variables.imageIndex),\n            )\n            queryClient.setQueryData<Project | undefined>(projectQueryKey, (previous) =>\n                applyLocationSelectionToProject(previous, variables.locationId, variables.imageIndex),\n            )\n\n            return {\n                previousAssets,\n                previousProject,\n                targetKey,\n                requestId,\n            }\n        },\n        onError: (_error, _variables, context) => {\n            if (!context) return\n            const latestRequestId = latestRequestIdByTargetRef.current[context.targetKey]\n            if (latestRequestId !== context.requestId) return\n            queryClient.setQueryData(queryKeys.projectAssets.all(projectId), context.previousAssets)\n            queryClient.setQueryData(queryKeys.projectData(projectId), context.previousProject)\n        },\n        onSettled: (_data, _error, variables) => {\n            if (variables.confirm) {\n                void invalidateProjectAssets()\n            }\n        },\n    })\n}\n\n/**\n * 撤回项目场景图片\n */\n\nexport function useUndoProjectLocationImage(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async (locationId: string) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/undo-regenerate`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    type: 'location',\n                    id: locationId\n                })\n            }, 'Failed to undo image')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/location-management-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { logError as _ulogError } from '@/lib/logging/core'\nimport type { Project } from '@/types/project'\nimport { queryKeys } from '../keys'\nimport type { ProjectAssetsData } from '../hooks/useProjectAssets'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport { apiFetch } from '@/lib/api-fetch'\nimport {\n    clearTaskTargetOverlay,\n    upsertTaskTargetOverlay,\n} from '../task-target-overlay'\nimport {\n    invalidateQueryTemplates,\n    requestJsonWithError,\n    requestTaskResponseWithError,\n    requestVoidWithError,\n} from './mutation-shared'\n\ninterface DeleteProjectLocationContext {\n    previousAssets: ProjectAssetsData | undefined\n    previousProject: Project | undefined\n}\n\nfunction removeLocationFromAssets(\n    previous: ProjectAssetsData | undefined,\n    locationId: string,\n): ProjectAssetsData | undefined {\n    if (!previous) return previous\n    return {\n        ...previous,\n        locations: (previous.locations || []).filter((location) => location.id !== locationId),\n    }\n}\n\nfunction removeLocationFromProject(\n    previous: Project | undefined,\n    locationId: string,\n): Project | undefined {\n    if (!previous?.novelPromotionData) return previous\n    const currentLocations = previous.novelPromotionData.locations || []\n    return {\n        ...previous,\n        novelPromotionData: {\n            ...previous.novelPromotionData,\n            locations: currentLocations.filter((location) => location.id !== locationId),\n        },\n    }\n}\n\nexport function useDeleteProjectLocation(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async (locationId: string) => {\n            await requestVoidWithError(\n                `/api/novel-promotion/${projectId}/location?id=${encodeURIComponent(locationId)}`,\n                { method: 'DELETE' },\n                'Failed to delete location',\n            )\n        },\n        onMutate: async (locationId): Promise<DeleteProjectLocationContext> => {\n            const assetsQueryKey = queryKeys.projectAssets.all(projectId)\n            const projectQueryKey = queryKeys.projectData(projectId)\n\n            await queryClient.cancelQueries({ queryKey: assetsQueryKey })\n            await queryClient.cancelQueries({ queryKey: projectQueryKey })\n\n            const previousAssets = queryClient.getQueryData<ProjectAssetsData>(assetsQueryKey)\n            const previousProject = queryClient.getQueryData<Project>(projectQueryKey)\n\n            queryClient.setQueryData<ProjectAssetsData | undefined>(assetsQueryKey, (previous) =>\n                removeLocationFromAssets(previous, locationId),\n            )\n            queryClient.setQueryData<Project | undefined>(projectQueryKey, (previous) =>\n                removeLocationFromProject(previous, locationId),\n            )\n\n            return {\n                previousAssets,\n                previousProject,\n            }\n        },\n        onError: (_error, _locationId, context) => {\n            if (!context) return\n            queryClient.setQueryData(queryKeys.projectAssets.all(projectId), context.previousAssets)\n            queryClient.setQueryData(queryKeys.projectData(projectId), context.previousProject)\n        },\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 更新项目场景名字\n */\n\nexport function useUpdateProjectLocationName(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({ locationId, name }: { locationId: string; name: string }) => {\n            const res = await requestJsonWithError(`/api/novel-promotion/${projectId}/location`, {\n                method: 'PATCH',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ locationId, name })\n            }, 'Failed to update location name')\n\n            // 等待图片标签更新完成，确保 onSuccess invalidate 后前端能立即看到新标签\n            try {\n                await apiFetch(`/api/novel-promotion/${projectId}/update-asset-label`, {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({\n                        type: 'location',\n                        id: locationId,\n                        newName: name\n                    })\n                })\n            } catch (e) {\n                _ulogError('更新图片标签失败:', e)\n            }\n\n            return res\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * 更新项目角色形象描述\n */\n\nexport function useUpdateProjectLocationDescription(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            locationId,\n            description,\n            imageIndex,\n        }: {\n            locationId: string\n            description: string\n            imageIndex?: number\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/location`, {\n                method: 'PATCH',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                    locationId,\n                    imageIndex: typeof imageIndex === 'number' ? imageIndex : 0,\n                    description,\n                }),\n            }, 'Failed to update location description')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * 更新项目角色介绍\n */\n\nexport function useAiModifyProjectLocationDescription(projectId: string) {\n    return useMutation({\n        mutationFn: async ({\n            locationId,\n            currentDescription,\n            modifyInstruction,\n            imageIndex,\n        }: {\n            locationId: string\n            currentDescription: string\n            modifyInstruction: string\n            imageIndex?: number\n        }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/ai-modify-location`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({\n                        locationId,\n                        imageIndex: typeof imageIndex === 'number' ? imageIndex : 0,\n                        currentDescription,\n                        modifyInstruction,\n                    }),\n                },\n                'Failed to modify location description',\n            )\n            return resolveTaskResponse<{ prompt?: string; modifiedDescription?: string }>(response)\n        },\n    })\n}\n\n/**\n * AI 设计项目场景描述\n */\n\nexport function useAiCreateProjectLocation(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: { userInstruction: string }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/ai-create-location`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'Failed to design location',\n            )\n            return await resolveTaskResponse<{ prompt?: string }>(response)\n        },\n    })\n}\n\n/**\n * 创建项目场景\n */\n\nexport function useCreateProjectLocation(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async (payload: {\n            name: string\n            description: string\n            artStyle?: string\n            count?: number\n        }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/location`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'Failed to create location',\n            ),\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * AI 设计项目角色文案\n */\n\nexport function useConfirmProjectLocationSelection(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n    return useMutation({\n        mutationFn: async ({ locationId }: { locationId: string }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/location/confirm-selection`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ locationId }),\n                },\n                '确认选择失败',\n            ),\n        onSettled: invalidateProjectAssets,\n    })\n}\n\n/**\n * 确认角色档案并触发描述生成\n */\n\nexport function useBatchGenerateLocationImages(projectId: string) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async (locationIds: string[]) => {\n            const results = await Promise.allSettled(\n                locationIds.map(locationId =>\n                    apiFetch(`/api/novel-promotion/${projectId}/generate-image`, {\n                        method: 'POST',\n                        headers: { 'Content-Type': 'application/json' },\n                        body: JSON.stringify({\n                            type: 'location',\n                            id: locationId\n                        })\n                    })\n                )\n            )\n            return results\n        },\n        onMutate: (locationIds) => {\n            for (const locationId of locationIds) {\n                upsertTaskTargetOverlay(queryClient, {\n                    projectId,\n                    targetType: 'LocationImage',\n                    targetId: locationId,\n                    intent: 'generate',\n                })\n            }\n        },\n        onError: (_error, locationIds) => {\n            for (const locationId of locationIds) {\n                clearTaskTargetOverlay(queryClient, {\n                    projectId,\n                    targetType: 'LocationImage',\n                    targetId: locationId,\n                })\n            }\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        }\n    })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/mutation-shared.ts",
    "content": "import type { QueryClient, QueryKey } from '@tanstack/react-query'\nimport { apiFetch } from '@/lib/api-fetch'\nimport { resolveTaskErrorMessage } from '@/lib/task/error-message'\n\nexport { getPageLocale } from '@/lib/api-fetch'\n\nexport type MutationRequestError = Error & {\n  status?: number\n  payload?: Record<string, unknown>\n  detail?: string\n}\n\nasync function parseJsonSafe(response: Response): Promise<Record<string, unknown>> {\n  const data = await response.json().catch(() => ({}))\n  if (data && typeof data === 'object') return data as Record<string, unknown>\n  return {}\n}\n\nfunction createRequestError(\n  status: number,\n  payload: Record<string, unknown>,\n  fallbackMessage: string,\n): MutationRequestError {\n  const error = new Error(resolveTaskErrorMessage(payload, fallbackMessage)) as MutationRequestError\n  error.status = status\n  error.payload = payload\n  if (typeof payload.detail === 'string') {\n    error.detail = payload.detail\n  }\n  return error\n}\n\nexport async function requestJsonWithError<T>(\n  input: RequestInfo | URL,\n  init: RequestInit,\n  fallbackMessage: string,\n): Promise<T> {\n  const response = await apiFetch(input, init)\n  const data = await parseJsonSafe(response)\n  if (!response.ok) {\n    throw createRequestError(response.status, data, fallbackMessage)\n  }\n  return data as T\n}\n\nexport async function requestVoidWithError(\n  input: RequestInfo | URL,\n  init: RequestInit,\n  fallbackMessage: string,\n): Promise<void> {\n  const response = await apiFetch(input, init)\n  if (response.ok) return\n  const data = await parseJsonSafe(response)\n  throw createRequestError(response.status, data, fallbackMessage)\n}\n\nexport async function requestTaskResponseWithError(\n  input: RequestInfo | URL,\n  init: RequestInit,\n  fallbackMessage: string,\n): Promise<Response> {\n  const response = await apiFetch(input, init)\n  if (response.ok) return response\n  const data = await parseJsonSafe(response)\n  throw createRequestError(response.status, data, fallbackMessage)\n}\n\nexport async function requestBlobWithError(\n  input: RequestInfo | URL,\n  init: RequestInit,\n  fallbackMessage: string,\n): Promise<Blob> {\n  const response = await apiFetch(input, init)\n  if (response.ok) {\n    return await response.blob()\n  }\n\n  const data = await parseJsonSafe(response)\n  throw createRequestError(response.status, data, fallbackMessage)\n}\n\nexport async function invalidateQueryTemplates(\n  queryClient: QueryClient,\n  templates: QueryKey[],\n): Promise<void> {\n  await Promise.all(\n    templates.map((queryKey) => queryClient.invalidateQueries({ queryKey })),\n  )\n}\n"
  },
  {
    "path": "src/lib/query/mutations/storyboard-panel-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport { resolveTaskErrorMessage } from '@/lib/task/error-message'\nimport { apiFetch } from '@/lib/api-fetch'\nimport {\n    clearTaskTargetOverlay,\n    upsertTaskTargetOverlay,\n} from '../task-target-overlay'\nimport {\n    invalidateQueryTemplates,\n    requestJsonWithError,\n    requestTaskResponseWithError,\n} from './mutation-shared'\n\nexport function useRegenerateProjectPanelImage(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async ({ panelId, count }: { panelId: string; count?: number }) => {\n            const res = await apiFetch(`/api/novel-promotion/${projectId}/regenerate-panel-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ panelId, count: count ?? 1 }),\n            })\n            if (!res.ok) {\n                const error = await res.json().catch(() => ({}))\n                if (res.status === 402) throw new Error('余额不足，请充值后继续使用')\n                if (res.status === 400 && String(error?.error || '').includes('敏感')) {\n                    throw new Error(resolveTaskErrorMessage(error, '提示词包含敏感内容'))\n                }\n                if (res.status === 429 || error?.code === 'RATE_LIMIT') {\n                    const retryAfter = error?.retryAfter || 60\n                    throw new Error(`API 配额超限，请等待 ${retryAfter} 秒后重试`)\n                }\n                throw new Error(resolveTaskErrorMessage(error, '重新生成失败'))\n            }\n            return res.json()\n        },\n        onMutate: ({ panelId }) => {\n            upsertTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'NovelPromotionPanel',\n                targetId: panelId,\n                intent: 'regenerate',\n            })\n        },\n        onError: (_error, { panelId }) => {\n            clearTaskTargetOverlay(queryClient, {\n                projectId,\n                targetType: 'NovelPromotionPanel',\n                targetId: panelId,\n            })\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 修改镜头图片（storyboard）\n */\n\nexport function useModifyProjectStoryboardImage(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async (payload: {\n            storyboardId: string\n            panelIndex: number\n            modifyPrompt: string\n            extraImageUrls: string[]\n            selectedAssets: Array<{\n                id: string\n                name: string\n                type: 'character' | 'location'\n                imageUrl: string | null\n                appearanceId?: number\n                appearanceName?: string\n            }>\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/modify-storyboard-image`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(payload),\n            }, '修改失败')\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 下载剧集全部图片（zip）\n */\n\nexport function useDownloadProjectImages(projectId: string) {\n    return useMutation({\n        mutationFn: async ({ episodeId }: { episodeId: string }) => {\n            const response = await apiFetch(`/api/novel-promotion/${projectId}/download-images?episodeId=${episodeId}`)\n            if (!response.ok) {\n                const error = await response.json().catch(() => ({}))\n                throw new Error(resolveTaskErrorMessage(error, '下载失败'))\n            }\n            return response.blob()\n        },\n    })\n}\n\n/**\n * 更新分镜 panel\n */\n\nexport function useUpdateProjectPanel(projectId: string) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async (payload: Record<string, unknown>) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/panel`,\n                {\n                    method: 'PUT',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                '保存失败',\n            ),\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 选择/取消镜头候选图（项目）\n */\n\nexport function useCreateProjectPanel(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async (payload: Record<string, unknown>) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/panel`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(payload),\n            }, '添加失败')\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 删除 panel\n */\n\nexport function useDeleteProjectPanel(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async ({ panelId }: { panelId: string }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/panel?panelId=${panelId}`, {\n                method: 'DELETE',\n            }, '删除失败')\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 删除 storyboard group\n */\n\nexport function useDeleteProjectStoryboardGroup(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async ({ storyboardId }: { storyboardId: string }) => {\n            return await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/storyboard-group?storyboardId=${storyboardId}`,\n                { method: 'DELETE' },\n                '删除失败',\n            )\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 异步重生成文字分镜\n */\n\nexport function useRegenerateProjectStoryboardText(projectId: string) {\n    return useMutation({\n        mutationFn: async ({ storyboardId }: { storyboardId: string }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/regenerate-storyboard-text`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ storyboardId, async: true }),\n                },\n                'regenerate storyboard text failed',\n            )\n            return resolveTaskResponse(response)\n        },\n    })\n}\n\n/**\n * 新增 storyboard group\n */\n\nexport function useCreateProjectStoryboardGroup(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async (payload: { episodeId: string; insertIndex: number }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/storyboard-group`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(payload),\n            }, '添加失败')\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 移动 storyboard group\n */\n\nexport function useMoveProjectStoryboardGroup(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async (payload: { episodeId: string; clipId: string; direction: 'up' | 'down' }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/storyboard-group`, {\n                method: 'PUT',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(payload),\n            }, '移动失败')\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 插入 panel（异步）\n */\n\nexport function useInsertProjectPanel(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async (payload: { storyboardId: string; insertAfterPanelId: string; userInput: string }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/insert-panel`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(payload),\n            }, '插入分镜失败')\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 生成镜头变体（异步）\n */\n\nexport function useCreateProjectPanelVariant(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async (payload: {\n            storyboardId: string\n            insertAfterPanelId: string\n            sourcePanelId: string\n            variant: {\n                title: string\n                description: string\n                shot_type: string\n                camera_move: string\n                video_prompt: string\n            }\n            includeCharacterAssets: boolean\n            includeLocationAsset: boolean\n        }) => {\n            return await requestJsonWithError<{ panelId: string }>(`/api/novel-promotion/${projectId}/panel-variant`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(payload),\n            }, '生成变体失败')\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n\n/**\n * 清除 storyboard 错误\n */\nexport function useClearProjectStoryboardError(projectId: string) {\n    const queryClient = useQueryClient()\n    return useMutation({\n        mutationFn: async ({ storyboardId }: { storyboardId: string }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/storyboards`,\n                {\n                    method: 'PATCH',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ storyboardId }),\n                },\n                '清除分镜错误失败',\n            ),\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n        },\n    })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/storyboard-prompt-mutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport {\n  invalidateQueryTemplates,\n  requestJsonWithError,\n  requestTaskResponseWithError,\n} from './mutation-shared'\n\nexport function useAiModifyProjectShotPrompt(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: {\n            currentPrompt: string\n            currentVideoPrompt?: string\n            modifyInstruction: string\n            referencedAssets: Array<{\n                id: string\n                name: string\n                description: string\n                type: 'character' | 'location'\n            }>\n        }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/ai-modify-shot-prompt`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'Failed to modify shot prompt',\n            )\n            return await resolveTaskResponse<{\n                modifiedImagePrompt: string\n                modifiedVideoPrompt?: string\n                referencedAssets?: Array<{\n                    id: string\n                    name: string\n                    description: string\n                    type: 'character' | 'location'\n                }>\n            }>(response)\n        },\n    })\n}\n\n/**\n * 设计音色（项目）\n */\n\nexport function useAnalyzeProjectShotVariants(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: { panelId: string }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/analyze-shot-variants`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                '分析失败',\n            )\n            return await resolveTaskResponse<{\n                success: boolean\n                suggestions: Array<{\n                    id: number\n                    title: string\n                    description: string\n                    shot_type: string\n                    camera_move: string\n                    video_prompt: string\n                    creative_score: number\n                }>\n                panelInfo?: {\n                    panelNumber?: string | number | null\n                    imageUrl?: string | null\n                    description?: string | null\n                }\n            }>(response)\n        },\n    })\n}\n\n/**\n * 更新摄影规则（项目）\n */\n\nexport function useUpdateProjectPhotographyPlan(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: {\n            storyboardId: string\n            photographyPlan: string\n        }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/photography-plan`,\n                {\n                    method: 'PUT',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                '保存摄影规则失败',\n            ),\n    })\n}\n\n/**\n * 更新镜头演技指导（项目）\n */\n\nexport function useUpdateProjectPanelActingNotes(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: {\n            storyboardId: string\n            panelIndex: number\n            actingNotes: string\n        }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/panel`,\n                {\n                    method: 'PUT',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                '保存演技指导失败',\n            ),\n    })\n}\n\n/**\n * 选择/取消镜头候选图（项目）\n */\n\nexport function useSelectProjectPanelCandidate(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async (payload: {\n            panelId: string\n            action: 'select' | 'cancel'\n            selectedImageUrl?: string\n        }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}/panel/select-candidate`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'Failed to select panel candidate',\n            ),\n        onSuccess: invalidateProjectAssets,\n    })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/task-mutations.ts",
    "content": "'use client'\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { requestJsonWithError } from './mutation-shared'\n\nexport function useDismissFailedTasks(projectId: string) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async (taskIds: string[]) => {\n            return await requestJsonWithError<{ success: boolean; dismissed: number }>(\n                '/api/tasks/dismiss',\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ taskIds }),\n                },\n                '关闭错误失败',\n            )\n        },\n        onSuccess: () => {\n            queryClient.invalidateQueries({ queryKey: queryKeys.tasks.all(projectId), exact: false })\n        },\n    })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/useAssetHubMutations.ts",
    "content": "export * from './asset-hub-mutations-runtime'\n"
  },
  {
    "path": "src/lib/query/mutations/useCharacterMutations.core.ts",
    "content": "export * from './character-base-mutations'\nexport * from './character-image-ops-mutations'\nexport * from './character-voice-mutations'\nexport * from './character-profile-mutations'\n"
  },
  {
    "path": "src/lib/query/mutations/useCharacterMutations.ts",
    "content": "export * from './useCharacterMutations.core'\n"
  },
  {
    "path": "src/lib/query/mutations/useEpisodeMutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport type { Project } from '@/types/project'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport { queryKeys } from '../keys'\nimport {\n  invalidateQueryTemplates,\n  requestBlobWithError,\n  requestJsonWithError,\n  requestTaskResponseWithError,\n} from './mutation-shared'\n\n/**\n * 获取项目剧集列表\n */\nexport function useListProjectEpisodes(projectId: string) {\n  return useMutation({\n    mutationFn: async () =>\n      await requestJsonWithError<{\n        episodes?: Array<{\n          episodeNumber?: number\n          name?: string\n          description?: string\n          novelText?: string\n        }>\n      }>(`/api/novel-promotion/${projectId}/episodes`, { method: 'GET' }, '获取剧集失败'),\n  })\n}\n\n/**\n * AI 智能分割剧集\n */\nexport function useSplitProjectEpisodes(projectId: string) {\n  return useMutation({\n    mutationFn: async (payload: { content: string; async?: boolean }) => {\n      const response = await requestTaskResponseWithError(\n        `/api/novel-promotion/${projectId}/episodes/split`,\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify(payload),\n        },\n        '分割失败',\n      )\n      return resolveTaskResponse<{\n        episodes: Array<{\n          number: number\n          title: string\n          summary: string\n          content: string\n          wordCount: number\n        }>\n      }>(response)\n    },\n  })\n}\n\n/**\n * 使用章节标记分割剧集\n */\nexport function useSplitProjectEpisodesByMarkers(projectId: string) {\n  return useMutation({\n    mutationFn: async (payload: { content: string }) =>\n      await requestJsonWithError<{\n        episodes?: Array<{\n          number: number\n          title: string\n          summary: string\n          content: string\n          wordCount: number\n        }>\n      }>(\n        `/api/novel-promotion/${projectId}/episodes/split-by-markers`,\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify(payload),\n        },\n        '分割失败',\n      ),\n  })\n}\n\n/**\n * 批量保存项目剧集\n */\nexport function useSaveProjectEpisodesBatch(projectId: string) {\n  return useMutation({\n    mutationFn: async (payload: {\n      episodes: Array<{\n        name: string\n        description?: string\n        novelText?: string\n      }>\n      clearExisting?: boolean\n      importStatus?: 'pending' | 'completed'\n      triggerGlobalAnalysis?: boolean\n    }) =>\n      await requestJsonWithError(\n        `/api/novel-promotion/${projectId}/episodes/batch`,\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify(payload),\n        },\n        '保存剧集失败',\n      ),\n  })\n}\n\n/**\n * 更新剧集字段\n */\nexport function useUpdateProjectEpisodeField(projectId: string) {\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: async ({\n      episodeId,\n      key,\n      value,\n    }: {\n      episodeId: string\n      key: string\n      value: unknown\n    }) =>\n      await requestJsonWithError(\n        `/api/novel-promotion/${projectId}/episodes/${episodeId}`,\n        {\n          method: 'PATCH',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({ [key]: value }),\n        },\n        'Failed to update episode',\n      ),\n    onMutate: async (variables) => {\n      const episodeQueryKey = queryKeys.episodeData(projectId, variables.episodeId)\n      const projectQueryKey = queryKeys.projectData(projectId)\n\n      await queryClient.cancelQueries({ queryKey: episodeQueryKey })\n      await queryClient.cancelQueries({ queryKey: projectQueryKey })\n\n      const previousEpisode = queryClient.getQueryData<Record<string, unknown>>(episodeQueryKey)\n      const previousProject = queryClient.getQueryData<Project>(projectQueryKey)\n\n      queryClient.setQueryData<Record<string, unknown> | undefined>(episodeQueryKey, (prev) => {\n        if (!prev) return prev\n        return {\n          ...prev,\n          [variables.key]: variables.value,\n        }\n      })\n\n      queryClient.setQueryData<Project | undefined>(projectQueryKey, (prev) => {\n        if (!prev?.novelPromotionData) return prev\n        const episodes = Array.isArray(prev.novelPromotionData.episodes)\n          ? prev.novelPromotionData.episodes.map((episode) =>\n              episode.id === variables.episodeId ? { ...episode, [variables.key]: variables.value } : episode,\n            )\n          : prev.novelPromotionData.episodes\n        return {\n          ...prev,\n          novelPromotionData: {\n            ...prev.novelPromotionData,\n            episodes,\n          },\n        }\n      })\n\n      return { previousEpisode, previousProject, episodeId: variables.episodeId }\n    },\n    onError: (_error, _variables, context) => {\n      if (context?.previousEpisode && context.episodeId) {\n        queryClient.setQueryData(queryKeys.episodeData(projectId, context.episodeId), context.previousEpisode)\n      }\n      if (context?.previousProject) {\n        queryClient.setQueryData(queryKeys.projectData(projectId), context.previousProject)\n      }\n    },\n    onSettled: (_, __, variables) => {\n      invalidateQueryTemplates(queryClient, [\n        queryKeys.episodeData(projectId, variables.episodeId),\n        queryKeys.projectData(projectId),\n      ])\n    },\n  })\n}\n\n/**\n * 更新 clip 数据\n */\nexport function useUpdateProjectClip(projectId: string) {\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: async ({\n      clipId,\n      data,\n    }: {\n      clipId: string\n      data: Record<string, unknown>\n      episodeId?: string\n    }) =>\n      await requestJsonWithError(\n        `/api/novel-promotion/${projectId}/clips/${clipId}`,\n        {\n          method: 'PATCH',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify(data),\n        },\n        'update failed',\n      ),\n    onMutate: async (variables) => {\n      if (!variables.episodeId) return { previousEpisode: null, episodeId: null }\n\n      const episodeQueryKey = queryKeys.episodeData(projectId, variables.episodeId)\n      await queryClient.cancelQueries({ queryKey: episodeQueryKey })\n\n      const previousEpisode = queryClient.getQueryData<Record<string, unknown>>(episodeQueryKey)\n      queryClient.setQueryData<Record<string, unknown> | undefined>(episodeQueryKey, (prev) => {\n        if (!prev) return prev\n        const clips = Array.isArray(prev.clips) ? prev.clips : []\n        return {\n          ...prev,\n          clips: clips.map((clip: Record<string, unknown>) =>\n            clip?.id === variables.clipId ? { ...clip, ...variables.data } : clip,\n          ),\n        }\n      })\n\n      return { previousEpisode, episodeId: variables.episodeId }\n    },\n    onError: (_error, _variables, context) => {\n      if (context?.previousEpisode && context.episodeId) {\n        queryClient.setQueryData(queryKeys.episodeData(projectId, context.episodeId), context.previousEpisode)\n      }\n    },\n    onSettled: (_data, _error, variables) => {\n      const queryTemplates: Array<readonly unknown[]> = [queryKeys.projectData(projectId)]\n      if (variables.episodeId) queryTemplates.push(queryKeys.episodeData(projectId, variables.episodeId))\n      invalidateQueryTemplates(queryClient, queryTemplates)\n    },\n  })\n}\n\n/**\n * 下载远程文件 blob（避免组件层直接 fetch）\n */\nexport function useDownloadRemoteBlob() {\n  return useMutation({\n    mutationFn: async (url: string) =>\n      await requestBlobWithError(\n        url,\n        { method: 'GET' },\n        '下载失败',\n      ),\n  })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/useLocationMutations.core.ts",
    "content": "export * from './location-image-mutations'\nexport * from './location-management-mutations'\n"
  },
  {
    "path": "src/lib/query/mutations/useLocationMutations.ts",
    "content": "export * from './useLocationMutations.core'\n"
  },
  {
    "path": "src/lib/query/mutations/useProjectConfigMutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport type { Project } from '@/types/project'\nimport { queryKeys } from '../keys'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport {\n  invalidateQueryTemplates,\n  requestJsonWithError,\n  requestTaskResponseWithError,\n} from './mutation-shared'\n\nexport function useAnalyzeProjectGlobalAssets(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async () => {\n            const res = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/analyze-global`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ async: true }),\n                },\n                'Failed to analyze global assets',\n            )\n            return resolveTaskResponse<{ stats?: { newCharacters?: number; newLocations?: number } }>(res)\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * 从资产中心复制到项目资产\n */\n\nexport function useCopyProjectAssetFromGlobal(projectId: string) {\n    const queryClient = useQueryClient()\n    const invalidateProjectAssets = () =>\n        invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])\n\n    return useMutation({\n        mutationFn: async ({\n            type,\n            targetId,\n            globalAssetId,\n        }: {\n            type: 'character' | 'location' | 'voice'\n            targetId: string\n            globalAssetId: string\n        }) => {\n            return await requestJsonWithError(`/api/novel-promotion/${projectId}/copy-from-global`, {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ type, targetId, globalAssetId }),\n            }, 'Failed to copy from global')\n        },\n        onSuccess: invalidateProjectAssets,\n    })\n}\n\n/**\n * AI 修改镜头提示词（项目）\n */\n\nexport function useUpdateProjectConfig(projectId: string) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ key, value }: { key: string; value: unknown }) =>\n            await requestJsonWithError(\n                `/api/novel-promotion/${projectId}`,\n                {\n                    method: 'PATCH',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ [key]: value }),\n                },\n                'Failed to update config',\n            ),\n        onMutate: async ({ key, value }) => {\n            const projectQueryKey = queryKeys.projectData(projectId)\n            await queryClient.cancelQueries({ queryKey: projectQueryKey })\n            const previousProject = queryClient.getQueryData<Project>(projectQueryKey)\n\n            queryClient.setQueryData<Project | undefined>(projectQueryKey, (prev) => {\n                if (!prev?.novelPromotionData) return prev\n                return {\n                    ...prev,\n                    novelPromotionData: {\n                        ...prev.novelPromotionData,\n                        [key]: value,\n                    },\n                }\n            })\n\n            return { previousProject }\n        },\n        onError: (_error, _variables, context) => {\n            if (context?.previousProject) {\n                queryClient.setQueryData(queryKeys.projectData(projectId), context.previousProject)\n            }\n        },\n        onSettled: () => {\n            invalidateQueryTemplates(queryClient, [queryKeys.projectData(projectId)])\n        },\n    })\n}\n\n/**\n * 分析项目资产（异步任务）\n */\n\nexport function useAnalyzeProjectAssets(projectId: string) {\n    const queryClient = useQueryClient()\n\n    return useMutation({\n        mutationFn: async ({ episodeId }: { episodeId: string }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/analyze`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ episodeId, async: true }),\n                },\n                'Failed to analyze assets',\n            )\n            return resolveTaskResponse(response)\n        },\n        onSettled: (_, __, variables) => {\n            invalidateQueryTemplates(queryClient, [\n                queryKeys.episodeData(projectId, variables.episodeId),\n                queryKeys.projectAssets.all(projectId),\n            ])\n        },\n    })\n}\n\n/**\n * 获取下游分镜统计（用于重建确认）\n */\n\nexport function useGetProjectStoryboardStats(projectId: string) {\n    return useMutation({\n        mutationFn: async ({ episodeId }: { episodeId: string }) => {\n            const data = await requestJsonWithError<{ storyboards?: Array<{ panels?: unknown[] }> }>(\n                `/api/novel-promotion/${projectId}/storyboards?episodeId=${encodeURIComponent(episodeId)}`,\n                { method: 'GET' },\n                'storyboards check failed',\n            )\n            const storyboards = Array.isArray(data?.storyboards) ? data.storyboards : []\n            const storyboardCount = storyboards.length\n            const panelCount = storyboards.reduce((sum: number, storyboard) => {\n                const panels = Array.isArray(storyboard?.panels) ? storyboard.panels.length : 0\n                return sum + panels\n            }, 0)\n            return {\n                storyboardCount,\n                panelCount,\n            }\n        },\n    })\n}\n\n/**\n * 获取 VoiceStage 所需数据\n */\n"
  },
  {
    "path": "src/lib/query/mutations/useProjectMutations.ts",
    "content": "export * from './useCharacterMutations'\nexport * from './useLocationMutations'\nexport * from './useStoryboardMutations'\nexport * from './useVideoMutations'\nexport * from './useVoiceMutations'\nexport * from './useProjectConfigMutations'\nexport * from './useEpisodeMutations'\n"
  },
  {
    "path": "src/lib/query/mutations/useStoryboardMutations.core.ts",
    "content": "export * from './storyboard-prompt-mutations'\nexport * from './storyboard-panel-mutations'\n"
  },
  {
    "path": "src/lib/query/mutations/useStoryboardMutations.ts",
    "content": "export * from './useStoryboardMutations.core'\n"
  },
  {
    "path": "src/lib/query/mutations/useVideoMutations.ts",
    "content": "import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { queryKeys } from '../keys'\nimport { invalidateQueryTemplates, requestJsonWithError } from './mutation-shared'\n\n/**\n * 获取剧集可下载视频列表（项目）\n */\nexport function useListProjectEpisodeVideoUrls(projectId: string) {\n  return useMutation({\n    mutationFn: async (payload: {\n      episodeId: string\n      panelPreferences: Record<string, boolean>\n    }) =>\n      await requestJsonWithError(\n        `/api/novel-promotion/${projectId}/video-urls`,\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify(payload),\n        },\n        '获取视频列表失败',\n      ),\n  })\n}\n\n/**\n * 更新 panel 首尾帧链接状态（项目）\n */\nexport function useUpdateProjectPanelLink(projectId: string) {\n  return useMutation({\n    mutationFn: async (payload: {\n      storyboardId: string\n      panelIndex: number\n      linked: boolean\n    }) =>\n      await requestJsonWithError(\n        `/api/novel-promotion/${projectId}/panel-link`,\n        {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify(payload),\n        },\n        '保存链接状态失败',\n      ),\n  })\n}\n\n/**\n * 更新 Panel 视频提示词\n */\nexport function useUpdateProjectPanelVideoPrompt(projectId: string) {\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: async ({\n      storyboardId,\n      panelIndex,\n      value,\n      field = 'videoPrompt',\n    }: {\n      storyboardId: string\n      panelIndex: number\n      value: string\n      field?: 'videoPrompt' | 'firstLastFramePrompt'\n    }) =>\n      await requestJsonWithError(\n        `/api/novel-promotion/${projectId}/panel`,\n        {\n          method: 'PATCH',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            storyboardId,\n            panelIndex,\n            ...(field === 'firstLastFramePrompt'\n              ? { firstLastFramePrompt: value }\n              : { videoPrompt: value }),\n          }),\n        },\n        'update failed',\n      ),\n    onSettled: () => {\n      invalidateQueryTemplates(queryClient, [queryKeys.projectData(projectId)])\n    },\n  })\n}\n"
  },
  {
    "path": "src/lib/query/mutations/useVoiceMutations.ts",
    "content": "import { useMutation } from '@tanstack/react-query'\nimport { resolveTaskResponse } from '@/lib/task/client'\nimport type { SpeakerVoiceEntry, SpeakerVoicePatch } from '@/lib/voice/provider-voice-binding'\nimport {\n    requestBlobWithError,\n    requestJsonWithError,\n    requestTaskResponseWithError,\n    requestVoidWithError,\n} from './mutation-shared'\n\ntype ProjectVoiceLine = {\n    id: string\n    lineIndex: number\n    speaker: string\n    content: string\n    emotionPrompt: string | null\n    emotionStrength: number | null\n    audioUrl: string | null\n    updatedAt: string | null\n    lineTaskRunning: boolean\n    matchedPanelId?: string | null\n    matchedStoryboardId?: string | null\n    matchedPanelIndex?: number | null\n}\n\ntype GenerateProjectVoiceResponse = {\n    success?: boolean\n    async?: boolean\n    taskId?: string\n    taskIds?: string[]\n    total?: number\n    error?: string\n    results?: Array<{ lineId?: string; taskId?: string; audioUrl?: string }>\n}\n\nexport function useDesignProjectVoice(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: {\n            voicePrompt: string\n            previewText: string\n            preferredName: string\n            language: 'zh'\n        }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/voice-design`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'Failed to design voice',\n            )\n            return await resolveTaskResponse<{\n                success?: boolean\n                voiceId?: string\n                targetModel?: string\n                audioBase64?: string\n                requestId?: string\n            }>(response)\n        },\n    })\n}\n\n/**\n * 分析镜头变体（项目）\n */\n\nexport function useFetchProjectVoiceStageData(projectId: string) {\n    return useMutation({\n        mutationFn: async ({ episodeId }: { episodeId: string }): Promise<{\n            voiceLines: ProjectVoiceLine[]\n            speakerVoices: Record<string, SpeakerVoiceEntry>\n            speakers: string[]\n        }> => {\n            const [linesData, voicesData, speakersData] = await Promise.all([\n                requestJsonWithError<{ voiceLines?: ProjectVoiceLine[] }>(\n                    `/api/novel-promotion/${projectId}/voice-lines?episodeId=${episodeId}`,\n                    { method: 'GET' },\n                    '获取台词失败',\n                ),\n                requestJsonWithError<{ speakerVoices?: Record<string, SpeakerVoiceEntry> }>(\n                    `/api/novel-promotion/${projectId}/speaker-voice?episodeId=${episodeId}`,\n                    { method: 'GET' },\n                    '获取角色音色失败',\n                ),\n                requestJsonWithError<{ speakers?: string[] }>(\n                    `/api/novel-promotion/${projectId}/voice-lines?speakersOnly=1`,\n                    { method: 'GET' },\n                    '获取说话人失败',\n                ),\n            ])\n\n            return {\n                voiceLines: linesData.voiceLines || [],\n                speakerVoices: voicesData.speakerVoices || {},\n                speakers: speakersData.speakers || [],\n            }\n        },\n    })\n}\n\n/**\n * 分析配音台词\n */\n\nexport function useAnalyzeProjectVoice(projectId: string) {\n    return useMutation({\n        mutationFn: async ({ episodeId }: { episodeId: string }) => {\n            const response = await requestTaskResponseWithError(\n                `/api/novel-promotion/${projectId}/voice-analyze`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ episodeId, async: true }),\n                },\n                'voice analyze failed',\n            )\n            return resolveTaskResponse(response)\n        },\n    })\n}\n\n/**\n * 生成单条/批量配音\n */\n\nexport function useGenerateProjectVoice(projectId: string) {\n    return useMutation({\n        mutationFn: async ({\n            episodeId,\n            lineId,\n            all,\n        }: {\n            episodeId: string\n            lineId?: string\n            all?: boolean\n        }) =>\n            await requestJsonWithError<GenerateProjectVoiceResponse>(\n                `/api/novel-promotion/${projectId}/voice-generate`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(all ? { episodeId, all: true } : { episodeId, lineId }),\n                },\n                'voice generate failed',\n            ),\n    })\n}\n\n/**\n * 创建台词\n */\n\nexport function useCreateProjectVoiceLine(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: {\n            episodeId: string\n            content: string\n            speaker: string\n            matchedPanelId?: string | null\n        }) =>\n            await requestJsonWithError<{ voiceLine: ProjectVoiceLine }>(\n                `/api/novel-promotion/${projectId}/voice-lines`,\n                {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'add failed',\n            ),\n    })\n}\n\n/**\n * 更新台词字段\n */\n\nexport function useUpdateProjectVoiceLine(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: Record<string, unknown>) =>\n            await requestJsonWithError<{ voiceLine: ProjectVoiceLine }>(\n                `/api/novel-promotion/${projectId}/voice-lines`,\n                {\n                    method: 'PATCH',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'update failed',\n            ),\n    })\n}\n\n/**\n * 删除台词\n */\n\nexport function useDeleteProjectVoiceLine(projectId: string) {\n    return useMutation({\n        mutationFn: async ({ lineId }: { lineId: string }) => {\n            await requestVoidWithError(\n                `/api/novel-promotion/${projectId}/voice-lines?lineId=${lineId}`,\n                { method: 'DELETE' },\n                'delete failed',\n            )\n            return null\n        },\n    })\n}\n\n/**\n * 下载配音 zip\n */\n\nexport function useDownloadProjectVoices(projectId: string) {\n    return useMutation({\n        mutationFn: async ({ episodeId }: { episodeId: string }) =>\n            await requestBlobWithError(\n                `/api/novel-promotion/${projectId}/download-voices?episodeId=${episodeId}`,\n                { method: 'GET' },\n                'download failed',\n            ),\n    })\n}\n\n/**\n * 为发言人直接设置音色（写入 episode.speakerVoices）\n * 用于不在资产库中的角色在配音阶段内联绑定音色\n */\nexport function useUpdateSpeakerVoice(projectId: string) {\n    return useMutation({\n        mutationFn: async (payload: {\n            episodeId: string\n            speaker: string\n        } & SpeakerVoicePatch) =>\n            await requestJsonWithError<{ success: boolean }>(\n                `/api/novel-promotion/${projectId}/speaker-voice`,\n                {\n                    method: 'PATCH',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify(payload),\n                },\n                'update speaker voice failed',\n            ),\n    })\n}\n"
  },
  {
    "path": "src/lib/query/task-target-overlay.ts",
    "content": "import type { QueryClient } from '@tanstack/react-query'\nimport { queryKeys } from './keys'\nimport { TASK_EVENT_TYPE } from '@/lib/task/types'\nimport type { TaskIntent } from '@/lib/task/intent'\n\nexport const TASK_TARGET_OVERLAY_TTL_MS = 30_000\n\nexport type TaskTargetOverlayPhase = 'queued' | 'processing'\n\nexport type TaskTargetOverlayState = {\n  targetType: string\n  targetId: string\n  phase: TaskTargetOverlayPhase\n  runningTaskId: string | null\n  runningTaskType: string | null\n  intent: TaskIntent\n  hasOutputAtStart: boolean | null\n  progress: number | null\n  stage: string | null\n  stageLabel: string | null\n  updatedAt: string | null\n  lastError: null\n  expiresAt: number\n}\n\nexport type TaskTargetOverlayMap = Record<string, TaskTargetOverlayState>\n\nfunction toOverlayKey(targetType: string, targetId: string) {\n  return `${targetType}:${targetId}`\n}\n\nfunction normalizeOptionalString(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  return trimmed || null\n}\n\nfunction buildOptimisticTaskId(targetType: string, targetId: string, now: number): string {\n  return `optimistic:${targetType}:${targetId}:${now.toString(36)}`\n}\n\nfunction pruneExpiredOverlay(prev: TaskTargetOverlayMap | undefined, now: number) {\n  const next: TaskTargetOverlayMap = { ...(prev || {}) }\n  for (const [overlayKey, value] of Object.entries(next)) {\n    if ((value?.expiresAt || 0) <= now) {\n      delete next[overlayKey]\n    }\n  }\n  return next\n}\n\nexport function upsertTaskTargetOverlay(\n  queryClient: QueryClient,\n  params: {\n    projectId: string\n    targetType: string\n    targetId: string\n    phase?: TaskTargetOverlayPhase\n    runningTaskId?: string | null\n    runningTaskType?: string | null\n    intent?: TaskIntent\n    hasOutputAtStart?: boolean | null\n    progress?: number | null\n    stage?: string | null\n    stageLabel?: string | null\n    updatedAt?: string | null\n  },\n) {\n  const now = Date.now()\n  const key = toOverlayKey(params.targetType, params.targetId)\n  queryClient.setQueryData<TaskTargetOverlayMap>(\n    queryKeys.tasks.targetStateOverlay(params.projectId),\n    (prev) => {\n      const next = pruneExpiredOverlay(prev, now)\n      const existing = next[key]\n      const runningTaskId = normalizeOptionalString(params.runningTaskId)\n        || normalizeOptionalString(existing?.runningTaskId)\n        || buildOptimisticTaskId(params.targetType, params.targetId, now)\n      const runningTaskType = normalizeOptionalString(params.runningTaskType)\n        || normalizeOptionalString(existing?.runningTaskType)\n      next[key] = {\n        targetType: params.targetType,\n        targetId: params.targetId,\n        phase: params.phase || 'queued',\n        runningTaskId,\n        runningTaskType,\n        intent: params.intent || 'process',\n        hasOutputAtStart: params.hasOutputAtStart ?? null,\n        progress: params.progress ?? null,\n        stage: params.stage ?? null,\n        stageLabel: params.stageLabel ?? null,\n        updatedAt: params.updatedAt || new Date(now).toISOString(),\n        lastError: null,\n        expiresAt: now + TASK_TARGET_OVERLAY_TTL_MS,\n      }\n      return next\n    },\n  )\n}\n\nexport function clearTaskTargetOverlay(\n  queryClient: QueryClient,\n  params: {\n    projectId: string\n    targetType: string\n    targetId: string\n  },\n) {\n  const key = toOverlayKey(params.targetType, params.targetId)\n  queryClient.setQueryData<TaskTargetOverlayMap>(\n    queryKeys.tasks.targetStateOverlay(params.projectId),\n    (prev) => {\n      if (!prev || !prev[key]) return prev || {}\n      const next: TaskTargetOverlayMap = { ...prev }\n      delete next[key]\n      return next\n    },\n  )\n}\n\nexport function applyTaskLifecycleToOverlay(\n  queryClient: QueryClient,\n  params: {\n    projectId: string\n    lifecycleType: string | null\n    targetType: string | null\n    targetId: string | null\n    taskId: string | null\n    taskType: string | null\n    intent: TaskIntent\n    hasOutputAtStart: boolean | null\n    progress: number | null\n    stage: string | null\n    stageLabel: string | null\n    eventTs: string | null\n  },\n) {\n  if (!params.targetType || !params.targetId) return\n  if (params.lifecycleType === TASK_EVENT_TYPE.CREATED) {\n    upsertTaskTargetOverlay(queryClient, {\n      projectId: params.projectId,\n      targetType: params.targetType,\n      targetId: params.targetId,\n      phase: 'queued',\n      runningTaskId: params.taskId,\n      runningTaskType: params.taskType,\n      intent: params.intent,\n      hasOutputAtStart: params.hasOutputAtStart,\n      progress: params.progress,\n      stage: params.stage,\n      stageLabel: params.stageLabel,\n      updatedAt: params.eventTs,\n    })\n    return\n  }\n\n  if (params.lifecycleType === TASK_EVENT_TYPE.PROCESSING) {\n    upsertTaskTargetOverlay(queryClient, {\n      projectId: params.projectId,\n      targetType: params.targetType,\n      targetId: params.targetId,\n      phase: 'processing',\n      runningTaskId: params.taskId,\n      runningTaskType: params.taskType,\n      intent: params.intent,\n      hasOutputAtStart: params.hasOutputAtStart,\n      progress: params.progress,\n      stage: params.stage,\n      stageLabel: params.stageLabel,\n      updatedAt: params.eventTs,\n    })\n    return\n  }\n\n  if (\n    params.lifecycleType === TASK_EVENT_TYPE.COMPLETED ||\n    params.lifecycleType === TASK_EVENT_TYPE.FAILED\n  ) {\n    const key = toOverlayKey(params.targetType, params.targetId)\n    queryClient.setQueryData<TaskTargetOverlayMap>(\n      queryKeys.tasks.targetStateOverlay(params.projectId),\n      (prev) => {\n        if (!prev || !prev[key]) return prev || {}\n        const current = prev[key]\n        const incomingTaskId = normalizeOptionalString(params.taskId)\n        const currentTaskId = normalizeOptionalString(current.runningTaskId)\n        if (incomingTaskId && currentTaskId && incomingTaskId !== currentTaskId) {\n          return prev\n        }\n        const next: TaskTargetOverlayMap = { ...prev }\n        delete next[key]\n        return next\n      },\n    )\n  }\n}\n"
  },
  {
    "path": "src/lib/rate-limit.ts",
    "content": "/**\n * 🛡️ IP 级别速率限制工具\n *\n * 基于 Redis 滑动窗口实现，适用于登录/注册等敏感接口。\n * 每个 (action, ip) 维度独立计数。超出阈值返回重试等待秒数。\n */\n\nimport { redis } from '@/lib/redis'\nimport { NextRequest } from 'next/server'\n\n// ============================================================\n// 类型\n// ============================================================\n\nexport interface RateLimitConfig {\n    /** 限流窗口时长（秒） */\n    windowSeconds: number\n    /** 窗口内最大请求数 */\n    maxRequests: number\n}\n\nexport interface RateLimitResult {\n    /** 是否被限流 */\n    limited: boolean\n    /** 剩余可用次数 */\n    remaining: number\n    /** 限流重置时间（秒数，仅 limited=true 时有意义） */\n    retryAfterSeconds: number\n}\n\n// ============================================================\n// 预设配置\n// ============================================================\n\n/** 登录：60 秒内最多 5 次 */\nexport const AUTH_LOGIN_LIMIT: RateLimitConfig = {\n    windowSeconds: 60,\n    maxRequests: 5,\n}\n\n/** 注册：60 秒内最多 3 次 */\nexport const AUTH_REGISTER_LIMIT: RateLimitConfig = {\n    windowSeconds: 60,\n    maxRequests: 3,\n}\n\n// ============================================================\n// 核心逻辑\n// ============================================================\n\n/**\n * 检查并递增速率限制计数器。\n *\n * @param action  限流动作名（如 \"auth:login\"），会拼入 Redis key\n * @param ip      客户端 IP\n * @param config  限流配置\n */\nexport async function checkRateLimit(\n    action: string,\n    ip: string,\n    config: RateLimitConfig,\n): Promise<RateLimitResult> {\n    const key = `rate_limit:${action}:${ip}`\n    const now = Date.now()\n    const windowMs = config.windowSeconds * 1000\n\n    // Lua 脚本：原子地清除过期条目、添加当前请求、返回当前窗口内请求数\n    const luaScript = `\n    local key = KEYS[1]\n    local now = tonumber(ARGV[1])\n    local windowMs = tonumber(ARGV[2])\n    local maxRequests = tonumber(ARGV[3])\n    local expireSeconds = tonumber(ARGV[4])\n\n    -- 移除窗口之前的条目\n    redis.call('ZREMRANGEBYSCORE', key, '-inf', now - windowMs)\n\n    -- 当前窗口内的请求数\n    local count = redis.call('ZCARD', key)\n\n    if count < maxRequests then\n      -- 未超限，添加当前请求\n      redis.call('ZADD', key, now, now .. ':' .. math.random(100000))\n      redis.call('EXPIRE', key, expireSeconds)\n      return { 0, maxRequests - count - 1, 0 }\n    else\n      -- 已超限，计算最早条目的过期时间\n      local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')\n      local retryAfterMs = 0\n      if #oldest >= 2 then\n        retryAfterMs = tonumber(oldest[2]) + windowMs - now\n        if retryAfterMs < 0 then retryAfterMs = 0 end\n      end\n      return { 1, 0, retryAfterMs }\n    end\n  `\n\n    try {\n        const result = await (redis as ReturnType<typeof redis.duplicate>).eval(\n            luaScript,\n            1,\n            key,\n            now,\n            windowMs,\n            config.maxRequests,\n            config.windowSeconds + 10, // TTL 略长于窗口以防边界\n        ) as [number, number, number]\n\n        return {\n            limited: result[0] === 1,\n            remaining: result[1],\n            retryAfterSeconds: Math.ceil(result[2] / 1000),\n        }\n    } catch {\n        // Redis 不可用时放行，避免 Redis 故障阻塞登录\n        return { limited: false, remaining: config.maxRequests, retryAfterSeconds: 0 }\n    }\n}\n\n// ============================================================\n// 辅助：提取客户端 IP\n// ============================================================\n\n/**\n * 从 NextRequest 提取客户端真实 IP。\n * 依次检查常见反向代理头，最终回退到 127.0.0.1。\n */\nexport function getClientIp(req: NextRequest): string {\n    // x-forwarded-for 可能包含多个 IP（逗号分隔），取第一个\n    const forwarded = req.headers.get('x-forwarded-for')\n    if (forwarded) {\n        const first = forwarded.split(',')[0]?.trim()\n        if (first) return first\n    }\n\n    const realIp = req.headers.get('x-real-ip')\n    if (realIp) return realIp.trim()\n\n    // Next.js 14+ 的 ip 属性\n    if ('ip' in req && typeof (req as NextRequest & { ip?: string }).ip === 'string') {\n        return (req as NextRequest & { ip?: string }).ip!\n    }\n\n    return '127.0.0.1'\n}\n"
  },
  {
    "path": "src/lib/redis.ts",
    "content": "import { logDebug as _ulogDebug, logError as _ulogError } from '@/lib/logging/core'\nimport Redis from 'ioredis'\n\ntype RedisSingleton = {\n  app?: Redis\n  queue?: Redis\n}\n\nconst globalForRedis = globalThis as typeof globalThis & {\n  __waoowaooRedis?: RedisSingleton\n}\n\nconst REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1'\nconst REDIS_PORT = Number.parseInt(process.env.REDIS_PORT || '6379', 10) || 6379\nconst REDIS_USERNAME = process.env.REDIS_USERNAME\nconst REDIS_PASSWORD = process.env.REDIS_PASSWORD\nconst REDIS_TLS = process.env.REDIS_TLS === 'true'\nconst IS_TEST_ENV = process.env.NODE_ENV === 'test'\n\nfunction buildBaseConfig() {\n  return {\n    host: REDIS_HOST,\n    port: REDIS_PORT,\n    username: REDIS_USERNAME,\n    password: REDIS_PASSWORD,\n    tls: REDIS_TLS ? {} : undefined,\n    enableReadyCheck: true,\n    lazyConnect: IS_TEST_ENV,\n    retryStrategy(times: number) {\n      // Exponential backoff capped at 30s.\n      return Math.min(2 ** Math.min(times, 10) * 100, 30_000)\n    },\n  }\n}\n\nfunction onConnectLog(scope: string, client: Redis) {\n  client.on('connect', () => _ulogDebug(`[Redis:${scope}] connected ${REDIS_HOST}:${REDIS_PORT}`))\n  client.on('error', (err) => _ulogError(`[Redis:${scope}] error:`, err.message))\n}\n\nfunction createAppRedis() {\n  const client = new Redis({\n    ...buildBaseConfig(),\n    maxRetriesPerRequest: 2,\n  })\n  onConnectLog('app', client)\n  return client\n}\n\nfunction createQueueRedis() {\n  const client = new Redis({\n    ...buildBaseConfig(),\n    // BullMQ requires null to avoid command retry side effects.\n    maxRetriesPerRequest: null,\n  })\n  onConnectLog('queue', client)\n  return client\n}\n\nconst singleton = globalForRedis.__waoowaooRedis || {}\nif (!globalForRedis.__waoowaooRedis) {\n  globalForRedis.__waoowaooRedis = singleton\n}\n\nexport const redis = singleton.app || (singleton.app = createAppRedis())\nexport const queueRedis = singleton.queue || (singleton.queue = createQueueRedis())\n\nexport function createSubscriber() {\n  const client = new Redis({\n    ...buildBaseConfig(),\n    maxRetriesPerRequest: null,\n  })\n  onConnectLog('sub', client)\n  return client\n}\n"
  },
  {
    "path": "src/lib/run-runtime/publisher.ts",
    "content": "import { redis } from '@/lib/redis'\nimport { appendRunEventWithSeq } from './service'\nimport type { RunEventInput } from './types'\n\nconst RUN_CHANNEL_PREFIX = 'run-events:project:'\n\nexport function getProjectRunChannel(projectId: string) {\n  return `${RUN_CHANNEL_PREFIX}${projectId}`\n}\n\nexport async function publishRunEvent(input: RunEventInput) {\n  const event = await appendRunEventWithSeq(input)\n  const message = {\n    id: event.id,\n    type: 'run.event',\n    runId: event.runId,\n    projectId: event.projectId,\n    userId: event.userId,\n    seq: event.seq,\n    eventType: event.eventType,\n    stepKey: event.stepKey || null,\n    attempt: event.attempt || null,\n    lane: event.lane || null,\n    payload: event.payload || null,\n    ts: event.createdAt,\n  }\n  await redis.publish(getProjectRunChannel(event.projectId), JSON.stringify(message))\n  return message\n}\n\n"
  },
  {
    "path": "src/lib/run-runtime/service.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { resolveRetryInvalidationStepKeys } from '@/lib/workflow-engine/dependencies'\nimport {\n  RUN_EVENT_TYPE,\n  RUN_STATE_MAX_BYTES,\n  RUN_STATUS,\n  RUN_STEP_STATUS,\n  type CreateRunInput,\n  type ListRunsInput,\n  type RunEvent,\n  type RunEventInput,\n  type RunStatus,\n  type StateRef,\n} from './types'\n\ntype JsonRecord = Record<string, unknown>\n\ntype GraphRunRow = {\n  id: string\n  userId: string\n  projectId: string\n  episodeId: string | null\n  workflowType: string\n  taskType: string | null\n  taskId: string | null\n  targetType: string\n  targetId: string\n  status: string\n  input: unknown\n  output: unknown\n  errorCode: string | null\n  errorMessage: string | null\n  cancelRequestedAt: Date | null\n  leaseOwner: string | null\n  leaseExpiresAt: Date | null\n  heartbeatAt: Date | null\n  workflowVersion: number\n  queuedAt: Date\n  startedAt: Date | null\n  finishedAt: Date | null\n  lastSeq: number\n  createdAt: Date\n  updatedAt: Date\n}\n\ntype GraphStepRow = {\n  id: string\n  runId: string\n  stepKey: string\n  stepTitle: string\n  status: string\n  currentAttempt: number\n  stepIndex: number\n  stepTotal: number\n  startedAt: Date | null\n  finishedAt: Date | null\n  lastErrorCode: string | null\n  lastErrorMessage: string | null\n  createdAt: Date\n  updatedAt: Date\n}\n\ntype GraphArtifactRow = {\n  id: string\n  runId: string\n  stepKey: string | null\n  artifactType: string\n  refId: string\n  versionHash: string | null\n  payload: unknown\n  createdAt: Date\n}\n\ntype GraphEventRow = {\n  id: bigint\n  runId: string\n  projectId: string\n  userId: string\n  seq: number\n  eventType: string\n  stepKey: string | null\n  attempt: number | null\n  lane: string | null\n  payload: unknown\n  createdAt: Date\n}\n\ntype GraphRunModel = {\n  create: (args: unknown) => Promise<GraphRunRow>\n  update: (args: unknown) => Promise<GraphRunRow>\n  updateMany: (args: unknown) => Promise<{ count: number }>\n  findUnique: (args: unknown) => Promise<GraphRunRow | null>\n  findMany: (args: unknown) => Promise<GraphRunRow[]>\n}\n\ntype GraphStepModel = {\n  upsert: (args: unknown) => Promise<GraphStepRow>\n  findMany: (args: unknown) => Promise<GraphStepRow[]>\n  updateMany: (args: unknown) => Promise<{ count: number }>\n  findUnique: (args: unknown) => Promise<GraphStepRow | null>\n  update: (args: unknown) => Promise<GraphStepRow>\n}\n\ntype GraphStepAttemptModel = {\n  upsert: (args: unknown) => Promise<unknown>\n}\n\ntype GraphEventModel = {\n  create: (args: unknown) => Promise<GraphEventRow>\n  findMany: (args: unknown) => Promise<GraphEventRow[]>\n}\n\ntype GraphCheckpointModel = {\n  create: (args: unknown) => Promise<unknown>\n  findMany: (args: unknown) => Promise<Array<{\n    id: string\n    runId: string\n    nodeKey: string\n    version: number\n    stateJson: unknown\n    stateBytes: number\n    createdAt: Date\n  }>>\n}\n\ntype GraphArtifactModel = {\n  upsert: (args: unknown) => Promise<GraphArtifactRow>\n  findMany: (args: unknown) => Promise<GraphArtifactRow[]>\n  deleteMany: (args: unknown) => Promise<{ count: number }>\n}\n\ntype GraphRuntimeTx = {\n  graphRun: GraphRunModel\n  graphStep: GraphStepModel\n  graphStepAttempt: GraphStepAttemptModel\n  graphEvent: GraphEventModel\n  graphCheckpoint: GraphCheckpointModel\n  graphArtifact: GraphArtifactModel\n}\n\ntype GraphRuntimeClient = GraphRuntimeTx & {\n  $transaction: <T>(fn: (tx: GraphRuntimeTx) => Promise<T>) => Promise<T>\n}\n\nconst runtimeClient = prisma as unknown as GraphRuntimeClient\n\nfunction toObject(value: unknown): JsonRecord {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as JsonRecord\n}\n\nfunction asRunStatus(value: string): RunStatus {\n  if (\n    value === RUN_STATUS.QUEUED ||\n    value === RUN_STATUS.RUNNING ||\n    value === RUN_STATUS.COMPLETED ||\n    value === RUN_STATUS.FAILED ||\n    value === RUN_STATUS.CANCELING ||\n    value === RUN_STATUS.CANCELED\n  ) {\n    return value\n  }\n  return RUN_STATUS.FAILED\n}\n\nfunction toIso(value: Date | null): string | null {\n  return value ? value.toISOString() : null\n}\n\nfunction readString(payload: JsonRecord, key: string): string | null {\n  const value = payload[key]\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  return trimmed || null\n}\n\nfunction readInt(payload: JsonRecord, key: string): number | null {\n  const value = payload[key]\n  if (typeof value === 'number' && Number.isFinite(value)) {\n    return Math.max(1, Math.floor(value))\n  }\n  if (typeof value === 'string' && value.trim()) {\n    const parsed = Number.parseInt(value, 10)\n    if (Number.isFinite(parsed)) return Math.max(1, parsed)\n  }\n  return null\n}\n\nfunction resolveErrorMessage(payload: JsonRecord): string | null {\n  const direct = readString(payload, 'message') || readString(payload, 'errorMessage')\n  if (direct) return direct\n  const errorPayload = toObject(payload.error)\n  return readString(errorPayload, 'message') || readString(errorPayload, 'errorMessage')\n}\n\nfunction normalizeLane(lane: string | null): 'text' | 'reasoning' | null {\n  if (lane === 'reasoning') return 'reasoning'\n  if (lane === 'text') return 'text'\n  return null\n}\n\nfunction mapEventRow(row: GraphEventRow): RunEvent {\n  return {\n    id: row.id.toString(),\n    runId: row.runId,\n    projectId: row.projectId,\n    userId: row.userId,\n    seq: row.seq,\n    eventType: row.eventType as RunEvent['eventType'],\n    stepKey: row.stepKey,\n    attempt: row.attempt,\n    lane: normalizeLane(row.lane),\n    payload: toObject(row.payload),\n    createdAt: row.createdAt.toISOString(),\n  }\n}\n\nfunction mapRunRow(run: GraphRunRow) {\n  return {\n    id: run.id,\n    userId: run.userId,\n    projectId: run.projectId,\n    episodeId: run.episodeId,\n    workflowType: run.workflowType,\n    taskType: run.taskType,\n    taskId: run.taskId,\n    targetType: run.targetType,\n    targetId: run.targetId,\n    status: asRunStatus(run.status),\n    input: toObject(run.input),\n    output: toObject(run.output),\n    errorCode: run.errorCode,\n    errorMessage: run.errorMessage,\n    cancelRequestedAt: toIso(run.cancelRequestedAt),\n    leaseOwner: run.leaseOwner,\n    leaseExpiresAt: toIso(run.leaseExpiresAt),\n    heartbeatAt: toIso(run.heartbeatAt),\n    workflowVersion: run.workflowVersion,\n    queuedAt: run.queuedAt.toISOString(),\n    startedAt: toIso(run.startedAt),\n    finishedAt: toIso(run.finishedAt),\n    lastSeq: run.lastSeq,\n    createdAt: run.createdAt.toISOString(),\n    updatedAt: run.updatedAt.toISOString(),\n  }\n}\n\nfunction mapStepRow(step: GraphStepRow) {\n  return {\n    id: step.id,\n    runId: step.runId,\n    stepKey: step.stepKey,\n    stepTitle: step.stepTitle,\n    status: step.status as\n      | typeof RUN_STEP_STATUS.PENDING\n      | typeof RUN_STEP_STATUS.RUNNING\n      | typeof RUN_STEP_STATUS.COMPLETED\n      | typeof RUN_STEP_STATUS.FAILED\n      | typeof RUN_STEP_STATUS.CANCELED,\n    currentAttempt: step.currentAttempt,\n    stepIndex: step.stepIndex,\n    stepTotal: step.stepTotal,\n    startedAt: toIso(step.startedAt),\n    finishedAt: toIso(step.finishedAt),\n    lastErrorCode: step.lastErrorCode,\n    lastErrorMessage: step.lastErrorMessage,\n    createdAt: step.createdAt.toISOString(),\n    updatedAt: step.updatedAt.toISOString(),\n  }\n}\n\nfunction mapArtifactRow(row: GraphArtifactRow) {\n  return {\n    id: row.id,\n    runId: row.runId,\n    stepKey: row.stepKey,\n    artifactType: row.artifactType,\n    refId: row.refId,\n    versionHash: row.versionHash,\n    payload: row.payload,\n    createdAt: row.createdAt.toISOString(),\n  }\n}\n\ntype MysqlIndexRow = {\n  Key_name: string\n  Non_unique: number | string\n  Seq_in_index: number | string\n  Column_name: string\n}\n\nconst REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS = ['runId', 'stepKey', 'artifactType', 'refId'] as const\nlet graphArtifactUniqueIndexCheck: Promise<void> | null = null\n\nfunction toIndexNumber(value: number | string) {\n  if (typeof value === 'number') return value\n  return Number.parseInt(value, 10)\n}\n\nfunction hasRequiredGraphArtifactUniqueIndex(rows: MysqlIndexRow[]) {\n  const indexColumns = new Map<string, Array<{ seq: number; column: string; nonUnique: number }>>()\n  for (const row of rows) {\n    const seq = toIndexNumber(row.Seq_in_index)\n    const nonUnique = toIndexNumber(row.Non_unique)\n    if (!Number.isFinite(seq) || !Number.isFinite(nonUnique)) continue\n    const key = row.Key_name\n    const list = indexColumns.get(key) || []\n    list.push({\n      seq,\n      column: row.Column_name,\n      nonUnique,\n    })\n    indexColumns.set(key, list)\n  }\n\n  for (const entries of indexColumns.values()) {\n    if (entries.length !== REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS.length) continue\n    const sorted = entries.sort((a, b) => a.seq - b.seq)\n    if (sorted[0]?.nonUnique !== 0) continue\n    const columns = sorted.map((entry) => entry.column)\n    if (columns.length !== REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS.length) continue\n    const match = columns.every((column, index) => column === REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS[index])\n    if (match) return true\n  }\n\n  return false\n}\n\nasync function ensureGraphArtifactUniqueIndex() {\n  if (process.env.NODE_ENV === 'test') return\n  if (graphArtifactUniqueIndexCheck) {\n    await graphArtifactUniqueIndexCheck\n    return\n  }\n\n  graphArtifactUniqueIndexCheck = (async () => {\n    const rows = await prisma.$queryRawUnsafe<MysqlIndexRow[]>('SHOW INDEX FROM graph_artifacts')\n    if (!hasRequiredGraphArtifactUniqueIndex(rows)) {\n      throw new Error(\n        'missing required unique index on graph_artifacts(runId, stepKey, artifactType, refId); run migration before starting runtime',\n      )\n    }\n  })()\n\n  try {\n    await graphArtifactUniqueIndexCheck\n  } catch (error) {\n    graphArtifactUniqueIndexCheck = null\n    throw error\n  }\n}\n\nasync function upsertArtifactStrict(params: {\n  artifactModel: GraphArtifactModel\n  runId: string\n  stepKey: string\n  artifactType: string\n  refId: string\n  versionHash: string | null\n  payload: unknown\n}) {\n  await ensureGraphArtifactUniqueIndex()\n  return await params.artifactModel.upsert({\n    where: {\n      runId_stepKey_artifactType_refId: {\n        runId: params.runId,\n        stepKey: params.stepKey,\n        artifactType: params.artifactType,\n        refId: params.refId,\n      },\n    },\n    create: {\n      runId: params.runId,\n      stepKey: params.stepKey,\n      artifactType: params.artifactType,\n      refId: params.refId,\n      versionHash: params.versionHash,\n      payload: params.payload,\n    },\n    update: {\n      versionHash: params.versionHash,\n      payload: params.payload,\n    },\n  })\n}\n\nfunction buildStepProjection(input: RunEventInput) {\n  const payload = toObject(input.payload)\n  const stepKey = input.stepKey || readString(payload, 'stepKey') || readString(payload, 'stepId')\n  if (!stepKey) return null\n  const stepTitle = readString(payload, 'stepTitle') || stepKey\n  const stepIndex = readInt(payload, 'stepIndex') || 1\n  const stepTotal = Math.max(stepIndex, readInt(payload, 'stepTotal') || stepIndex)\n  const attempt = input.attempt && input.attempt > 0 ? input.attempt : (readInt(payload, 'stepAttempt') || 1)\n  return {\n    stepKey,\n    stepTitle,\n    stepIndex,\n    stepTotal,\n    attempt,\n    payload,\n  }\n}\n\nfunction buildArtifactProjection(params: {\n  runId: string\n  stepKey: string\n  payload: JsonRecord\n  isStepFailed: boolean\n  isStepCompleted: boolean\n}) {\n  const payload = params.payload\n  const artifactType = readString(payload, 'artifactType')\n  const refId = readString(payload, 'artifactRefId') || readString(payload, 'refId') || params.stepKey\n  if (!refId) return null\n\n  const resolvedType = artifactType\n    || (\n      params.isStepFailed\n        ? 'step.error'\n        : params.isStepCompleted\n          ? 'step.output'\n          : null\n    )\n  if (!resolvedType) return null\n\n  const artifactPayload = toObject(payload.artifactPayload)\n  if (Object.keys(artifactPayload).length > 0) {\n    return {\n      runId: params.runId,\n      stepKey: params.stepKey,\n      artifactType: resolvedType,\n      refId,\n      versionHash: readString(payload, 'versionHash'),\n      payload: artifactPayload,\n    }\n  }\n\n  return {\n    runId: params.runId,\n    stepKey: params.stepKey,\n    artifactType: resolvedType,\n    refId,\n    versionHash: readString(payload, 'versionHash'),\n    payload,\n  }\n}\n\nasync function applyRunProjection(tx: GraphRuntimeTx, input: RunEventInput) {\n  const payload = toObject(input.payload)\n  const now = new Date()\n  if (input.eventType === RUN_EVENT_TYPE.RUN_START) {\n    await tx.graphRun.update({\n      where: { id: input.runId },\n      data: {\n        status: RUN_STATUS.RUNNING,\n        startedAt: now,\n      },\n    })\n    return\n  }\n\n  if (input.eventType === RUN_EVENT_TYPE.RUN_COMPLETE) {\n    await tx.graphRun.update({\n      where: { id: input.runId },\n      data: {\n        status: RUN_STATUS.COMPLETED,\n        output: payload,\n        finishedAt: now,\n        leaseOwner: null,\n        leaseExpiresAt: null,\n      },\n    })\n    await tx.graphStep.updateMany({\n      where: {\n        runId: input.runId,\n        status: {\n          in: [RUN_STEP_STATUS.PENDING, RUN_STEP_STATUS.RUNNING],\n        },\n      },\n      data: {\n        status: RUN_STEP_STATUS.COMPLETED,\n        finishedAt: now,\n      },\n    })\n    return\n  }\n\n  if (input.eventType === RUN_EVENT_TYPE.RUN_ERROR) {\n    await tx.graphRun.update({\n      where: { id: input.runId },\n      data: {\n        status: RUN_STATUS.FAILED,\n        errorCode: readString(payload, 'errorCode'),\n        errorMessage: resolveErrorMessage(payload),\n        finishedAt: now,\n        leaseOwner: null,\n        leaseExpiresAt: null,\n      },\n    })\n    await tx.graphStep.updateMany({\n      where: {\n        runId: input.runId,\n        status: {\n          in: [RUN_STEP_STATUS.PENDING, RUN_STEP_STATUS.RUNNING],\n        },\n      },\n      data: {\n        status: RUN_STEP_STATUS.FAILED,\n        finishedAt: now,\n        lastErrorCode: readString(payload, 'errorCode'),\n        lastErrorMessage: resolveErrorMessage(payload),\n      },\n    })\n    return\n  }\n\n  if (input.eventType === RUN_EVENT_TYPE.RUN_CANCELED) {\n    await tx.graphRun.update({\n      where: { id: input.runId },\n      data: {\n        status: RUN_STATUS.CANCELED,\n        finishedAt: now,\n        leaseOwner: null,\n        leaseExpiresAt: null,\n      },\n    })\n    await tx.graphStep.updateMany({\n      where: {\n        runId: input.runId,\n        status: {\n          in: [RUN_STEP_STATUS.PENDING, RUN_STEP_STATUS.RUNNING],\n        },\n      },\n      data: {\n        status: RUN_STEP_STATUS.CANCELED,\n        finishedAt: now,\n        lastErrorCode: 'CANCELED',\n        lastErrorMessage: 'Run cancelled',\n      },\n    })\n    return\n  }\n\n  const stepProjection = buildStepProjection(input)\n  if (!stepProjection) return\n\n  const isStepFailed = input.eventType === RUN_EVENT_TYPE.STEP_ERROR\n  const isStepCompleted = input.eventType === RUN_EVENT_TYPE.STEP_COMPLETE\n  const nextStatus = isStepFailed\n    ? RUN_STEP_STATUS.FAILED\n    : isStepCompleted\n      ? RUN_STEP_STATUS.COMPLETED\n      : RUN_STEP_STATUS.RUNNING\n  await tx.graphRun.updateMany({\n    where: {\n      id: input.runId,\n      status: {\n        in: [RUN_STATUS.QUEUED, RUN_STATUS.RUNNING],\n      },\n    },\n    data: {\n      status: RUN_STATUS.RUNNING,\n      startedAt: now,\n    },\n  })\n\n  await tx.graphStep.upsert({\n    where: {\n      runId_stepKey: {\n        runId: input.runId,\n        stepKey: stepProjection.stepKey,\n      },\n    },\n    create: {\n      runId: input.runId,\n      stepKey: stepProjection.stepKey,\n      stepTitle: stepProjection.stepTitle,\n      status: nextStatus,\n      currentAttempt: stepProjection.attempt,\n      stepIndex: stepProjection.stepIndex,\n      stepTotal: stepProjection.stepTotal,\n      startedAt: now,\n      finishedAt: isStepCompleted || isStepFailed ? now : null,\n      lastErrorCode: isStepFailed ? readString(stepProjection.payload, 'errorCode') : null,\n      lastErrorMessage: isStepFailed\n        ? (readString(stepProjection.payload, 'message') || readString(stepProjection.payload, 'errorMessage'))\n        : null,\n    },\n    update: {\n      stepTitle: stepProjection.stepTitle,\n      status: nextStatus,\n      currentAttempt: stepProjection.attempt,\n      stepIndex: stepProjection.stepIndex,\n      stepTotal: stepProjection.stepTotal,\n      startedAt: undefined,\n      finishedAt: isStepCompleted || isStepFailed ? now : null,\n      lastErrorCode: isStepFailed ? readString(stepProjection.payload, 'errorCode') : null,\n      lastErrorMessage: isStepFailed\n        ? resolveErrorMessage(stepProjection.payload)\n        : null,\n    },\n  })\n\n  await tx.graphStepAttempt.upsert({\n    where: {\n      runId_stepKey_attempt: {\n        runId: input.runId,\n        stepKey: stepProjection.stepKey,\n        attempt: stepProjection.attempt,\n      },\n    },\n    create: {\n      runId: input.runId,\n      stepKey: stepProjection.stepKey,\n      attempt: stepProjection.attempt,\n      status: nextStatus,\n      outputText: isStepCompleted ? readString(stepProjection.payload, 'text') : null,\n      outputReasoning: isStepCompleted ? readString(stepProjection.payload, 'reasoning') : null,\n      errorCode: isStepFailed ? readString(stepProjection.payload, 'errorCode') : null,\n      errorMessage: isStepFailed ? resolveErrorMessage(stepProjection.payload) : null,\n      startedAt: now,\n      finishedAt: isStepCompleted || isStepFailed ? now : null,\n      usageJson: toObject(stepProjection.payload.usage),\n    },\n    update: {\n      status: nextStatus,\n      outputText: isStepCompleted ? readString(stepProjection.payload, 'text') : null,\n      outputReasoning: isStepCompleted ? readString(stepProjection.payload, 'reasoning') : null,\n      errorCode: isStepFailed ? readString(stepProjection.payload, 'errorCode') : null,\n      errorMessage: isStepFailed ? resolveErrorMessage(stepProjection.payload) : null,\n      finishedAt: isStepCompleted || isStepFailed ? now : null,\n      usageJson: toObject(stepProjection.payload.usage),\n    },\n  })\n\n  const artifactProjection = buildArtifactProjection({\n    runId: input.runId,\n    stepKey: stepProjection.stepKey,\n    payload: stepProjection.payload,\n    isStepFailed,\n    isStepCompleted,\n  })\n  if (!artifactProjection) return\n\n  await upsertArtifactStrict({\n    artifactModel: tx.graphArtifact,\n    runId: artifactProjection.runId,\n    stepKey: artifactProjection.stepKey,\n    artifactType: artifactProjection.artifactType,\n    refId: artifactProjection.refId,\n    versionHash: artifactProjection.versionHash || null,\n    payload: artifactProjection.payload || null,\n  })\n}\n\nexport async function createRun(input: CreateRunInput) {\n  const row = await runtimeClient.graphRun.create({\n    data: {\n      userId: input.userId,\n      projectId: input.projectId,\n      episodeId: input.episodeId || null,\n      workflowType: input.workflowType,\n      taskType: input.taskType || null,\n      taskId: input.taskId || null,\n      targetType: input.targetType,\n      targetId: input.targetId,\n      status: RUN_STATUS.QUEUED,\n      input: input.input || null,\n      leaseOwner: null,\n      leaseExpiresAt: null,\n      heartbeatAt: null,\n      workflowVersion: 1,\n      queuedAt: new Date(),\n      lastSeq: 0,\n    },\n  })\n  return mapRunRow(row)\n}\n\nexport async function attachTaskToRun(runId: string, taskId: string) {\n  const row = await runtimeClient.graphRun.update({\n    where: { id: runId },\n    data: {\n      taskId,\n    },\n  })\n  return mapRunRow(row)\n}\n\nexport async function getRunById(runId: string) {\n  const row = await runtimeClient.graphRun.findUnique({\n    where: { id: runId },\n  })\n  if (!row) return null\n  return mapRunRow(row)\n}\n\nexport async function findReusableActiveRun(params: {\n  userId: string\n  projectId: string\n  workflowType: string\n  targetType: string\n  targetId: string\n}) {\n  const rows = await runtimeClient.graphRun.findMany({\n    where: {\n      userId: params.userId,\n      projectId: params.projectId,\n      workflowType: params.workflowType,\n      targetType: params.targetType,\n      targetId: params.targetId,\n      status: {\n        in: [RUN_STATUS.QUEUED, RUN_STATUS.RUNNING, RUN_STATUS.CANCELING],\n      },\n    },\n    orderBy: [\n      { updatedAt: 'desc' },\n      { createdAt: 'desc' },\n    ],\n    take: 1,\n  })\n  const row = rows[0]\n  return row ? mapRunRow(row) : null\n}\n\nexport async function claimRunLease(params: {\n  runId: string\n  userId: string\n  workerId: string\n  leaseMs: number\n}) {\n  const now = new Date()\n  const leaseExpiresAt = new Date(now.getTime() + Math.max(5_000, Math.floor(params.leaseMs)))\n  const result = await runtimeClient.graphRun.updateMany({\n    where: {\n      id: params.runId,\n      userId: params.userId,\n      status: {\n        in: [RUN_STATUS.QUEUED, RUN_STATUS.RUNNING, RUN_STATUS.CANCELING],\n      },\n      OR: [\n        { leaseOwner: null },\n        { leaseOwner: params.workerId },\n        { leaseExpiresAt: null },\n        { leaseExpiresAt: { lt: now } },\n      ],\n    },\n    data: {\n      leaseOwner: params.workerId,\n      leaseExpiresAt,\n      heartbeatAt: now,\n    },\n  })\n  if (result.count === 0) {\n    return null\n  }\n  return await getRunById(params.runId)\n}\n\nexport async function renewRunLease(params: {\n  runId: string\n  userId: string\n  workerId: string\n  leaseMs: number\n}) {\n  const now = new Date()\n  const leaseExpiresAt = new Date(now.getTime() + Math.max(5_000, Math.floor(params.leaseMs)))\n  const result = await runtimeClient.graphRun.updateMany({\n    where: {\n      id: params.runId,\n      userId: params.userId,\n      leaseOwner: params.workerId,\n      status: {\n        in: [RUN_STATUS.QUEUED, RUN_STATUS.RUNNING, RUN_STATUS.CANCELING],\n      },\n    },\n    data: {\n      leaseExpiresAt,\n      heartbeatAt: now,\n    },\n  })\n  if (result.count === 0) {\n    return null\n  }\n  return await getRunById(params.runId)\n}\n\nexport async function releaseRunLease(params: {\n  runId: string\n  workerId: string\n}) {\n  await runtimeClient.graphRun.updateMany({\n    where: {\n      id: params.runId,\n      leaseOwner: params.workerId,\n    },\n    data: {\n      leaseOwner: null,\n      leaseExpiresAt: null,\n    },\n  })\n}\n\nexport async function getRunSnapshot(runId: string) {\n  const [run, steps] = await Promise.all([\n    runtimeClient.graphRun.findUnique({\n      where: { id: runId },\n    }),\n    runtimeClient.graphStep.findMany({\n      where: { runId },\n      orderBy: [\n        { stepIndex: 'asc' },\n        { updatedAt: 'asc' },\n      ],\n    }),\n  ])\n  if (!run) return null\n  return {\n    run: mapRunRow(run),\n    steps: steps.map(mapStepRow),\n  }\n}\n\nexport async function listRuns(input: ListRunsInput) {\n  const safeLimit = Number.isFinite(input.limit || 50)\n    ? Math.min(Math.max(Math.floor(input.limit || 50), 1), 200)\n    : 50\n  const statusFilter = Array.isArray(input.statuses) && input.statuses.length > 0\n    ? { in: input.statuses }\n    : undefined\n  const rows = await runtimeClient.graphRun.findMany({\n    where: {\n      userId: input.userId,\n      ...(input.projectId ? { projectId: input.projectId } : {}),\n      ...(input.workflowType ? { workflowType: input.workflowType } : {}),\n      ...(input.targetType ? { targetType: input.targetType } : {}),\n      ...(input.targetId ? { targetId: input.targetId } : {}),\n      ...(input.episodeId ? { episodeId: input.episodeId } : {}),\n      ...(statusFilter ? { status: statusFilter } : {}),\n    },\n    orderBy: { createdAt: 'desc' },\n    take: safeLimit,\n  })\n  return rows.map(mapRunRow)\n}\n\nexport async function requestRunCancel(params: {\n  runId: string\n  userId: string\n}) {\n  await runtimeClient.graphRun.updateMany({\n    where: {\n      id: params.runId,\n      userId: params.userId,\n      status: {\n        in: [RUN_STATUS.QUEUED, RUN_STATUS.RUNNING],\n      },\n    },\n    data: {\n      status: RUN_STATUS.CANCELING,\n      cancelRequestedAt: new Date(),\n    },\n  })\n  const row = await runtimeClient.graphRun.findUnique({\n    where: { id: params.runId },\n  })\n  return row ? mapRunRow(row) : null\n}\n\nexport async function appendRunEventWithSeq(input: RunEventInput): Promise<RunEvent> {\n  return await runtimeClient.$transaction(async (tx) => {\n    const run = await tx.graphRun.update({\n      where: { id: input.runId },\n      data: {\n        lastSeq: { increment: 1 },\n      },\n      select: {\n        id: true,\n        lastSeq: true,\n      },\n    })\n\n    const created = await tx.graphEvent.create({\n      data: {\n        runId: input.runId,\n        projectId: input.projectId,\n        userId: input.userId,\n        seq: run.lastSeq,\n        eventType: input.eventType,\n        stepKey: input.stepKey || null,\n        attempt: input.attempt || null,\n        lane: input.lane || null,\n        payload: input.payload || null,\n      },\n    })\n\n    await applyRunProjection(tx, input)\n    return mapEventRow(created)\n  })\n}\n\nexport async function listRunEventsAfterSeq(params: {\n  runId: string\n  userId: string\n  afterSeq?: number\n  limit?: number\n}) {\n  const run = await runtimeClient.graphRun.findUnique({\n    where: { id: params.runId },\n    select: {\n      id: true,\n      userId: true,\n    },\n  })\n  if (!run || run.userId !== params.userId) return []\n  const safeAfterSeq = Number.isFinite(params.afterSeq || 0) ? Math.max(0, Math.floor(params.afterSeq || 0)) : 0\n  const safeLimit = Number.isFinite(params.limit || 200)\n    ? Math.min(Math.max(Math.floor(params.limit || 200), 1), 2000)\n    : 200\n  const rows = await runtimeClient.graphEvent.findMany({\n    where: {\n      runId: params.runId,\n      seq: {\n        gt: safeAfterSeq,\n      },\n    },\n    orderBy: { seq: 'asc' },\n    take: safeLimit,\n  })\n  return rows.map(mapEventRow)\n}\n\nexport function assertCheckpointStateSize(state: JsonRecord) {\n  const serialized = JSON.stringify(state)\n  const bytes = Buffer.byteLength(serialized, 'utf8')\n  if (bytes > RUN_STATE_MAX_BYTES) {\n    throw new Error(`checkpoint state too large: ${bytes} bytes (max ${RUN_STATE_MAX_BYTES})`)\n  }\n  return bytes\n}\n\nexport function buildLeanState(params: {\n  refs: StateRef\n  meta?: JsonRecord\n}) {\n  return {\n    refs: {\n      scriptId: params.refs.scriptId || null,\n      storyboardId: params.refs.storyboardId || null,\n      voiceLineBatchId: params.refs.voiceLineBatchId || null,\n      versionHash: params.refs.versionHash || null,\n      cursor: params.refs.cursor || null,\n    },\n    meta: params.meta || {},\n  }\n}\n\nexport async function createCheckpoint(params: {\n  runId: string\n  nodeKey: string\n  version: number\n  state: JsonRecord\n}) {\n  const bytes = assertCheckpointStateSize(params.state)\n  return await runtimeClient.graphCheckpoint.create({\n    data: {\n      runId: params.runId,\n      nodeKey: params.nodeKey,\n      version: params.version,\n      stateJson: params.state,\n      stateBytes: bytes,\n    },\n  })\n}\n\nexport async function listCheckpoints(params: {\n  runId: string\n  nodeKey?: string\n  limit?: number\n}) {\n  const safeLimit = Number.isFinite(params.limit || 20)\n    ? Math.min(Math.max(Math.floor(params.limit || 20), 1), 200)\n    : 20\n  return await runtimeClient.graphCheckpoint.findMany({\n    where: {\n      runId: params.runId,\n      ...(params.nodeKey ? { nodeKey: params.nodeKey } : {}),\n    },\n    orderBy: { version: 'desc' },\n    take: safeLimit,\n  })\n}\n\nexport async function createArtifact(params: {\n  runId: string\n  stepKey?: string | null\n  artifactType: string\n  refId: string\n  versionHash?: string | null\n  payload?: JsonRecord | null\n}) {\n  const artifactModel = (runtimeClient as unknown as { graphArtifact?: GraphArtifactModel }).graphArtifact\n  if (!artifactModel || typeof artifactModel.upsert !== 'function') {\n    if (process.env.NODE_ENV !== 'test') {\n      throw new Error('graphArtifact model unavailable')\n    }\n    return {\n      id: '',\n      runId: params.runId,\n      stepKey: params.stepKey || '__run__',\n      artifactType: params.artifactType,\n      refId: params.refId,\n      versionHash: params.versionHash || null,\n      payload: params.payload || null,\n      createdAt: new Date().toISOString(),\n    }\n  }\n  const stepKey = typeof params.stepKey === 'string' && params.stepKey.trim()\n    ? params.stepKey.trim()\n    : '__run__'\n  const artifactType = params.artifactType.trim()\n  const refId = params.refId.trim()\n  if (!artifactType) {\n    throw new Error('artifactType is required')\n  }\n  if (!refId) {\n    throw new Error('refId is required')\n  }\n\n  const row = await upsertArtifactStrict({\n    artifactModel,\n    runId: params.runId,\n    stepKey,\n    artifactType,\n    refId,\n    versionHash: params.versionHash || null,\n    payload: params.payload || null,\n  })\n  return mapArtifactRow(row)\n}\n\nexport async function listArtifacts(params: {\n  runId: string\n  stepKey?: string\n  artifactType?: string\n  refId?: string\n  limit?: number\n}) {\n  const artifactModel = (runtimeClient as unknown as { graphArtifact?: GraphArtifactModel }).graphArtifact\n  if (!artifactModel || typeof artifactModel.findMany !== 'function') {\n    if (process.env.NODE_ENV !== 'test') {\n      throw new Error('graphArtifact model unavailable')\n    }\n    return []\n  }\n  const safeLimit = Number.isFinite(params.limit || 200)\n    ? Math.min(Math.max(Math.floor(params.limit || 200), 1), 2000)\n    : 200\n  const rows = await artifactModel.findMany({\n    where: {\n      runId: params.runId,\n      ...(params.stepKey ? { stepKey: params.stepKey } : {}),\n      ...(params.artifactType ? { artifactType: params.artifactType } : {}),\n      ...(params.refId ? { refId: params.refId } : {}),\n    },\n    orderBy: { createdAt: 'desc' },\n    take: safeLimit,\n  })\n  return rows.map(mapArtifactRow)\n}\n\nexport async function retryFailedStep(params: {\n  runId: string\n  userId: string\n  stepKey: string\n}) {\n  const stepKey = params.stepKey.trim()\n  if (!stepKey) {\n    throw new Error('stepKey is required')\n  }\n\n  return await runtimeClient.$transaction(async (tx) => {\n    const run = await tx.graphRun.findUnique({\n      where: { id: params.runId },\n    })\n    if (!run || run.userId !== params.userId) {\n      return null\n    }\n\n    const step = await tx.graphStep.findUnique({\n      where: {\n        runId_stepKey: {\n          runId: params.runId,\n          stepKey,\n        },\n      },\n    })\n    if (!step) {\n      throw new Error('RUN_STEP_NOT_FOUND')\n    }\n    if (step.status !== RUN_STEP_STATUS.FAILED) {\n      throw new Error('RUN_STEP_NOT_FAILED')\n    }\n\n    const steps = await tx.graphStep.findMany({\n      where: { runId: params.runId },\n      orderBy: [\n        { stepIndex: 'asc' },\n        { updatedAt: 'asc' },\n      ],\n    })\n    const now = new Date()\n    const nextAttempt = Math.max(1, step.currentAttempt + 1)\n    const invalidatedStepKeys = resolveRetryInvalidationStepKeys({\n      workflowType: run.workflowType,\n      stepKey,\n      existingStepKeys: steps.map((item) => item.stepKey),\n    })\n\n    const updatedRun = await tx.graphRun.update({\n      where: { id: params.runId },\n      data: {\n        status: RUN_STATUS.RUNNING,\n        errorCode: null,\n        errorMessage: null,\n        finishedAt: null,\n        cancelRequestedAt: null,\n        startedAt: run.startedAt || now,\n      },\n    })\n    await tx.graphStep.updateMany({\n      where: {\n        runId: params.runId,\n        stepKey: { in: invalidatedStepKeys },\n      },\n      data: {\n        status: RUN_STEP_STATUS.PENDING,\n        currentAttempt: 0,\n        startedAt: null,\n        finishedAt: null,\n        lastErrorCode: null,\n        lastErrorMessage: null,\n      },\n    })\n    const updatedStep = await tx.graphStep.update({\n      where: {\n        runId_stepKey: {\n          runId: params.runId,\n          stepKey,\n        },\n      },\n      data: {\n        currentAttempt: nextAttempt,\n      },\n    })\n    await tx.graphArtifact.deleteMany({\n      where: {\n        runId: params.runId,\n        stepKey: { in: invalidatedStepKeys },\n      },\n    })\n\n    return {\n      run: mapRunRow(updatedRun),\n      step: mapStepRow(updatedStep),\n      retryAttempt: nextAttempt,\n      invalidatedStepKeys,\n    }\n  })\n}\n"
  },
  {
    "path": "src/lib/run-runtime/task-bridge.ts",
    "content": "import { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types'\nimport { RUN_EVENT_TYPE, type RunEventInput } from './types'\n\ntype JsonRecord = Record<string, unknown>\n\nfunction toObject(value: unknown): JsonRecord {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as JsonRecord\n}\n\nfunction readString(payload: JsonRecord, key: string): string | null {\n  const value = payload[key]\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  return trimmed || null\n}\n\nfunction readInt(payload: JsonRecord, key: string): number | null {\n  const value = payload[key]\n  if (typeof value === 'number' && Number.isFinite(value)) return Math.max(1, Math.floor(value))\n  if (typeof value === 'string' && value.trim()) {\n    const parsed = Number.parseInt(value, 10)\n    if (Number.isFinite(parsed)) return Math.max(1, parsed)\n  }\n  return null\n}\n\nfunction resolveRunId(payload: JsonRecord): string | null {\n  const direct = readString(payload, 'runId')\n  if (direct) return direct\n  const meta = toObject(payload.meta)\n  return readString(meta, 'runId')\n}\n\nfunction normalizeLifecycleType(value: string | null): string | null {\n  if (!value) return null\n  if (value === TASK_EVENT_TYPE.PROGRESS) return TASK_EVENT_TYPE.PROCESSING\n  if (\n    value === TASK_EVENT_TYPE.CREATED ||\n    value === TASK_EVENT_TYPE.PROCESSING ||\n    value === TASK_EVENT_TYPE.COMPLETED ||\n    value === TASK_EVENT_TYPE.FAILED\n  ) {\n    return value\n  }\n  return null\n}\n\nfunction stageLooksCompleted(stage: string | null): boolean {\n  if (!stage) return false\n  return (\n    stage === 'llm_completed' ||\n    stage === 'worker_llm_completed' ||\n    stage === 'worker_llm_complete' ||\n    stage === 'llm_proxy_persist' ||\n    stage === 'completed'\n  )\n}\n\nfunction stageLooksFailed(stage: string | null): boolean {\n  if (!stage) return false\n  return stage === 'llm_error' || stage === 'worker_llm_error' || stage === 'error'\n}\n\nexport function mapTaskSSEEventToRunEvents(event: SSEEvent): RunEventInput[] {\n  const payload = toObject(event.payload)\n  const runId = resolveRunId(payload)\n  if (!runId) return []\n\n  const base = {\n    runId,\n    projectId: event.projectId,\n    userId: event.userId,\n  }\n\n  const stepKey = readString(payload, 'stepKey') || readString(payload, 'stepId')\n  const attempt = readInt(payload, 'stepAttempt') || readInt(payload, 'attempt')\n\n  if (event.type === TASK_SSE_EVENT_TYPE.STREAM) {\n    const stream = toObject(payload.stream)\n    const delta = readString(stream, 'delta')\n    if (!delta) return []\n    const lane = readString(stream, 'lane')\n    const kind = readString(stream, 'kind')\n    const normalizedLane = lane === 'reasoning' || kind === 'reasoning' ? 'reasoning' : 'text'\n    const resolvedStepKey = stepKey || (event.taskType ? `step:${event.taskType}` : null)\n    if (!resolvedStepKey) return []\n    return [{\n      ...base,\n      eventType: RUN_EVENT_TYPE.STEP_CHUNK,\n      stepKey: resolvedStepKey,\n      attempt,\n      lane: normalizedLane,\n      payload,\n    }]\n  }\n\n  const lifecycleType = normalizeLifecycleType(readString(payload, 'lifecycleType'))\n  if (!lifecycleType) return []\n\n  if (lifecycleType === TASK_EVENT_TYPE.CREATED) {\n    return [{\n      ...base,\n      eventType: RUN_EVENT_TYPE.RUN_START,\n      payload,\n    }]\n  }\n\n  if (lifecycleType === TASK_EVENT_TYPE.PROCESSING) {\n    if (!stepKey) return []\n    const events: RunEventInput[] = [{\n      ...base,\n      eventType: RUN_EVENT_TYPE.STEP_START,\n      stepKey,\n      attempt,\n      payload,\n    }]\n\n    const stage = readString(payload, 'stage')\n    const done = payload.done === true\n    const hasErrorObject = Object.keys(toObject(payload.error)).length > 0\n    if (done || stageLooksCompleted(stage)) {\n      events.push({\n        ...base,\n        eventType: RUN_EVENT_TYPE.STEP_COMPLETE,\n        stepKey,\n        attempt,\n        payload,\n      })\n      return events\n    }\n\n    if (stageLooksFailed(stage) || hasErrorObject) {\n      events.push({\n        ...base,\n        eventType: RUN_EVENT_TYPE.STEP_ERROR,\n        stepKey,\n        attempt,\n        payload,\n      })\n    }\n    return events\n  }\n\n  if (lifecycleType === TASK_EVENT_TYPE.COMPLETED) {\n    const events: RunEventInput[] = []\n    if (stepKey) {\n      events.push({\n        ...base,\n        eventType: RUN_EVENT_TYPE.STEP_COMPLETE,\n        stepKey,\n        attempt,\n        payload,\n      })\n    }\n    events.push({\n      ...base,\n      eventType: RUN_EVENT_TYPE.RUN_COMPLETE,\n      payload,\n    })\n    return events\n  }\n\n  if (lifecycleType === TASK_EVENT_TYPE.FAILED) {\n    const events: RunEventInput[] = []\n    if (stepKey) {\n      events.push({\n        ...base,\n        eventType: RUN_EVENT_TYPE.STEP_ERROR,\n        stepKey,\n        attempt,\n        payload,\n      })\n    }\n    events.push({\n      ...base,\n      eventType: RUN_EVENT_TYPE.RUN_ERROR,\n      payload,\n    })\n    return events\n  }\n\n  return []\n}\n"
  },
  {
    "path": "src/lib/run-runtime/types.ts",
    "content": "export const RUN_STATUS = {\n  QUEUED: 'queued',\n  RUNNING: 'running',\n  COMPLETED: 'completed',\n  FAILED: 'failed',\n  CANCELING: 'canceling',\n  CANCELED: 'canceled',\n} as const\n\nexport type RunStatus = (typeof RUN_STATUS)[keyof typeof RUN_STATUS]\n\nexport const RUN_STEP_STATUS = {\n  PENDING: 'pending',\n  RUNNING: 'running',\n  COMPLETED: 'completed',\n  FAILED: 'failed',\n  CANCELED: 'canceled',\n} as const\n\nexport type RunStepStatus = (typeof RUN_STEP_STATUS)[keyof typeof RUN_STEP_STATUS]\n\nexport const RUN_EVENT_TYPE = {\n  RUN_START: 'run.start',\n  STEP_START: 'step.start',\n  STEP_CHUNK: 'step.chunk',\n  STEP_COMPLETE: 'step.complete',\n  STEP_ERROR: 'step.error',\n  RUN_COMPLETE: 'run.complete',\n  RUN_ERROR: 'run.error',\n  RUN_CANCELED: 'run.canceled',\n} as const\n\nexport type RunEventType = (typeof RUN_EVENT_TYPE)[keyof typeof RUN_EVENT_TYPE]\n\nexport type RunEventInput = {\n  runId: string\n  projectId: string\n  userId: string\n  eventType: RunEventType\n  stepKey?: string | null\n  attempt?: number | null\n  lane?: 'text' | 'reasoning' | null\n  payload?: Record<string, unknown> | null\n}\n\nexport type RunEvent = {\n  id: string\n  runId: string\n  projectId: string\n  userId: string\n  seq: number\n  eventType: RunEventType\n  stepKey?: string | null\n  attempt?: number | null\n  lane?: 'text' | 'reasoning' | null\n  payload?: Record<string, unknown> | null\n  createdAt: string\n}\n\nexport type CreateRunInput = {\n  userId: string\n  projectId: string\n  episodeId?: string | null\n  workflowType: string\n  taskType?: string | null\n  taskId?: string | null\n  targetType: string\n  targetId: string\n  input?: Record<string, unknown> | null\n}\n\nexport type RunLeaseState = {\n  leaseOwner?: string | null\n  leaseExpiresAt?: string | null\n  heartbeatAt?: string | null\n  workflowVersion?: number\n}\n\nexport type ListRunsInput = {\n  userId: string\n  projectId?: string\n  workflowType?: string\n  targetType?: string\n  targetId?: string\n  episodeId?: string\n  statuses?: RunStatus[]\n  limit?: number\n}\n\nexport type StateRef = {\n  scriptId?: string\n  storyboardId?: string\n  voiceLineBatchId?: string\n  versionHash?: string\n  cursor?: string\n}\n\nexport const RUN_STATE_MAX_BYTES = 64 * 1024\n"
  },
  {
    "path": "src/lib/run-runtime/workflow-lease.ts",
    "content": "import { TaskTerminatedError } from '@/lib/task/errors'\nimport { RUN_STATUS } from './types'\nimport { claimRunLease, getRunById, releaseRunLease, renewRunLease } from './service'\n\nconst DEFAULT_RUN_LEASE_MS = 30_000\n\nexport function getDefaultRunLeaseMs() {\n  return DEFAULT_RUN_LEASE_MS\n}\n\nexport async function assertWorkflowRunActive(params: {\n  runId: string\n  workerId: string\n  stage: string\n}) {\n  const run = await getRunById(params.runId)\n  if (!run) {\n    throw new TaskTerminatedError(params.runId, `Run terminated during ${params.stage}: run not found`)\n  }\n  if (run.leaseOwner !== params.workerId) {\n    throw new TaskTerminatedError(params.runId, `Run terminated during ${params.stage}: lease lost`)\n  }\n  if (\n    run.status === RUN_STATUS.CANCELING\n    || run.status === RUN_STATUS.CANCELED\n    || run.status === RUN_STATUS.COMPLETED\n    || run.status === RUN_STATUS.FAILED\n  ) {\n    throw new TaskTerminatedError(params.runId, `Run terminated during ${params.stage}`)\n  }\n}\n\nexport async function withWorkflowRunLease<T>(params: {\n  runId: string\n  userId: string\n  workerId: string\n  leaseMs?: number\n  run: () => Promise<T>\n}): Promise<{ claimed: boolean; result: T | null }> {\n  const leaseMs = params.leaseMs ?? DEFAULT_RUN_LEASE_MS\n  const claimed = await claimRunLease({\n    runId: params.runId,\n    userId: params.userId,\n    workerId: params.workerId,\n    leaseMs,\n  })\n  if (!claimed) {\n    return { claimed: false, result: null }\n  }\n\n  const heartbeatTimer = setInterval(() => {\n    void renewRunLease({\n      runId: params.runId,\n      userId: params.userId,\n      workerId: params.workerId,\n      leaseMs,\n    })\n  }, Math.max(5_000, Math.floor(leaseMs / 3)))\n\n  try {\n    return {\n      claimed: true,\n      result: await params.run(),\n    }\n  } finally {\n    clearInterval(heartbeatTimer)\n    await releaseRunLease({\n      runId: params.runId,\n      workerId: params.workerId,\n    })\n  }\n}\n"
  },
  {
    "path": "src/lib/run-runtime/workflow.ts",
    "content": "import { TASK_TYPE, type TaskType } from '@/lib/task/types'\n\nconst AI_TASK_TYPES: ReadonlySet<TaskType> = new Set<TaskType>(Object.values(TASK_TYPE))\n\nexport function isAiTaskType(type: TaskType): boolean {\n  return AI_TASK_TYPES.has(type)\n}\n\nexport function workflowTypeFromTaskType(type: TaskType): string {\n  return type\n}\n\n"
  },
  {
    "path": "src/lib/server-boot.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\n// 服务器启动时生成的唯一ID，用于检测服务器重启\n// 每次服务器重启，这个值都会变化\nexport const SERVER_BOOT_ID = `boot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`\n\n_ulogInfo(`[Server] Boot ID: ${SERVER_BOOT_ID}`)\n"
  },
  {
    "path": "src/lib/srt.ts",
    "content": "/**\n * SRT字幕条目\n */\nexport interface SRTEntry {\n  index: number\n  startTime: string\n  endTime: string\n  text: string\n}\n\n/**\n * 解析SRT格式文本\n * @param srtText SRT格式文本\n * @returns SRT条目数组\n */\nexport function parseSRT(srtText: string): SRTEntry[] {\n  const entries: SRTEntry[] = []\n  \n  // 按空行分割\n  const blocks = srtText.trim().split(/\\n\\s*\\n/)\n  \n  for (const block of blocks) {\n    const lines = block.trim().split('\\n')\n    if (lines.length < 3) continue\n    \n    const index = parseInt(lines[0])\n    const timeLine = lines[1]\n    const text = lines.slice(2).join('\\n')\n    \n    // 解析时间行：00:00:00,000 --> 00:00:02,000\n    const timeMatch = timeLine.match(/(\\S+)\\s*-->\\s*(\\S+)/)\n    if (!timeMatch) continue\n    \n    entries.push({\n      index,\n      startTime: timeMatch[1],\n      endTime: timeMatch[2],\n      text\n    })\n  }\n  \n  return entries\n}\n\n/**\n * 根据序号范围切割SRT内容\n * @param srtText 完整SRT文本\n * @param start 起始序号（包含）\n * @param end 结束序号（包含）\n * @returns 切割后的SRT文本\n */\nexport function sliceSRT(srtText: string, start: number, end: number): string {\n  const entries = parseSRT(srtText)\n  \n  // 过滤指定范围的条目\n  const slicedEntries = entries.filter(entry => entry.index >= start && entry.index <= end)\n  \n  // 重新组装为SRT格式\n  return slicedEntries.map(entry => \n    `${entry.index}\\n${entry.startTime} --> ${entry.endTime}\\n${entry.text}`\n  ).join('\\n\\n')\n}\n\n/**\n * 计算SRT片段的总时长（秒）\n * @param srtText SRT文本\n * @returns 总时长（秒）\n */\nexport function calculateSRTDuration(srtText: string): number {\n  const entries = parseSRT(srtText)\n  if (entries.length === 0) return 0\n  \n  const firstEntry = entries[0]\n  const lastEntry = entries[entries.length - 1]\n  \n  const startSeconds = timeToSeconds(firstEntry.startTime)\n  const endSeconds = timeToSeconds(lastEntry.endTime)\n  \n  return endSeconds - startSeconds\n}\n\n/**\n * 将SRT时间格式转换为秒\n * @param timeStr 时间字符串（例如：00:00:02,500）\n * @returns 秒数\n */\nfunction timeToSeconds(timeStr: string): number {\n  // 格式：HH:MM:SS,mmm\n  const match = timeStr.match(/(\\d+):(\\d+):(\\d+)[,.](\\d+)/)\n  if (!match) return 0\n  \n  const hours = parseInt(match[1])\n  const minutes = parseInt(match[2])\n  const seconds = parseInt(match[3])\n  const milliseconds = parseInt(match[4])\n  \n  return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000\n}\n\n/**\n * 验证是否为有效的SRT格式\n * @param text 文本内容\n * @returns 是否有效\n */\nexport function isValidSRT(text: string): boolean {\n  try {\n    const entries = parseSRT(text)\n    return entries.length > 0\n  } catch {\n    return false\n  }\n}\n\n/**\n * 从SRT格式中提取纯文本（移除序号和时间轴）\n * @param srtText SRT格式文本\n * @returns 纯文本内容\n */\nexport function extractTextFromSRT(srtText: string): string {\n  const entries = parseSRT(srtText)\n  return entries.map(entry => entry.text).join('\\n')\n}\n\n"
  },
  {
    "path": "src/lib/sse/shared-subscriber.ts",
    "content": "import { logError as _ulogError } from '@/lib/logging/core'\nimport type Redis from 'ioredis'\nimport { createSubscriber } from '@/lib/redis'\n\ntype MessageHandler = (message: string) => void\n\nclass SharedSubscriber {\n  private readonly subscriber: Redis\n  private readonly listeners = new Map<string, Map<number, MessageHandler>>()\n  private listenerSeq = 1\n\n  constructor() {\n    this.subscriber = createSubscriber()\n    this.subscriber.on('message', (channel, message) => {\n      const channelListeners = this.listeners.get(channel)\n      if (!channelListeners || channelListeners.size === 0) return\n\n      for (const handler of channelListeners.values()) {\n        try {\n          handler(message)\n        } catch (error: unknown) {\n          const message = error instanceof Error ? error.message : String(error)\n          _ulogError(`[SSE:shared] listener error channel=${channel} error=${message}`)\n        }\n      }\n    })\n\n    this.subscriber.on('error', (error) => {\n      _ulogError(`[SSE:shared] redis error: ${error?.message || 'unknown'}`)\n    })\n  }\n\n  async addChannelListener(channel: string, handler: MessageHandler): Promise<() => Promise<void>> {\n    let channelListeners = this.listeners.get(channel)\n    if (!channelListeners) {\n      channelListeners = new Map<number, MessageHandler>()\n      this.listeners.set(channel, channelListeners)\n    }\n\n    const listenerId = this.listenerSeq++\n    channelListeners.set(listenerId, handler)\n\n    try {\n      if (channelListeners.size === 1) {\n        await this.subscriber.subscribe(channel)\n      }\n    } catch (error) {\n      channelListeners.delete(listenerId)\n      if (channelListeners.size === 0) {\n        this.listeners.delete(channel)\n      }\n      throw error\n    }\n\n    return async () => {\n      const listeners = this.listeners.get(channel)\n      if (!listeners) return\n\n      listeners.delete(listenerId)\n      if (listeners.size > 0) return\n\n      this.listeners.delete(channel)\n      try {\n        await this.subscriber.unsubscribe(channel)\n      } catch {}\n    }\n  }\n}\n\ntype GlobalSharedSubscriber = typeof globalThis & {\n  __waoowaooSharedSubscriber?: SharedSubscriber\n}\n\nconst globalForSharedSubscriber = globalThis as GlobalSharedSubscriber\n\nexport function getSharedSubscriber() {\n  if (!globalForSharedSubscriber.__waoowaooSharedSubscriber) {\n    globalForSharedSubscriber.__waoowaooSharedSubscriber = new SharedSubscriber()\n  }\n  return globalForSharedSubscriber.__waoowaooSharedSubscriber\n}\n"
  },
  {
    "path": "src/lib/storage/bootstrap.ts",
    "content": "import { CreateBucketCommand, HeadBucketCommand, S3Client } from '@aws-sdk/client-s3'\nimport { createStorageProvider } from '@/lib/storage/factory'\nimport type { StorageFactoryOptions } from '@/lib/storage/types'\nimport { requireEnv } from '@/lib/storage/utils'\n\nconst DEFAULT_MINIO_REGION = 'us-east-1'\n\nexport type StorageBootstrapResult = 'skipped' | 'existing' | 'created'\n\ntype BucketErrorShape = {\n  name?: string\n  code?: string\n  Code?: string\n  $metadata?: {\n    httpStatusCode?: number\n  }\n}\n\nfunction isMissingBucketError(error: unknown): boolean {\n  if (!(error instanceof Error)) {\n    return false\n  }\n\n  const bucketError = error as BucketErrorShape\n  const errorName = bucketError.name || ''\n  const errorCode = bucketError.code || bucketError.Code || ''\n  const statusCode = bucketError.$metadata?.httpStatusCode\n\n  return errorName === 'NotFound'\n    || errorCode === 'NotFound'\n    || errorCode === 'NoSuchBucket'\n    || statusCode === 404\n}\n\nexport async function ensureMinioBucket(): Promise<Exclude<StorageBootstrapResult, 'skipped'>> {\n  const endpoint = requireEnv('MINIO_ENDPOINT')\n  const accessKeyId = requireEnv('MINIO_ACCESS_KEY')\n  const secretAccessKey = requireEnv('MINIO_SECRET_KEY')\n  const bucket = requireEnv('MINIO_BUCKET')\n  const region = (process.env.MINIO_REGION || DEFAULT_MINIO_REGION).trim() || DEFAULT_MINIO_REGION\n  const forcePathStyle = process.env.MINIO_FORCE_PATH_STYLE !== 'false'\n\n  const client = new S3Client({\n    endpoint,\n    region,\n    forcePathStyle,\n    credentials: {\n      accessKeyId,\n      secretAccessKey,\n    },\n  })\n\n  try {\n    await client.send(new HeadBucketCommand({ Bucket: bucket }))\n    return 'existing'\n  } catch (error: unknown) {\n    if (!isMissingBucketError(error)) {\n      throw error\n    }\n  }\n\n  await client.send(new CreateBucketCommand({ Bucket: bucket }))\n  return 'created'\n}\n\nexport async function ensureStorageReady(options: StorageFactoryOptions = {}): Promise<StorageBootstrapResult> {\n  const provider = createStorageProvider(options)\n\n  if (provider.kind !== 'minio') {\n    return 'skipped'\n  }\n\n  return await ensureMinioBucket()\n}\n"
  },
  {
    "path": "src/lib/storage/errors.ts",
    "content": "export class StorageProviderNotImplementedError extends Error {\n  constructor(type: string) {\n    super(`Storage provider \"${type}\" is not implemented`)\n    this.name = 'StorageProviderNotImplementedError'\n  }\n}\n\nexport class StorageConfigError extends Error {\n  constructor(message: string) {\n    super(message)\n    this.name = 'StorageConfigError'\n  }\n}\n"
  },
  {
    "path": "src/lib/storage/factory.ts",
    "content": "import { StorageConfigError } from '@/lib/storage/errors'\nimport { LocalStorageProvider } from '@/lib/storage/providers/local'\nimport { MinioStorageProvider } from '@/lib/storage/providers/minio'\nimport { CosStorageProvider } from '@/lib/storage/providers/cos'\nimport type { StorageFactoryOptions, StorageProvider, StorageType } from '@/lib/storage/types'\n\nfunction normalizeStorageType(rawType: string | undefined): StorageType {\n  const normalized = (rawType || 'minio').trim().toLowerCase()\n  if (normalized === 'minio' || normalized === 'local' || normalized === 'cos') {\n    return normalized\n  }\n  throw new StorageConfigError(`Unsupported STORAGE_TYPE: ${rawType}`)\n}\n\nexport function createStorageProvider(options: StorageFactoryOptions = {}): StorageProvider {\n  const type = normalizeStorageType(options.storageType || process.env.STORAGE_TYPE)\n\n  if (type === 'minio') {\n    return new MinioStorageProvider()\n  }\n  if (type === 'local') {\n    return new LocalStorageProvider()\n  }\n\n  return new CosStorageProvider()\n}\n"
  },
  {
    "path": "src/lib/storage/index.ts",
    "content": "import { createScopedLogger } from '@/lib/logging/core'\nimport { createStorageProvider } from '@/lib/storage/factory'\nimport type { DeleteObjectsResult, StorageProvider } from '@/lib/storage/types'\nimport { DEFAULT_SIGNED_URL_EXPIRES_SECONDS, withRetry } from '@/lib/storage/utils'\n\nconst storageLogger = createScopedLogger({\n  module: 'storage.provider',\n})\n\nconst UPLOAD_MAX_RETRIES = 3\nconst RETRY_DELAY_BASE_MS = 2000\n\nlet providerSingleton: StorageProvider | null = null\n\nexport function getStorageProvider(): StorageProvider {\n  if (!providerSingleton) {\n    providerSingleton = createStorageProvider()\n    storageLogger.info(`[Storage] provider initialized: ${providerSingleton.kind}`)\n  }\n  return providerSingleton\n}\n\nexport function getStorageType(): string {\n  return getStorageProvider().kind\n}\n\nexport function toFetchableUrl(inputUrl: string): string {\n  return getStorageProvider().toFetchableUrl(inputUrl)\n}\n\nexport function generateUniqueKey(prefix: string, ext: string = 'png'): string {\n  return getStorageProvider().generateUniqueKey({ prefix, ext })\n}\n\nexport async function uploadObject(\n  body: Buffer,\n  key: string,\n  maxRetries: number = UPLOAD_MAX_RETRIES,\n  contentType?: string,\n): Promise<string> {\n  const provider = getStorageProvider()\n\n  const result = await withRetry(\n    async () => {\n      return await provider.uploadObject({ key, body, contentType })\n    },\n    maxRetries,\n    RETRY_DELAY_BASE_MS,\n  )\n\n  return result.key\n}\n\nexport async function deleteObject(key: string): Promise<void> {\n  await getStorageProvider().deleteObject(key)\n}\n\nexport async function deleteObjects(keys: string[]): Promise<DeleteObjectsResult> {\n  return await getStorageProvider().deleteObjects(keys)\n}\n\nexport function extractStorageKey(input: string | null | undefined): string | null {\n  return getStorageProvider().extractStorageKey(input)\n}\n\nexport async function getObjectBuffer(key: string): Promise<Buffer> {\n  return await getStorageProvider().getObjectBuffer(key)\n}\n\nexport async function getSignedObjectUrl(key: string, expiresInSeconds: number = DEFAULT_SIGNED_URL_EXPIRES_SECONDS): Promise<string> {\n  return await getStorageProvider().getSignedObjectUrl({\n    key,\n    expiresInSeconds,\n  })\n}\n\nexport function getSignedUrl(key: string, expiresInSeconds: number = DEFAULT_SIGNED_URL_EXPIRES_SECONDS): string {\n  const provider = getStorageProvider()\n  if (provider.kind === 'local') {\n    return `/api/files/${encodeURIComponent(key)}`\n  }\n\n  return `/api/storage/sign?key=${encodeURIComponent(key)}&expires=${encodeURIComponent(String(expiresInSeconds))}`\n}\n\nexport function getSignedUrls(keys: string[], expiresInSeconds: number = DEFAULT_SIGNED_URL_EXPIRES_SECONDS): string[] {\n  return keys.map((key) => getSignedUrl(key, expiresInSeconds))\n}\n\nexport async function downloadAndUploadImage(\n  imageUrl: string,\n  key: string,\n  maxRetries: number = UPLOAD_MAX_RETRIES,\n): Promise<string> {\n  const sharp = (await import('sharp')).default\n\n  return await withRetry(async () => {\n    const response = await fetch(toFetchableUrl(imageUrl))\n    if (!response.ok) {\n      throw new Error(`Failed to download image: ${response.status} ${response.statusText}`)\n    }\n\n    const buffer = Buffer.from(await response.arrayBuffer())\n    let processed = await sharp(buffer).jpeg({ quality: 95, mozjpeg: true }).toBuffer()\n    let quality = 95\n    const maxSizeBytes = 10 * 1024 * 1024\n\n    while (processed.length > maxSizeBytes && quality > 60) {\n      quality -= 5\n      processed = await sharp(buffer).jpeg({ quality, mozjpeg: true }).toBuffer()\n    }\n\n    const jpgKey = key.replace(/\\.(png|webp)$/i, '.jpg')\n    return await uploadObject(processed, jpgKey, 1, 'image/jpeg')\n  }, maxRetries, RETRY_DELAY_BASE_MS)\n}\n\nexport async function downloadAndUploadVideo(\n  videoUrl: string,\n  key: string,\n  maxRetries: number = UPLOAD_MAX_RETRIES,\n  requestHeaders?: Record<string, string>,\n): Promise<string> {\n  return await withRetry(async () => {\n    const response = await fetch(toFetchableUrl(videoUrl), {\n      headers: {\n        'User-Agent': 'Mozilla/5.0 (compatible; VideoDownloader/1.0)',\n        ...(requestHeaders || {}),\n      },\n    })\n\n    if (!response.ok) {\n      throw new Error(`Failed to download video: ${response.status} ${response.statusText}`)\n    }\n\n    const buffer = Buffer.from(await response.arrayBuffer())\n    return await uploadObject(buffer, key, 1)\n  }, maxRetries, RETRY_DELAY_BASE_MS)\n}\n\nexport * from './signed-urls'\n"
  },
  {
    "path": "src/lib/storage/init.ts",
    "content": "import { ensureStorageReady } from '@/lib/storage/bootstrap'\nimport { requireEnv } from '@/lib/storage/utils'\n\nasync function main() {\n  const result = await ensureStorageReady()\n\n  if (result === 'skipped') {\n    return\n  }\n\n  const bucket = requireEnv('MINIO_BUCKET')\n  if (result === 'created') {\n    console.log(`[storage:init] created MinIO bucket \"${bucket}\"`)\n    return\n  }\n\n  console.log(`[storage:init] verified MinIO bucket \"${bucket}\"`)\n}\n\nvoid main().catch((error: unknown) => {\n  console.error('[storage:init] failed to prepare storage')\n  console.error(error)\n  process.exit(1)\n})\n"
  },
  {
    "path": "src/lib/storage/providers/cos.ts",
    "content": "import { StorageProviderNotImplementedError } from '@/lib/storage/errors'\nimport type { DeleteObjectsResult, SignedUrlParams, StorageProvider, UploadObjectParams, UploadObjectResult } from '@/lib/storage/types'\n\nexport class CosStorageProvider implements StorageProvider {\n  readonly kind = 'cos' as const\n\n  constructor() {\n    throw new StorageProviderNotImplementedError('cos')\n  }\n\n  async uploadObject(_params: UploadObjectParams): Promise<UploadObjectResult> {\n    throw new StorageProviderNotImplementedError('cos')\n  }\n\n  async deleteObject(_key: string): Promise<void> {\n    throw new StorageProviderNotImplementedError('cos')\n  }\n\n  async deleteObjects(_keys: string[]): Promise<DeleteObjectsResult> {\n    throw new StorageProviderNotImplementedError('cos')\n  }\n\n  async getSignedObjectUrl(_params: SignedUrlParams): Promise<string> {\n    throw new StorageProviderNotImplementedError('cos')\n  }\n\n  async getObjectBuffer(_key: string): Promise<Buffer> {\n    throw new StorageProviderNotImplementedError('cos')\n  }\n\n  extractStorageKey(_input: string | null | undefined): string | null {\n    throw new StorageProviderNotImplementedError('cos')\n  }\n\n  toFetchableUrl(_inputUrl: string): string {\n    throw new StorageProviderNotImplementedError('cos')\n  }\n\n  generateUniqueKey(_params: { prefix: string; ext: string }): string {\n    throw new StorageProviderNotImplementedError('cos')\n  }\n}\n"
  },
  {
    "path": "src/lib/storage/providers/local.ts",
    "content": "import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport type { DeleteObjectsResult, SignedUrlParams, StorageProvider, UploadObjectParams, UploadObjectResult } from '@/lib/storage/types'\nimport { normalizeKey, toFetchableUrl } from '@/lib/storage/utils'\n\nconst UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads'\n\nfunction resolveUploadPath(key: string): string {\n  return path.join(process.cwd(), UPLOAD_DIR, normalizeKey(key))\n}\n\nexport class LocalStorageProvider implements StorageProvider {\n  readonly kind = 'local' as const\n\n  async uploadObject(params: UploadObjectParams): Promise<UploadObjectResult> {\n    const normalizedKey = normalizeKey(params.key)\n    const filePath = resolveUploadPath(normalizedKey)\n    await fs.mkdir(path.dirname(filePath), { recursive: true })\n    await fs.writeFile(filePath, params.body)\n    return { key: normalizedKey }\n  }\n\n  async deleteObject(key: string): Promise<void> {\n    try {\n      await fs.unlink(resolveUploadPath(key))\n    } catch (error: unknown) {\n      const code = (error as { code?: string })?.code\n      if (code !== 'ENOENT') {\n        throw error\n      }\n    }\n  }\n\n  async deleteObjects(keys: string[]): Promise<DeleteObjectsResult> {\n    const validKeys = keys.filter((key) => typeof key === 'string' && key.trim().length > 0)\n    let success = 0\n    let failed = 0\n\n    for (const key of validKeys) {\n      try {\n        await this.deleteObject(key)\n        success += 1\n      } catch {\n        failed += 1\n      }\n    }\n\n    return { success, failed }\n  }\n\n  async getSignedObjectUrl(params: SignedUrlParams): Promise<string> {\n    void params.expiresInSeconds\n    return `/api/files/${encodeURIComponent(normalizeKey(params.key))}`\n  }\n\n  async getObjectBuffer(key: string): Promise<Buffer> {\n    return await fs.readFile(resolveUploadPath(key))\n  }\n\n  extractStorageKey(input: string | null | undefined): string | null {\n    if (!input) return null\n    if (input.startsWith('/api/files/')) {\n      return normalizeKey(decodeURIComponent(input.replace('/api/files/', '')))\n    }\n    if (!input.startsWith('http') && !input.startsWith('/')) {\n      return normalizeKey(input)\n    }\n\n    try {\n      const parsed = new URL(input)\n      return normalizeKey(parsed.pathname)\n    } catch {\n      return null\n    }\n  }\n\n  toFetchableUrl(inputUrl: string): string {\n    return toFetchableUrl(inputUrl)\n  }\n\n  generateUniqueKey(params: { prefix: string; ext: string }): string {\n    const timestamp = Date.now()\n    const random = Math.random().toString(36).slice(2, 8)\n    return `images/${params.prefix}-${timestamp}-${random}.${params.ext}`\n  }\n}\n"
  },
  {
    "path": "src/lib/storage/providers/minio.ts",
    "content": "import type { DeleteObjectsResult, SignedUrlParams, StorageProvider, UploadObjectParams, UploadObjectResult } from '@/lib/storage/types'\nimport { requireEnv, streamToBuffer, toFetchableUrl } from '@/lib/storage/utils'\n\nconst DEFAULT_MINIO_REGION = 'us-east-1'\n\ntype S3ClientLike = {\n  send(command: unknown): Promise<unknown>\n}\n\ntype S3SdkModule = {\n  S3Client: new (config: Record<string, unknown>) => S3ClientLike\n  PutObjectCommand: new (input: Record<string, unknown>) => unknown\n  DeleteObjectCommand: new (input: Record<string, unknown>) => unknown\n  DeleteObjectsCommand: new (input: Record<string, unknown>) => unknown\n  GetObjectCommand: new (input: Record<string, unknown>) => unknown\n}\n\ntype PresignerModule = {\n  getSignedUrl: (client: S3ClientLike, command: unknown, options: { expiresIn: number }) => Promise<string>\n}\n\nexport class MinioStorageProvider implements StorageProvider {\n  readonly kind = 'minio' as const\n\n  private readonly bucket: string\n  private readonly endpoint: string\n  private readonly region: string\n  private readonly forcePathStyle: boolean\n  private readonly accessKeyId: string\n  private readonly secretAccessKey: string\n  private clientPromise: Promise<S3ClientLike> | null = null\n\n  constructor() {\n    this.endpoint = requireEnv('MINIO_ENDPOINT')\n    this.accessKeyId = requireEnv('MINIO_ACCESS_KEY')\n    this.secretAccessKey = requireEnv('MINIO_SECRET_KEY')\n    this.bucket = requireEnv('MINIO_BUCKET')\n    this.region = process.env.MINIO_REGION || DEFAULT_MINIO_REGION\n    this.forcePathStyle = process.env.MINIO_FORCE_PATH_STYLE !== 'false'\n  }\n\n  private async loadSdk(): Promise<S3SdkModule> {\n    return await import('@aws-sdk/client-s3') as unknown as S3SdkModule\n  }\n\n  private async loadPresigner(): Promise<PresignerModule> {\n    return await import('@aws-sdk/s3-request-presigner') as unknown as PresignerModule\n  }\n\n  private async getClient(): Promise<S3ClientLike> {\n    if (!this.clientPromise) {\n      this.clientPromise = (async () => {\n        const { S3Client } = await this.loadSdk()\n        return new S3Client({\n          endpoint: this.endpoint,\n          region: this.region,\n          forcePathStyle: this.forcePathStyle,\n          credentials: {\n            accessKeyId: this.accessKeyId,\n            secretAccessKey: this.secretAccessKey,\n          },\n        })\n      })()\n    }\n    return await this.clientPromise\n  }\n\n  async uploadObject(params: UploadObjectParams): Promise<UploadObjectResult> {\n    const sdk = await this.loadSdk()\n    const client = await this.getClient()\n    await client.send(new sdk.PutObjectCommand({\n      Bucket: this.bucket,\n      Key: params.key,\n      Body: params.body,\n      ContentType: params.contentType,\n    }))\n\n    return { key: params.key }\n  }\n\n  async deleteObject(key: string): Promise<void> {\n    const sdk = await this.loadSdk()\n    const client = await this.getClient()\n    await client.send(new sdk.DeleteObjectCommand({\n      Bucket: this.bucket,\n      Key: key,\n    }))\n  }\n\n  async deleteObjects(keys: string[]): Promise<DeleteObjectsResult> {\n    const validKeys = keys.filter((key) => typeof key === 'string' && key.trim().length > 0)\n    if (validKeys.length === 0) {\n      return { success: 0, failed: 0 }\n    }\n\n    const sdk = await this.loadSdk()\n    const client = await this.getClient()\n    const result = await client.send(new sdk.DeleteObjectsCommand({\n      Bucket: this.bucket,\n      Delete: {\n        Objects: validKeys.map((key) => ({ Key: key })),\n      },\n    })) as { Deleted?: unknown[]; Errors?: unknown[] }\n\n    return {\n      success: result.Deleted?.length ?? 0,\n      failed: result.Errors?.length ?? 0,\n    }\n  }\n\n  async getSignedObjectUrl(params: SignedUrlParams): Promise<string> {\n    const sdk = await this.loadSdk()\n    const presigner = await this.loadPresigner()\n    const client = await this.getClient()\n\n    return await presigner.getSignedUrl(\n      client,\n      new sdk.GetObjectCommand({\n        Bucket: this.bucket,\n        Key: params.key,\n      }),\n      {\n        expiresIn: params.expiresInSeconds,\n      },\n    )\n  }\n\n  async getObjectBuffer(key: string): Promise<Buffer> {\n    const sdk = await this.loadSdk()\n    const client = await this.getClient()\n    const result = await client.send(new sdk.GetObjectCommand({\n      Bucket: this.bucket,\n      Key: key,\n    })) as { Body?: unknown }\n    return await streamToBuffer(result.Body)\n  }\n\n  extractStorageKey(input: string | null | undefined): string | null {\n    if (!input) return null\n\n    if (!input.startsWith('http') && !input.startsWith('/')) {\n      return input\n    }\n\n    try {\n      const parsed = new URL(input)\n      let pathname = parsed.pathname.replace(/^\\/+/, '')\n      const bucketPrefix = `${this.bucket}/`\n      if (pathname.startsWith(bucketPrefix)) {\n        pathname = pathname.slice(bucketPrefix.length)\n      }\n      if (parsed.hostname.startsWith(`${this.bucket}.`) && pathname) {\n        return pathname\n      }\n      return pathname || null\n    } catch {\n      return null\n    }\n  }\n\n  toFetchableUrl(inputUrl: string): string {\n    return toFetchableUrl(inputUrl)\n  }\n\n  generateUniqueKey(params: { prefix: string; ext: string }): string {\n    const timestamp = Date.now()\n    const random = Math.random().toString(36).slice(2, 8)\n    return `images/${params.prefix}-${timestamp}-${random}.${params.ext}`\n  }\n}\n"
  },
  {
    "path": "src/lib/storage/signed-urls.ts",
    "content": "import { decodeImageUrlsFromDb } from '@/lib/contracts/image-urls-contract'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport { getSignedUrl } from '@/lib/storage'\n\nexport type UnknownRecord = Record<string, unknown>\n\nexport interface AppLike {\n  imageUrls: string | null\n  descriptions: string | unknown[] | null\n  imageUrl: string | null\n  [key: string]: unknown\n}\n\nexport interface CharacterLike {\n  appearances?: AppLike[]\n  customVoiceUrl?: string | null\n  [key: string]: unknown\n}\n\nexport interface LocationImageLike {\n  imageUrl: string | null\n  [key: string]: unknown\n}\n\nexport interface LocationLike {\n  images?: LocationImageLike[]\n  [key: string]: unknown\n}\n\nexport interface ShotLike {\n  imageUrl: string | null\n  videoUrl: string | null\n  [key: string]: unknown\n}\n\nexport interface PanelLike {\n  imageUrl: string | null\n  sketchImageUrl: string | null\n  videoUrl: string | null\n  lipSyncVideoUrl: string | null\n  candidateImages: string | null\n  panelImageHistory?: string | null\n  imageHistory?: string | null\n  [key: string]: unknown\n}\n\nexport interface StoryboardLike {\n  panels?: PanelLike[]\n  imageHistory?: string | null\n  storyboardImageUrl: string | null\n  [key: string]: unknown\n}\n\nexport interface ProjectLike {\n  audioUrl?: string | null\n  characters?: CharacterLike[]\n  locations?: LocationLike[]\n  shots?: ShotLike[]\n  storyboards?: StoryboardLike[]\n  [key: string]: unknown\n}\n\nconst signedUrlLogger = createScopedLogger({\n  module: 'storage.signed-urls',\n})\nconst _ulogError = (...args: unknown[]) => signedUrlLogger.error(...args)\n\nexport function keyToSignedUrl(key: string | null, expires: number = 24 * 60 * 60): string | null {\n  if (!key) return null\n  if (key.startsWith('http://') || key.startsWith('https://')) {\n    return key\n  }\n  return getSignedUrl(key, expires)\n}\n\nexport function addSignedUrlsToCharacter(character: CharacterLike) {\n  const appearances = character.appearances?.map((app) => {\n    const imageUrls = decodeImageUrlsFromDb(app.imageUrls, 'appearance.imageUrls')\n      .map((key) => keyToSignedUrl(key))\n      .filter((url): url is string => !!url)\n\n    let descriptions: string[] | null = null\n    if (app.descriptions) {\n      try {\n        descriptions = typeof app.descriptions === 'string' ? JSON.parse(app.descriptions) : app.descriptions as string[]\n      } catch (error: unknown) {\n        _ulogError('[signed-url] failed to parse descriptions', app.descriptions, error)\n      }\n    }\n\n    return {\n      ...app,\n      imageUrl: keyToSignedUrl(app.imageUrl),\n      imageUrls,\n      descriptions,\n    }\n  }) || []\n\n  return {\n    ...character,\n    appearances,\n    customVoiceUrl: character.customVoiceUrl ? keyToSignedUrl(character.customVoiceUrl) : null,\n  }\n}\n\nexport function addSignedUrlToLocation(location: LocationLike) {\n  const images = location.images?.map((img) => ({\n    ...img,\n    imageUrl: keyToSignedUrl(img.imageUrl),\n  })) || []\n\n  return {\n    ...location,\n    images,\n  }\n}\n\nexport function addSignedUrlsToShot(shot: ShotLike) {\n  return {\n    ...shot,\n    imageUrl: keyToSignedUrl(shot.imageUrl),\n    videoUrl: keyToSignedUrl(shot.videoUrl),\n  }\n}\n\nexport function addSignedUrlToAssetCharacter(character: { imageUrl: string | null } & UnknownRecord) {\n  return {\n    ...character,\n    imageUrl: keyToSignedUrl(character.imageUrl),\n  }\n}\n\nexport function addSignedUrlToAssetLocation(location: { imageUrl: string | null } & UnknownRecord) {\n  return {\n    ...location,\n    imageUrl: keyToSignedUrl(location.imageUrl),\n  }\n}\n\nexport function addSignedUrlsToStoryboard(storyboard: StoryboardLike) {\n  let panels: PanelLike[] = []\n  if (storyboard.panels && Array.isArray(storyboard.panels)) {\n    panels = storyboard.panels.map((dbPanel) => {\n      let panelHistoryCount = 0\n      const historyField = dbPanel.panelImageHistory || dbPanel.imageHistory\n      if (historyField) {\n        try {\n          const history = JSON.parse(historyField)\n          panelHistoryCount = Array.isArray(history) ? history.length : 0\n        } catch {\n          panelHistoryCount = 0\n        }\n      }\n\n      let signedCandidateImages = dbPanel.candidateImages\n      if (signedCandidateImages) {\n        try {\n          const candidates = JSON.parse(signedCandidateImages)\n          if (Array.isArray(candidates)) {\n            const signedCandidates = candidates.map((candidate) => {\n              if (typeof candidate !== 'string') return candidate\n              if (candidate.startsWith('PENDING:')) return candidate\n              return keyToSignedUrl(candidate) || candidate\n            })\n            signedCandidateImages = JSON.stringify(signedCandidates)\n          }\n        } catch {\n          signedCandidateImages = dbPanel.candidateImages\n        }\n      }\n\n      return {\n        ...dbPanel,\n        imageUrl: dbPanel.imageUrl ? keyToSignedUrl(dbPanel.imageUrl) : null,\n        sketchImageUrl: keyToSignedUrl(dbPanel.sketchImageUrl),\n        videoUrl: dbPanel.videoUrl && !dbPanel.videoUrl.startsWith('http')\n          ? getSignedUrl(dbPanel.videoUrl, 7200)\n          : dbPanel.videoUrl,\n        lipSyncVideoUrl: dbPanel.lipSyncVideoUrl && !dbPanel.lipSyncVideoUrl.startsWith('http')\n          ? getSignedUrl(dbPanel.lipSyncVideoUrl, 7200)\n          : dbPanel.lipSyncVideoUrl,\n        candidateImages: signedCandidateImages,\n        historyCount: panelHistoryCount,\n      }\n    })\n  }\n\n  let historyCount = 0\n  if (storyboard.imageHistory) {\n    try {\n      const history = JSON.parse(storyboard.imageHistory)\n      historyCount = Array.isArray(history) ? history.length : 0\n    } catch {\n      historyCount = 0\n    }\n  }\n\n  return {\n    ...storyboard,\n    storyboardImageUrl: keyToSignedUrl(storyboard.storyboardImageUrl),\n    panels,\n    historyCount,\n  }\n}\n\nexport function addSignedUrlsToProject(project: ProjectLike) {\n  return {\n    ...project,\n    audioUrl: project.audioUrl ? getSignedUrl(project.audioUrl) : project.audioUrl,\n    characters: project.characters?.map(addSignedUrlsToCharacter) || [],\n    locations: project.locations?.map(addSignedUrlToLocation) || [],\n    shots: project.shots?.map(addSignedUrlsToShot) || [],\n    storyboards: project.storyboards?.map(addSignedUrlsToStoryboard) || [],\n  }\n}\n"
  },
  {
    "path": "src/lib/storage/types.ts",
    "content": "export type StorageType = 'minio' | 'local' | 'cos'\n\nexport interface UploadObjectParams {\n  key: string\n  body: Buffer\n  contentType?: string\n}\n\nexport interface UploadObjectResult {\n  key: string\n}\n\nexport interface DeleteObjectsResult {\n  success: number\n  failed: number\n}\n\nexport interface SignedUrlParams {\n  key: string\n  expiresInSeconds: number\n}\n\nexport interface StorageProvider {\n  readonly kind: StorageType\n  uploadObject(params: UploadObjectParams): Promise<UploadObjectResult>\n  deleteObject(key: string): Promise<void>\n  deleteObjects(keys: string[]): Promise<DeleteObjectsResult>\n  getSignedObjectUrl(params: SignedUrlParams): Promise<string>\n  getObjectBuffer(key: string): Promise<Buffer>\n  extractStorageKey(input: string | null | undefined): string | null\n  toFetchableUrl(inputUrl: string): string\n  generateUniqueKey(params: { prefix: string; ext: string }): string\n}\n\nexport interface StorageFactoryOptions {\n  storageType?: string\n}\n"
  },
  {
    "path": "src/lib/storage/utils.ts",
    "content": "import { StorageConfigError } from './errors'\nimport { getInternalBaseUrl } from '@/lib/env'\n\nexport const DEFAULT_SIGNED_URL_EXPIRES_SECONDS = 24 * 60 * 60\n\nexport function resolveBaseUrl(): string {\n  return getInternalBaseUrl()\n}\n\nexport function toFetchableUrl(inputUrl: string): string {\n  if (inputUrl.startsWith('http://') || inputUrl.startsWith('https://') || inputUrl.startsWith('data:')) {\n    return inputUrl\n  }\n  if (inputUrl.startsWith('/')) {\n    return `${resolveBaseUrl()}${inputUrl}`\n  }\n  return inputUrl\n}\n\nexport function requireEnv(name: string): string {\n  const value = process.env[name]\n  if (!value || !value.trim()) {\n    throw new StorageConfigError(`Missing required environment variable: ${name}`)\n  }\n  return value.trim()\n}\n\nexport function isHttpUrl(value: string): boolean {\n  return value.startsWith('http://') || value.startsWith('https://')\n}\n\nexport function normalizeKey(raw: string): string {\n  return raw.replace(/^\\/+/, '')\n}\n\nexport async function withRetry<T>(\n  action: () => Promise<T>,\n  maxRetries: number,\n  delayBaseMs: number,\n): Promise<T> {\n  let lastError: unknown = null\n\n  for (let attempt = 1; attempt <= maxRetries; attempt += 1) {\n    try {\n      return await action()\n    } catch (error: unknown) {\n      lastError = error\n      if (attempt === maxRetries) break\n      const delayMs = delayBaseMs * Math.pow(2, attempt - 1)\n      await new Promise((resolve) => setTimeout(resolve, delayMs))\n    }\n  }\n\n  throw lastError ?? new Error('Unknown retry failure')\n}\n\nexport async function streamToBuffer(body: unknown): Promise<Buffer> {\n  if (!body) {\n    throw new Error('Empty response body from storage provider')\n  }\n  if (body instanceof Uint8Array) {\n    return Buffer.from(body)\n  }\n  if (typeof body === 'string') {\n    return Buffer.from(body)\n  }\n\n  const chunks: Buffer[] = []\n  for await (const chunk of body as AsyncIterable<unknown>) {\n    if (Buffer.isBuffer(chunk)) {\n      chunks.push(chunk)\n      continue\n    }\n    if (chunk instanceof Uint8Array) {\n      chunks.push(Buffer.from(chunk))\n      continue\n    }\n    chunks.push(Buffer.from(String(chunk)))\n  }\n\n  return Buffer.concat(chunks)\n}\n"
  },
  {
    "path": "src/lib/storyboard-phases.ts",
    "content": " import { logInfo as _ulogInfo, logWarn as _ulogWarn, logError as _ulogError } from '@/lib/logging/core'\n/**\n * 分镜生成多阶段处理器\n * 将分镜生成拆分为3个独立阶段，每阶段控制在Vercel时间限制内\n * \n * 每个阶段失败后重试一次\n */\n\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { logAIAnalysis } from '@/lib/logging/semantic'\nimport { buildCharactersIntroduction } from '@/lib/constants'\nimport type { Locale } from '@/i18n/routing'\nimport { getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'\n\n// 阶段类型\nexport type StoryboardPhase = 1 | '2-cinematography' | '2-acting' | 3\n\ntype JsonRecord = Record<string, unknown>\n\nexport type ClipCharacterRef = string | { name?: string | null }\n\ntype CharacterAppearance = {\n    changeReason?: string | null\n    descriptions?: string | null\n    selectedIndex?: number | null\n    description?: string | null\n}\n\nexport type CharacterAsset = {\n    name: string\n    appearances?: CharacterAppearance[]\n}\n\nexport type LocationAsset = {\n    name: string\n    images?: Array<{\n        isSelected?: boolean\n        description?: string | null\n    }>\n}\n\ntype ClipAsset = {\n    id?: string\n    start?: string | number | null\n    end?: string | number | null\n    startText?: string | null\n    endText?: string | null\n    characters?: string | null\n    location?: string | null\n    content?: string | null\n    screenplay?: string | null\n}\n\ntype SessionAsset = {\n    user: {\n        id: string\n        name: string\n    }\n}\n\ntype NovelPromotionAssetData = {\n    analysisModel: string\n    characters: CharacterAsset[]\n    locations: LocationAsset[]\n}\n\nexport type StoryboardPanel = JsonRecord & {\n    panel_number?: number\n    description?: string\n    location?: string\n    source_text?: string\n    characters?: unknown\n    srt_range?: unknown[]\n    scene_type?: string\n    shot_type?: string\n    camera_move?: string\n    video_prompt?: string\n    duration?: number\n    photographyPlan?: JsonRecord\n    actingNotes?: unknown\n}\n\nexport type PhotographyRule = JsonRecord & {\n    panel_number?: number\n    composition?: string\n    lighting?: string\n    color_palette?: string\n    atmosphere?: string\n    technical_notes?: string\n}\n\nexport type ActingDirection = JsonRecord & {\n    panel_number?: number\n    characters?: unknown\n}\n\nfunction isJsonRecord(value: unknown): value is JsonRecord {\n    return typeof value === 'object' && value !== null\n}\n\nfunction parseClipCharacters(raw: string | null | undefined): ClipCharacterRef[] {\n    if (!raw) return []\n    try {\n        const parsed = JSON.parse(raw)\n        return Array.isArray(parsed) ? (parsed as ClipCharacterRef[]) : []\n    } catch {\n        return []\n    }\n}\n\nfunction parseScreenplay(raw: string | null | undefined): unknown {\n    if (!raw) return null\n    try {\n        return JSON.parse(raw)\n    } catch {\n        return null\n    }\n}\n\nfunction parseDescriptions(raw: string | null | undefined): string[] {\n    if (!raw) return []\n    try {\n        const parsed = JSON.parse(raw)\n        if (!Array.isArray(parsed)) return []\n        return parsed.filter((item): item is string => typeof item === 'string')\n    } catch {\n        return []\n    }\n}\n\n// 阶段进度映射\nexport const PHASE_PROGRESS: Record<string, { start: number, end: number, label: string, labelKey: string }> = {\n    '1': { start: 10, end: 40, label: '规划分镜', labelKey: 'phases.planning' },\n    '2-cinematography': { start: 40, end: 55, label: '设计摄影', labelKey: 'phases.cinematography' },\n    '2-acting': { start: 55, end: 70, label: '设计演技', labelKey: 'phases.acting' },\n    '3': { start: 70, end: 100, label: '补充细节', labelKey: 'phases.detail' }\n}\n\n// 中间结果存储接口\nexport interface PhaseResult {\n    clipId: string\n    planPanels?: StoryboardPanel[]\n    photographyRules?: PhotographyRule[]\n    actingDirections?: ActingDirection[]  // 演技指导数据\n    finalPanels?: StoryboardPanel[]\n}\n\n// ========== 辅助函数 ==========\n\n// 🔥 辅助函数：从 clipCharacters 提取角色名（支持混合格式）\nfunction extractCharacterNames(clipCharacters: ClipCharacterRef[]): string[] {\n    return clipCharacters.map(item => {\n        if (typeof item === 'string') return item\n        if (typeof item === 'object' && typeof item.name === 'string') return item.name\n        return ''\n    }).filter(Boolean)\n}\n\n/**\n * 按别名匹配检查角色名是否匹配引用名\n * 优先级：1. 精确全名  2. 按 '/' 拆分后别名精确匹配\n */\nfunction characterNameMatches(characterName: string, referenceName: string): boolean {\n    const charLower = characterName.toLowerCase().trim()\n    const refLower = referenceName.toLowerCase().trim()\n    if (charLower === refLower) return true\n    const charAliases = charLower.split('/').map(s => s.trim()).filter(Boolean)\n    const refAliases = refLower.split('/').map(s => s.trim()).filter(Boolean)\n    return refAliases.some(refAlias => charAliases.includes(refAlias))\n}\n\n// 根据 clip.characters 筛选角色形象列表\nexport function getFilteredAppearanceList(characters: CharacterAsset[], clipCharacters: ClipCharacterRef[]): string {\n    if (clipCharacters.length === 0) return '无'\n    const charNames = extractCharacterNames(clipCharacters)\n    return characters\n        .filter((c) => charNames.some(name => characterNameMatches(c.name, name)))\n        .map((c) => {\n            const appearances = c.appearances || []\n            if (appearances.length === 0) return `${c.name}: [\"初始形象\"]`\n            const appearanceNames = appearances.map((app) => app.changeReason || '初始形象')\n            return `${c.name}: [${appearanceNames.map((n: string) => `\"${n}\"`).join(', ')}]`\n        }).join('\\n') || '无'\n}\n\n// 根据 clip.characters 筛选角色完整描述\nexport function getFilteredFullDescription(characters: CharacterAsset[], clipCharacters: ClipCharacterRef[]): string {\n    if (clipCharacters.length === 0) return '无'\n    const charNames = extractCharacterNames(clipCharacters)\n    return characters\n        .filter((c) => charNames.some(name => characterNameMatches(c.name, name)))\n        .map((c) => {\n            const appearances = c.appearances || []\n            if (appearances.length === 0) return `【${c.name}】无形象描述`\n\n            return appearances.map((app) => {\n                const appearanceName = app.changeReason || '初始形象'\n                const descriptions = parseDescriptions(app.descriptions)\n                const selectedIndex = typeof app.selectedIndex === 'number' ? app.selectedIndex : 0\n                const finalDesc = descriptions[selectedIndex] || app.description || '无描述'\n                return `【${c.name} - ${appearanceName}】${finalDesc}`\n            }).join('\\n')\n        }).join('\\n') || '无'\n}\n\n// 根据 clip.location 筛选场景描述\nexport function getFilteredLocationsDescription(locations: LocationAsset[], clipLocation: string | null): string {\n    if (!clipLocation) return '无'\n    const location = locations.find((l) => l.name.toLowerCase() === clipLocation.toLowerCase())\n    if (!location) return '无'\n    const selectedImage = location.images?.find((img) => img.isSelected) || location.images?.[0]\n    return selectedImage?.description || '无描述'\n}\n\n// 格式化Clip标识（支持SRT模式和Agent模式）\nexport function formatClipId(clip: ClipAsset): string {\n    // SRT 模式\n    if (clip.start !== undefined && clip.start !== null) {\n        return `${clip.start}-${clip.end}`\n    }\n    // Agent 模式\n    if (clip.startText && clip.endText) {\n        return `${clip.startText.substring(0, 10)}...~...${clip.endText.substring(0, 10)}`\n    }\n    // 回退\n    return clip.id?.substring(0, 8) || 'unknown'\n}\n\n// 解析JSON响应\nfunction parseJsonResponse<T extends JsonRecord>(responseText: string, clipId: string, phase: number): T[] {\n    let jsonText = responseText.trim()\n    jsonText = jsonText.replace(/^```json\\s*/i, '').replace(/^```\\s*/, '').replace(/\\s*```$/, '')\n\n    const firstBracket = jsonText.indexOf('[')\n    const lastBracket = jsonText.lastIndexOf(']')\n\n    if (firstBracket === -1 || lastBracket === -1 || lastBracket <= firstBracket) {\n        throw new Error(`Phase ${phase}: JSON格式错误 clip ${clipId}`)\n    }\n\n    jsonText = jsonText.substring(firstBracket, lastBracket + 1)\n    const result = JSON.parse(jsonText)\n\n    if (!Array.isArray(result) || result.length === 0) {\n        throw new Error(`Phase ${phase}: 返回空数据 clip ${clipId}`)\n    }\n\n    const normalized = result.filter(isJsonRecord) as T[]\n    if (normalized.length === 0) {\n        throw new Error(`Phase ${phase}: 数据结构错误 clip ${clipId}`)\n    }\n\n    return normalized\n}\n\n// ========== Phase 1: 基础分镜规划 ==========\nexport async function executePhase1(\n    clip: ClipAsset,\n    novelPromotionData: NovelPromotionAssetData,\n    session: SessionAsset,\n    projectId: string,\n    projectName: string,\n    locale: Locale,\n    taskId?: string\n): Promise<PhaseResult> {\n    const clipId = formatClipId(clip)\n    void taskId\n    _ulogInfo(`[Phase 1] Clip ${clipId}: 开始基础分镜规划...`)\n\n    // 读取提示词模板\n    const planPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_STORYBOARD_PLAN, locale)\n\n    // 解析clip数据\n    const clipCharacters = parseClipCharacters(clip.characters)\n    const clipLocation = clip.location || null\n\n    // 构建资产信息\n    const charactersLibName = novelPromotionData.characters.map((c) => c.name).join(', ') || '无'\n    const locationsLibName = novelPromotionData.locations.map((l) => l.name).join(', ') || '无'\n    const filteredAppearanceList = getFilteredAppearanceList(novelPromotionData.characters, clipCharacters)\n    const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)\n    const charactersIntroduction = buildCharactersIntroduction(novelPromotionData.characters)\n\n    // 构建clip JSON\n    const clipJson = JSON.stringify({\n        id: clip.id,\n        content: clip.content,\n        characters: clipCharacters,\n        location: clipLocation\n    }, null, 2)\n\n    // 读取剧本\n    const screenplay = parseScreenplay(clip.screenplay)\n    if (clip.screenplay && !screenplay) {\n        _ulogWarn(`[Phase 1] Clip ${clipId}: 剧本JSON解析失败`)\n    }\n\n    // 构建提示词\n    let planPrompt = planPromptTemplate\n        .replace('{characters_lib_name}', charactersLibName)\n        .replace('{locations_lib_name}', locationsLibName)\n        .replace('{characters_introduction}', charactersIntroduction)\n        .replace('{characters_appearance_list}', filteredAppearanceList)\n        .replace('{characters_full_description}', filteredFullDescription)\n        .replace('{clip_json}', clipJson)\n\n    if (screenplay) {\n        planPrompt = planPrompt.replace('{clip_content}', `【剧本格式】\\n${JSON.stringify(screenplay, null, 2)}`)\n    } else {\n        planPrompt = planPrompt.replace('{clip_content}', clip.content || '')\n    }\n\n    // 记录发送给 AI 的完整 prompt\n    logAIAnalysis(session.user.id, session.user.name, projectId, projectName, {\n        action: 'STORYBOARD_PHASE1_PROMPT',\n        input: { 片段标识: clipId, 完整提示词: planPrompt },\n        model: novelPromotionData.analysisModel\n    })\n\n    // 调用AI（失败后重试一次）\n    let planPanels: StoryboardPanel[] = []\n\n    for (let attempt = 1; attempt <= 2; attempt++) {\n        try {\n            const planResult = await executeAiTextStep({\n                userId: session.user.id,\n                model: novelPromotionData.analysisModel,\n                messages: [{ role: 'user', content: planPrompt }],\n                reasoning: true,\n                projectId,\n                action: 'storyboard_phase1_plan',\n                meta: {\n                    stepId: 'storyboard_phase1_plan',\n                    stepTitle: '分镜规划',\n                    stepIndex: 1,\n                    stepTotal: 1,\n                },\n            })\n\n            const planResponseText = planResult.text\n            if (!planResponseText) {\n                throw new Error(`Phase 1: 无响应 clip ${clipId}`)\n            }\n\n            planPanels = parseJsonResponse<StoryboardPanel>(planResponseText, clipId, 1)\n\n            // 统计有效分镜数量\n            const validPanelCount = planPanels.filter(panel =>\n                panel.description && panel.description !== '无' && panel.location !== '无'\n            ).length\n\n            _ulogInfo(`[Phase 1] Clip ${clipId}: 共 ${planPanels.length} 个分镜，其中 ${validPanelCount} 个有效分镜`)\n\n            if (validPanelCount === 0) {\n                throw new Error(`Phase 1: 返回全部为空分镜 clip ${clipId}`)\n            }\n\n            // ========== 检测 source_text 字段，缺失则重试 ==========\n            const missingSourceText = planPanels.some(panel => !panel.source_text)\n            if (missingSourceText && attempt === 1) {\n                _ulogWarn(`[Phase 1] Clip ${clipId}: 有分镜缺少source_text，尝试重试...`)\n                continue\n            }\n\n            // 成功，跳出循环\n            break\n        } catch (error: unknown) {\n            const message = error instanceof Error ? error.message : String(error)\n            _ulogError(`[Phase 1] Clip ${clipId}: 第${attempt}次尝试失败: ${message}`)\n            if (attempt === 2) throw error\n        }\n    }\n\n    // 记录第一阶段完整输出\n    logAIAnalysis(session.user.id, session.user.name, projectId, projectName, {\n        action: 'STORYBOARD_PHASE1_OUTPUT',\n        output: {\n            片段标识: clipId,\n            总分镜数: planPanels.length,\n            第一阶段完整结果: planPanels\n        },\n        model: novelPromotionData.analysisModel\n    })\n\n    _ulogInfo(`[Phase 1] Clip ${clipId}: 生成 ${planPanels.length} 个基础分镜`)\n\n    return { clipId, planPanels }\n}\n\n// ========== Phase 2: 摄影规则生成 ==========\nexport async function executePhase2(\n    clip: ClipAsset,\n    planPanels: StoryboardPanel[],\n    novelPromotionData: NovelPromotionAssetData,\n    session: SessionAsset,\n    projectId: string,\n    projectName: string,\n    locale: Locale,\n    taskId?: string\n): Promise<PhaseResult> {\n    const clipId = formatClipId(clip)\n    void taskId\n    _ulogInfo(`[Phase 2] Clip ${clipId}: 开始生成摄影规则...`)\n\n    // 读取提示词\n    const cinematographerPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_CINEMATOGRAPHER, locale)\n\n    // 解析clip数据\n    const clipCharacters = parseClipCharacters(clip.characters)\n    const clipLocation = clip.location || null\n\n    const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)\n    const filteredLocationsDescription = getFilteredLocationsDescription(novelPromotionData.locations, clipLocation)\n\n    // 构建提示词\n    const cinematographerPrompt = cinematographerPromptTemplate\n        .replace('{panels_json}', JSON.stringify(planPanels, null, 2))\n        .replace('{panel_count}', planPanels.length.toString())\n        .replace(/\\{panel_count\\}/g, planPanels.length.toString())\n        .replace('{locations_description}', filteredLocationsDescription)\n        .replace('{characters_info}', filteredFullDescription)\n\n    let photographyRules: PhotographyRule[] = []\n\n    // 失败后重试一次\n    for (let attempt = 1; attempt <= 2; attempt++) {\n        try {\n            const cinematographerResult = await executeAiTextStep({\n                userId: session.user.id,\n                model: novelPromotionData.analysisModel,\n                messages: [{ role: 'user', content: cinematographerPrompt }],\n                reasoning: true,\n                projectId,\n                action: 'storyboard_phase2_cinematography',\n                meta: {\n                    stepId: 'storyboard_phase2_cinematography',\n                    stepTitle: '摄影规则',\n                    stepIndex: 1,\n                    stepTotal: 1,\n                },\n            })\n\n            const responseText = cinematographerResult.text\n            if (!responseText) {\n                throw new Error(`Phase 2: 无响应 clip ${clipId}`)\n            }\n\n            photographyRules = parseJsonResponse<PhotographyRule>(responseText, clipId, 2)\n\n            _ulogInfo(`[Phase 2] Clip ${clipId}: 成功生成 ${photographyRules.length} 个镜头的摄影规则`)\n\n            // 记录摄影方案生成结果\n            logAIAnalysis(session.user.id, session.user.name, projectId, projectName, {\n                action: 'CINEMATOGRAPHER_PLAN',\n                output: {\n                    片段标识: clipId,\n                    镜头数量: planPanels.length,\n                    摄影规则数量: photographyRules.length,\n                    摄影规则: photographyRules\n                },\n                model: novelPromotionData.analysisModel\n            })\n\n            // 成功，跳出循环\n            break\n        } catch (e: unknown) {\n            const message = e instanceof Error ? e.message : String(e)\n            _ulogError(`[Phase 2] Clip ${clipId}: 第${attempt}次尝试失败: ${message}`)\n            if (attempt === 2) throw e\n        }\n    }\n\n    return { clipId, planPanels, photographyRules }\n}\n\n// ========== Phase 2-Acting: 演技指导生成 ==========\nexport async function executePhase2Acting(\n    clip: ClipAsset,\n    planPanels: StoryboardPanel[],\n    novelPromotionData: NovelPromotionAssetData,\n    session: SessionAsset,\n    projectId: string,\n    projectName: string,\n    locale: Locale,\n    taskId?: string\n): Promise<PhaseResult> {\n    const clipId = formatClipId(clip)\n    void taskId\n    _ulogInfo(`[Phase 2-Acting] ==========================================`)\n    _ulogInfo(`[Phase 2-Acting] Clip ${clipId}: 开始生成演技指导...`)\n    _ulogInfo(`[Phase 2-Acting] planPanels 数量: ${planPanels.length}`)\n    _ulogInfo(`[Phase 2-Acting] projectId: ${projectId}, projectName: ${projectName}`)\n\n    // 读取提示词\n    const actingPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_ACTING_DIRECTION, locale)\n\n    // 解析clip数据\n    const clipCharacters = parseClipCharacters(clip.characters)\n\n    const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)\n\n    // 构建提示词\n    const actingPrompt = actingPromptTemplate\n        .replace('{panels_json}', JSON.stringify(planPanels, null, 2))\n        .replace('{panel_count}', planPanels.length.toString())\n        .replace(/\\{panel_count\\}/g, planPanels.length.toString())\n        .replace('{characters_info}', filteredFullDescription)\n\n    let actingDirections: ActingDirection[] = []\n\n    // 失败后重试一次\n    for (let attempt = 1; attempt <= 2; attempt++) {\n        try {\n            const actingResult = await executeAiTextStep({\n                userId: session.user.id,\n                model: novelPromotionData.analysisModel,\n                messages: [{ role: 'user', content: actingPrompt }],\n                reasoning: true,\n                projectId,\n                action: 'storyboard_phase2_acting',\n                meta: {\n                    stepId: 'storyboard_phase2_acting',\n                    stepTitle: '演技指导',\n                    stepIndex: 1,\n                    stepTotal: 1,\n                },\n            })\n\n            const responseText = actingResult.text\n            if (!responseText) {\n                throw new Error(`Phase 2-Acting: 无响应 clip ${clipId}`)\n            }\n\n            actingDirections = parseJsonResponse<ActingDirection>(responseText, clipId, 2)\n\n            _ulogInfo(`[Phase 2-Acting] Clip ${clipId}: 成功生成 ${actingDirections.length} 个镜头的演技指导`)\n\n            // 记录演技指导生成结果\n            logAIAnalysis(session.user.id, session.user.name, projectId, projectName, {\n                action: 'ACTING_DIRECTION_PLAN',\n                output: {\n                    片段标识: clipId,\n                    镜头数量: planPanels.length,\n                    演技指导数量: actingDirections.length,\n                    演技指导: actingDirections\n                },\n                model: novelPromotionData.analysisModel\n            })\n\n            // 成功，跳出循环\n            break\n        } catch (e: unknown) {\n            const message = e instanceof Error ? e.message : String(e)\n            _ulogError(`[Phase 2-Acting] Clip ${clipId}: 第${attempt}次尝试失败: ${message}`)\n            if (attempt === 2) throw e\n        }\n    }\n\n    return { clipId, planPanels, actingDirections }\n}\n\n// ========== Phase 3: 补充细节和video_prompt ==========\nexport async function executePhase3(\n    clip: ClipAsset,\n    planPanels: StoryboardPanel[],\n    photographyRules: PhotographyRule[],\n    novelPromotionData: NovelPromotionAssetData,\n    session: SessionAsset,\n    projectId: string,\n    projectName: string,\n    locale: Locale,\n    taskId?: string\n): Promise<PhaseResult> {\n    const clipId = formatClipId(clip)\n    void taskId\n    _ulogInfo(`[Phase 3] Clip ${clipId}: 开始补充镜头细节...`)\n\n    // 读取提示词\n    const detailPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_STORYBOARD_DETAIL, locale)\n\n    // 解析clip数据\n    const clipCharacters = parseClipCharacters(clip.characters)\n    const clipLocation = clip.location || null\n\n    const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)\n    const filteredLocationsDescription = getFilteredLocationsDescription(novelPromotionData.locations, clipLocation)\n\n    // 构建提示词\n    const detailPrompt = detailPromptTemplate\n        .replace('{panels_json}', JSON.stringify(planPanels, null, 2))\n        .replace('{characters_age_gender}', filteredFullDescription)  // 改用完整描述\n        .replace('{locations_description}', filteredLocationsDescription)\n\n    // 记录发送给 AI 的完整 prompt\n    logAIAnalysis(session.user.id, session.user.name, projectId, projectName, {\n        action: 'STORYBOARD_PHASE3_PROMPT',\n        input: { 片段标识: clipId, 完整提示词: detailPrompt },\n        model: novelPromotionData.analysisModel\n    })\n\n    void photographyRules\n    let finalPanels: StoryboardPanel[] = []\n\n    // 失败后重试一次\n    for (let attempt = 1; attempt <= 2; attempt++) {\n        try {\n            const detailResult = await executeAiTextStep({\n                userId: session.user.id,\n                model: novelPromotionData.analysisModel,\n                messages: [{ role: 'user', content: detailPrompt }],\n                reasoning: true,\n                projectId,\n                action: 'storyboard_phase3_detail',\n                meta: {\n                    stepId: 'storyboard_phase3_detail',\n                    stepTitle: '镜头细化',\n                    stepIndex: 1,\n                    stepTotal: 1,\n                },\n            })\n\n            const detailResponseText = detailResult.text\n            if (!detailResponseText) {\n                throw new Error(`Phase 3: 无响应 clip ${clipId}`)\n            }\n\n            finalPanels = parseJsonResponse<StoryboardPanel>(detailResponseText, clipId, 3)\n\n            // 记录第三阶段完整输出（过滤前）\n            logAIAnalysis(session.user.id, session.user.name, projectId, projectName, {\n                action: 'STORYBOARD_PHASE3_OUTPUT',\n                output: {\n                    片段标识: clipId,\n                    总分镜数: finalPanels.length,\n                    第三阶段完整结果_过滤前: finalPanels\n                },\n                model: novelPromotionData.analysisModel\n            })\n\n            // 过滤掉\"无\"的空分镜\n            const beforeFilterCount = finalPanels.length\n            finalPanels = finalPanels.filter((panel) =>\n                panel.description && panel.description !== '无' && panel.location !== '无'\n            )\n            _ulogInfo(`[Phase 3] Clip ${clipId}: 过滤空分镜 ${beforeFilterCount} -> ${finalPanels.length} 个有效分镜`)\n\n            if (finalPanels.length === 0) {\n                throw new Error(`Phase 3: 过滤后无有效分镜 clip ${clipId}`)\n            }\n\n            // 注意：photographyRules的合并已移至route.ts中，与并行执行的Phase 2结果合并\n\n            // 记录最终输出\n            logAIAnalysis(session.user.id, session.user.name, projectId, projectName, {\n                action: 'STORYBOARD_FINAL_OUTPUT',\n                output: {\n                    片段标识: clipId,\n                    过滤前总数: beforeFilterCount,\n                    过滤后有效数: finalPanels.length,\n                    最终有效分镜: finalPanels\n                },\n                model: novelPromotionData.analysisModel\n            })\n\n            // 成功，跳出循环\n            break\n        } catch (e: unknown) {\n            const message = e instanceof Error ? e.message : String(e)\n            _ulogError(`[Phase 3] Clip ${clipId}: 第${attempt}次尝试失败: ${message}`)\n            if (attempt === 2) throw e\n        }\n    }\n\n    _ulogInfo(`[Phase 3] Clip ${clipId}: 完成 ${finalPanels.length} 个镜头细节`)\n\n    return { clipId, finalPanels }\n}\n"
  },
  {
    "path": "src/lib/task/client.ts",
    "content": "import { resolveTaskErrorMessage } from './error-message'\nimport { apiFetch } from '@/lib/api-fetch'\n\ntype TaskStatus = 'queued' | 'processing' | 'completed' | 'failed' | 'canceled'\n\ntype TaskSnapshot = {\n  id: string\n  status: TaskStatus\n  progress?: number | null\n  result?: Record<string, unknown> | null\n  errorMessage?: string | null\n}\n\ntype TaskSnapshotResponse = {\n  success: boolean\n  task?: TaskSnapshot | null\n}\n\ntype WaitTaskOptions = {\n  intervalMs?: number\n  timeoutMs?: number\n  onTaskUpdate?: (task: TaskSnapshot) => void\n}\n\nfunction sleep(ms: number) {\n  return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nexport function isAsyncTaskResponse(data: unknown): data is { async: true; taskId: string } {\n  if (!data || typeof data !== 'object') return false\n  const payload = data as Record<string, unknown>\n  return payload.async === true && typeof payload.taskId === 'string' && payload.taskId.length > 0\n}\n\nexport async function waitForTaskResult(taskId: string, options: WaitTaskOptions = {}) {\n  const intervalMs = options.intervalMs ?? 1500\n  const timeoutMs = options.timeoutMs ?? 0\n  const onTaskUpdate = options.onTaskUpdate\n  const startedAt = Date.now()\n\n  while (true) {\n    if (timeoutMs > 0 && Date.now() - startedAt > timeoutMs) {\n      throw new Error(`Task timeout: ${taskId}`)\n    }\n\n    const response = await apiFetch(`/api/tasks/${taskId}`, {\n      method: 'GET',\n      cache: 'no-store',\n    })\n    if (!response.ok) {\n      const errorPayload = await response.json().catch(() => null)\n      throw new Error(resolveTaskErrorMessage(errorPayload, `Task fetch failed: ${taskId}`))\n    }\n\n    const payload = (await response.json()) as TaskSnapshotResponse\n    const task = payload.task\n    if (!task) {\n      throw new Error(`Task not found: ${taskId}`)\n    }\n\n    onTaskUpdate?.(task)\n\n    if (task.status === 'completed') {\n      return task.result || { success: true }\n    }\n    if (task.status === 'failed' || task.status === 'canceled') {\n      throw new Error(resolveTaskErrorMessage(task, `Task ${task.status}`))\n    }\n    if (task.status !== 'queued' && task.status !== 'processing') {\n      throw new Error(resolveTaskErrorMessage(task, `Task ${task.status}`))\n    }\n\n    await sleep(intervalMs)\n  }\n}\n\nexport async function resolveTaskResponse<T = Record<string, unknown>>(response: Response, options?: WaitTaskOptions) {\n  const data = await response.json().catch(() => null)\n  if (!response.ok) {\n    throw new Error(resolveTaskErrorMessage(data, 'Request failed'))\n  }\n  if (isAsyncTaskResponse(data)) {\n    return await waitForTaskResult(data.taskId, options) as T\n  }\n  return (data || {}) as T\n}\n"
  },
  {
    "path": "src/lib/task/error-message.ts",
    "content": "import { normalizeTaskError } from '@/lib/errors/normalize'\nimport { isKnownErrorCode, type UnifiedErrorCode } from '@/lib/errors/codes'\nimport { getUserMessageByCode } from '@/lib/errors/user-messages'\n\nexport type TaskErrorSummary = {\n  code: string | null\n  message: string\n  cancelled: boolean\n}\n\nfunction asObject(value: unknown): Record<string, unknown> | null {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n  return value as Record<string, unknown>\n}\n\nfunction asString(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  return trimmed.length > 0 ? trimmed : null\n}\n\nfunction asBoolean(value: unknown): boolean {\n  return value === true\n}\n\nfunction looksCancelledMessage(value: string | null): boolean {\n  if (!value) return false\n  const lower = value.toLowerCase()\n  return (\n    lower.includes('task cancelled') ||\n    lower.includes('task canceled') ||\n    lower.includes('cancelled by user') ||\n    lower.includes('canceled by user') ||\n    lower.includes('任务已取消')\n  )\n}\n\nexport function resolveTaskErrorSummary(payload: unknown, fallbackMessage = 'Task failed'): TaskErrorSummary {\n  const source = asObject(payload) || {}\n  const sourceError = asObject(source.error) || {}\n  const sourceErrorDetails = asObject(sourceError.details)\n  const sourceDetails = asObject(source.details)\n\n  const code =\n    asString(sourceError.code) ||\n    asString(source.errorCode) ||\n    asString(source.code)\n\n  const message =\n    asString(sourceError.message) ||\n    asString(sourceErrorDetails?.message) ||\n    asString(source.error) ||\n    asString(sourceDetails?.message) ||\n    asString(source.details) ||\n    asString(source.errorMessage) ||\n    asString(source.message)\n\n  const normalized = normalizeTaskError(code, message, sourceErrorDetails)\n  const normalizedDetails = asObject(normalized?.details)\n  const stage = asString(source.stage)\n  const normalizedMessage = asString(normalized?.message)\n\n  const cancelled =\n    asBoolean(source.cancelled) ||\n    asBoolean(source.canceled) ||\n    asBoolean(sourceError.cancelled) ||\n    asBoolean(sourceError.canceled) ||\n    asBoolean(sourceErrorDetails?.cancelled) ||\n    asBoolean(sourceErrorDetails?.canceled) ||\n    asBoolean(normalizedDetails?.cancelled) ||\n    asBoolean(normalizedDetails?.canceled) ||\n    stage === 'cancelled' ||\n    code === 'TASK_CANCELLED' ||\n    asString(normalizedDetails?.originalCode) === 'TASK_CANCELLED' ||\n    looksCancelledMessage(normalizedMessage) ||\n    looksCancelledMessage(message)\n\n  if (cancelled) {\n    return {\n      code: normalized?.code || 'CONFLICT',\n      message: 'Task cancelled by user',\n      cancelled: true,\n    }\n  }\n\n  const userFriendlyMessage =\n    normalized?.code && isKnownErrorCode(normalized.code)\n      ? getUserMessageByCode(normalized.code as UnifiedErrorCode)\n      : null\n\n  const shouldPreferUserFriendlyMessage =\n    normalized?.code === 'MODEL_NOT_OPEN'\n    || normalized?.code === 'EMPTY_RESPONSE'\n\n  return {\n    code: normalized?.code || code || null,\n    message: shouldPreferUserFriendlyMessage\n      ? (userFriendlyMessage || message || normalizedMessage || fallbackMessage)\n      : (message || userFriendlyMessage || normalizedMessage || fallbackMessage),\n    cancelled: false,\n  }\n}\n\nexport function resolveTaskErrorMessage(payload: unknown, fallbackMessage = 'Task failed') {\n  return resolveTaskErrorSummary(payload, fallbackMessage).message\n}\n"
  },
  {
    "path": "src/lib/task/errors.ts",
    "content": "export class TaskTerminatedError extends Error {\n  taskId: string\n\n  constructor(taskId: string, message = 'Task terminated') {\n    super(message)\n    this.name = 'TaskTerminatedError'\n    this.taskId = taskId\n  }\n}\n"
  },
  {
    "path": "src/lib/task/has-output.ts",
    "content": "import { prisma } from '@/lib/prisma'\n\nfunction isNonEmptyString(value: unknown): value is string {\n  return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction parseJsonStringArray(raw: string | null | undefined): string[] {\n  if (!raw || typeof raw !== 'string') return []\n  try {\n    const parsed = JSON.parse(raw)\n    if (!Array.isArray(parsed)) return []\n    return parsed.filter((item): item is string => isNonEmptyString(item))\n  } catch {\n    return []\n  }\n}\n\nfunction hasUrlList(raw: string | null | undefined) {\n  return parseJsonStringArray(raw).length > 0\n}\n\nfunction parseCompositeTargetId(targetId: string): string[] {\n  return targetId.split(':').map((item) => item.trim()).filter(Boolean)\n}\n\nexport async function hasCharacterAppearanceOutput(params: {\n  appearanceId?: string | null\n  characterId?: string | null\n  appearanceIndex?: number | null\n}) {\n  if (isNonEmptyString(params.appearanceId)) {\n    const row = await prisma.characterAppearance.findUnique({\n      where: { id: params.appearanceId },\n      select: {\n        imageUrl: true,\n        imageUrls: true,\n        imageMediaId: true,\n      },\n    })\n    if (!row) return false\n    return isNonEmptyString(row.imageUrl) || !!row.imageMediaId || hasUrlList(row.imageUrls)\n  }\n\n  if (!isNonEmptyString(params.characterId)) return false\n  const row = await prisma.characterAppearance.findFirst({\n    where: {\n      characterId: params.characterId,\n      ...(typeof params.appearanceIndex === 'number'\n        ? { appearanceIndex: params.appearanceIndex }\n        : {}),\n    },\n    select: {\n      imageUrl: true,\n      imageUrls: true,\n      imageMediaId: true,\n    },\n  })\n  if (!row) return false\n  return isNonEmptyString(row.imageUrl) || !!row.imageMediaId || hasUrlList(row.imageUrls)\n}\n\nexport async function hasLocationImageOutput(params: {\n  imageId?: string | null\n  locationId?: string | null\n  imageIndex?: number | null\n}) {\n  if (isNonEmptyString(params.imageId)) {\n    const row = await prisma.locationImage.findUnique({\n      where: { id: params.imageId },\n      select: {\n        imageUrl: true,\n        imageMediaId: true,\n      },\n    })\n    if (!row) return false\n    return isNonEmptyString(row.imageUrl) || !!row.imageMediaId\n  }\n\n  if (!isNonEmptyString(params.locationId)) return false\n  const row = await prisma.locationImage.findFirst({\n    where: {\n      locationId: params.locationId,\n      ...(typeof params.imageIndex === 'number' ? { imageIndex: params.imageIndex } : {}),\n    },\n    select: {\n      imageUrl: true,\n      imageMediaId: true,\n    },\n  })\n  if (!row) return false\n  return isNonEmptyString(row.imageUrl) || !!row.imageMediaId\n}\n\nexport async function hasPanelImageOutput(panelId: string | null | undefined) {\n  if (!isNonEmptyString(panelId)) return false\n  const panel = await prisma.novelPromotionPanel.findUnique({\n    where: { id: panelId },\n    select: {\n      imageUrl: true,\n      imageMediaId: true,\n    },\n  })\n  if (!panel) return false\n  return isNonEmptyString(panel.imageUrl) || !!panel.imageMediaId\n}\n\nexport async function hasPanelVideoOutput(panelId: string | null | undefined) {\n  if (!isNonEmptyString(panelId)) return false\n  const panel = await prisma.novelPromotionPanel.findUnique({\n    where: { id: panelId },\n    select: {\n      videoUrl: true,\n      videoMediaId: true,\n    },\n  })\n  if (!panel) return false\n  return isNonEmptyString(panel.videoUrl) || !!panel.videoMediaId\n}\n\nexport async function hasPanelLipSyncOutput(panelId: string | null | undefined) {\n  if (!isNonEmptyString(panelId)) return false\n  const panel = await prisma.novelPromotionPanel.findUnique({\n    where: { id: panelId },\n    select: {\n      lipSyncVideoUrl: true,\n      lipSyncVideoMediaId: true,\n    },\n  })\n  if (!panel) return false\n  return isNonEmptyString(panel.lipSyncVideoUrl) || !!panel.lipSyncVideoMediaId\n}\n\nexport async function hasVoiceLineAudioOutput(lineId: string | null | undefined) {\n  if (!isNonEmptyString(lineId)) return false\n  const line = await prisma.novelPromotionVoiceLine.findUnique({\n    where: { id: lineId },\n    select: {\n      audioUrl: true,\n      audioMediaId: true,\n    },\n  })\n  if (!line) return false\n  return isNonEmptyString(line.audioUrl) || !!line.audioMediaId\n}\n\nexport async function hasGlobalCharacterOutput(params: {\n  characterId?: string | null\n  appearanceIndex?: number | null\n}) {\n  if (!isNonEmptyString(params.characterId)) return false\n  const appearance = await prisma.globalCharacterAppearance.findFirst({\n    where: {\n      characterId: params.characterId,\n      ...(typeof params.appearanceIndex === 'number'\n        ? { appearanceIndex: params.appearanceIndex }\n        : {}),\n    },\n    select: {\n      imageUrl: true,\n      imageUrls: true,\n      imageMediaId: true,\n    },\n  })\n  if (!appearance) return false\n  return (\n    isNonEmptyString(appearance.imageUrl) ||\n    !!appearance.imageMediaId ||\n    hasUrlList(appearance.imageUrls)\n  )\n}\n\nexport async function hasGlobalLocationOutput(params: {\n  locationId?: string | null\n  imageIndex?: number | null\n}) {\n  if (!isNonEmptyString(params.locationId)) return false\n  const image = await prisma.globalLocationImage.findFirst({\n    where: {\n      locationId: params.locationId,\n      ...(typeof params.imageIndex === 'number' ? { imageIndex: params.imageIndex } : {}),\n    },\n    select: {\n      imageUrl: true,\n      imageMediaId: true,\n    },\n  })\n  if (!image) return false\n  return isNonEmptyString(image.imageUrl) || !!image.imageMediaId\n}\n\nexport async function hasGlobalCharacterAppearanceOutput(params: {\n  targetId?: string | null\n  characterId?: string | null\n  appearanceIndex?: number | null\n  imageIndex?: number | null\n}) {\n  let characterId = params.characterId || null\n  let appearanceIndex = params.appearanceIndex ?? null\n  let imageIndex = params.imageIndex ?? null\n\n  if (isNonEmptyString(params.targetId)) {\n    const parts = parseCompositeTargetId(params.targetId)\n    if (parts[0]) characterId = parts[0]\n    if (parts[1] && Number.isFinite(Number(parts[1]))) appearanceIndex = Number(parts[1])\n    if (parts[2] && Number.isFinite(Number(parts[2]))) imageIndex = Number(parts[2])\n  }\n\n  if (!isNonEmptyString(characterId) || typeof appearanceIndex !== 'number') return false\n  const appearance = await prisma.globalCharacterAppearance.findFirst({\n    where: {\n      characterId,\n      appearanceIndex,\n    },\n    select: {\n      imageUrl: true,\n      imageUrls: true,\n      imageMediaId: true,\n      selectedIndex: true,\n    },\n  })\n  if (!appearance) return false\n  if (isNonEmptyString(appearance.imageUrl) || !!appearance.imageMediaId) return true\n\n  const imageUrls = parseJsonStringArray(appearance.imageUrls)\n  if (imageUrls.length === 0) return false\n  if (typeof imageIndex === 'number') {\n    return isNonEmptyString(imageUrls[imageIndex] || null)\n  }\n  if (typeof appearance.selectedIndex === 'number') {\n    return isNonEmptyString(imageUrls[appearance.selectedIndex] || null)\n  }\n  return imageUrls.some((url) => isNonEmptyString(url))\n}\n\nexport async function hasGlobalLocationImageOutput(params: {\n  targetId?: string | null\n  locationId?: string | null\n  imageIndex?: number | null\n}) {\n  let locationId = params.locationId || null\n  let imageIndex = params.imageIndex ?? null\n  if (isNonEmptyString(params.targetId)) {\n    const parts = parseCompositeTargetId(params.targetId)\n    if (parts[0]) locationId = parts[0]\n    if (parts[1] && Number.isFinite(Number(parts[1]))) imageIndex = Number(parts[1])\n  }\n\n  if (!isNonEmptyString(locationId) || typeof imageIndex !== 'number') return false\n  const image = await prisma.globalLocationImage.findFirst({\n    where: {\n      locationId,\n      imageIndex,\n    },\n    select: {\n      imageUrl: true,\n      imageMediaId: true,\n    },\n  })\n  if (!image) return false\n  return isNonEmptyString(image.imageUrl) || !!image.imageMediaId\n}\n"
  },
  {
    "path": "src/lib/task/intent.ts",
    "content": "import { TASK_TYPE, type TaskType } from './types'\n\nexport type TaskIntent =\n  | 'generate'\n  | 'regenerate'\n  | 'modify'\n  | 'analyze'\n  | 'build'\n  | 'convert'\n  | 'process'\n\nexport const TASK_INTENTS: TaskIntent[] = [\n  'generate',\n  'regenerate',\n  'modify',\n  'analyze',\n  'build',\n  'convert',\n  'process',\n]\n\nconst TASK_INTENT_SET = new Set<string>(TASK_INTENTS)\n\nconst TASK_INTENT_BY_TYPE: Record<TaskType, TaskIntent> = {\n  [TASK_TYPE.IMAGE_PANEL]: 'generate',\n  [TASK_TYPE.IMAGE_CHARACTER]: 'generate',\n  [TASK_TYPE.IMAGE_LOCATION]: 'generate',\n  [TASK_TYPE.VIDEO_PANEL]: 'generate',\n  [TASK_TYPE.LIP_SYNC]: 'process',\n  [TASK_TYPE.VOICE_LINE]: 'generate',\n  [TASK_TYPE.VOICE_DESIGN]: 'generate',\n  [TASK_TYPE.ASSET_HUB_VOICE_DESIGN]: 'generate',\n  [TASK_TYPE.REGENERATE_STORYBOARD_TEXT]: 'regenerate',\n  [TASK_TYPE.INSERT_PANEL]: 'build',\n  [TASK_TYPE.PANEL_VARIANT]: 'regenerate',\n  [TASK_TYPE.MODIFY_ASSET_IMAGE]: 'modify',\n  [TASK_TYPE.REGENERATE_GROUP]: 'regenerate',\n  [TASK_TYPE.ASSET_HUB_IMAGE]: 'generate',\n  [TASK_TYPE.ASSET_HUB_MODIFY]: 'modify',\n  [TASK_TYPE.ANALYZE_NOVEL]: 'analyze',\n  [TASK_TYPE.STORY_TO_SCRIPT_RUN]: 'build',\n  [TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN]: 'build',\n  [TASK_TYPE.CLIPS_BUILD]: 'build',\n  [TASK_TYPE.SCREENPLAY_CONVERT]: 'convert',\n  [TASK_TYPE.VOICE_ANALYZE]: 'analyze',\n  [TASK_TYPE.ANALYZE_GLOBAL]: 'analyze',\n  [TASK_TYPE.AI_MODIFY_APPEARANCE]: 'modify',\n  [TASK_TYPE.AI_MODIFY_LOCATION]: 'modify',\n  [TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'modify',\n  [TASK_TYPE.ANALYZE_SHOT_VARIANTS]: 'analyze',\n  [TASK_TYPE.AI_CREATE_CHARACTER]: 'generate',\n  [TASK_TYPE.AI_CREATE_LOCATION]: 'generate',\n  [TASK_TYPE.REFERENCE_TO_CHARACTER]: 'process',\n  [TASK_TYPE.CHARACTER_PROFILE_CONFIRM]: 'build',\n  [TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM]: 'build',\n  [TASK_TYPE.EPISODE_SPLIT_LLM]: 'build',\n  [TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER]: 'generate',\n  [TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: 'generate',\n  [TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: 'modify',\n  [TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: 'modify',\n  [TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: 'process',\n}\n\nexport function resolveTaskIntent(taskType: string | null | undefined): TaskIntent {\n  if (!taskType) return 'process'\n  if (taskType in TASK_INTENT_BY_TYPE) {\n    return TASK_INTENT_BY_TYPE[taskType as TaskType]\n  }\n  return 'process'\n}\n\nexport function isTaskIntent(value: unknown): value is TaskIntent {\n  return typeof value === 'string' && TASK_INTENT_SET.has(value)\n}\n\nexport function coerceTaskIntent(value: unknown, fallbackTaskType?: string | null): TaskIntent {\n  if (isTaskIntent(value)) return value\n  return resolveTaskIntent(fallbackTaskType)\n}\n"
  },
  {
    "path": "src/lib/task/presentation.ts",
    "content": "import type { TaskIntent } from './intent'\n\nexport type TaskPresentationPhase = 'idle' | 'queued' | 'processing' | 'completed' | 'failed'\nexport type TaskPresentationResource = 'image' | 'video' | 'audio' | 'text'\nexport type TaskPresentationMode = 'none' | 'overlay' | 'placeholder'\n\nexport type TaskPresentationState = {\n  phase: TaskPresentationPhase\n  intent: TaskIntent\n  resource: TaskPresentationResource\n  hasOutput: boolean\n  mode: TaskPresentationMode\n  isRunning: boolean\n  isError: boolean\n  labelKey: string | null\n}\n\nexport function isRunningPhase(phase: string | null | undefined): phase is 'queued' | 'processing' {\n  return phase === 'queued' || phase === 'processing'\n}\n\nexport function resolveTaskPresentationState(input: {\n  phase: TaskPresentationPhase\n  intent: TaskIntent\n  resource: TaskPresentationResource\n  hasOutput: boolean\n}): TaskPresentationState {\n  const isRunning = input.phase === 'queued' || input.phase === 'processing'\n  if (isRunning) {\n    return {\n      phase: input.phase,\n      intent: input.intent,\n      resource: input.resource,\n      hasOutput: input.hasOutput,\n      mode: input.hasOutput ? 'overlay' : 'placeholder',\n      isRunning: true,\n      isError: false,\n      labelKey: `taskStatus.intent.${input.intent}.running.${input.resource}`,\n    }\n  }\n\n  if (input.phase === 'failed') {\n    return {\n      phase: input.phase,\n      intent: input.intent,\n      resource: input.resource,\n      hasOutput: input.hasOutput,\n      mode: input.hasOutput ? 'overlay' : 'placeholder',\n      isRunning: false,\n      isError: true,\n      labelKey: `taskStatus.failed.${input.resource}`,\n    }\n  }\n\n  return {\n    phase: input.phase,\n    intent: input.intent,\n    resource: input.resource,\n    hasOutput: input.hasOutput,\n    mode: 'none',\n    isRunning: false,\n    isError: false,\n    labelKey: null,\n  }\n}\n"
  },
  {
    "path": "src/lib/task/progress-message.ts",
    "content": "import { TASK_EVENT_TYPE, TASK_TYPE } from './types'\n\nconst TASK_TYPE_LABELS: Record<string, string> = {\n  [TASK_TYPE.IMAGE_PANEL]: 'progress.taskType.imagePanel',\n  [TASK_TYPE.IMAGE_CHARACTER]: 'progress.taskType.imageCharacter',\n  [TASK_TYPE.IMAGE_LOCATION]: 'progress.taskType.imageLocation',\n  [TASK_TYPE.VIDEO_PANEL]: 'progress.taskType.videoPanel',\n  [TASK_TYPE.LIP_SYNC]: 'progress.taskType.lipSync',\n  [TASK_TYPE.VOICE_LINE]: 'progress.taskType.voiceLine',\n  [TASK_TYPE.VOICE_DESIGN]: 'progress.taskType.voiceDesign',\n  [TASK_TYPE.ASSET_HUB_VOICE_DESIGN]: 'progress.taskType.assetHubVoiceDesign',\n  [TASK_TYPE.REGENERATE_STORYBOARD_TEXT]: 'progress.taskType.regenerateStoryboardText',\n  [TASK_TYPE.INSERT_PANEL]: 'progress.taskType.insertPanel',\n  [TASK_TYPE.PANEL_VARIANT]: 'progress.taskType.panelVariant',\n  [TASK_TYPE.MODIFY_ASSET_IMAGE]: 'progress.taskType.modifyAssetImage',\n  [TASK_TYPE.REGENERATE_GROUP]: 'progress.taskType.regenerateGroup',\n  [TASK_TYPE.ASSET_HUB_IMAGE]: 'progress.taskType.assetHubImage',\n  [TASK_TYPE.ASSET_HUB_MODIFY]: 'progress.taskType.assetHubModify',\n  [TASK_TYPE.ANALYZE_NOVEL]: 'progress.taskType.analyzeNovel',\n  [TASK_TYPE.STORY_TO_SCRIPT_RUN]: 'progress.taskType.storyToScriptRun',\n  [TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN]: 'progress.taskType.scriptToStoryboardRun',\n  [TASK_TYPE.CLIPS_BUILD]: 'progress.taskType.clipsBuild',\n  [TASK_TYPE.SCREENPLAY_CONVERT]: 'progress.taskType.screenplayConvert',\n  [TASK_TYPE.VOICE_ANALYZE]: 'progress.taskType.voiceAnalyze',\n  [TASK_TYPE.ANALYZE_GLOBAL]: 'progress.taskType.analyzeGlobal',\n  [TASK_TYPE.AI_MODIFY_APPEARANCE]: 'progress.taskType.aiModifyAppearance',\n  [TASK_TYPE.AI_MODIFY_LOCATION]: 'progress.taskType.aiModifyLocation',\n  [TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'progress.taskType.aiModifyShotPrompt',\n  [TASK_TYPE.ANALYZE_SHOT_VARIANTS]: 'progress.taskType.analyzeShotVariants',\n  [TASK_TYPE.AI_CREATE_CHARACTER]: 'progress.taskType.aiCreateCharacter',\n  [TASK_TYPE.AI_CREATE_LOCATION]: 'progress.taskType.aiCreateLocation',\n  [TASK_TYPE.REFERENCE_TO_CHARACTER]: 'progress.taskType.referenceToCharacter',\n  [TASK_TYPE.CHARACTER_PROFILE_CONFIRM]: 'progress.taskType.characterProfileConfirm',\n  [TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM]: 'progress.taskType.characterProfileBatchConfirm',\n  [TASK_TYPE.EPISODE_SPLIT_LLM]: 'progress.taskType.episodeSplitLlm',\n  [TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER]: 'progress.taskType.assetHubAiDesignCharacter',\n  [TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: 'progress.taskType.assetHubAiDesignLocation',\n  [TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: 'progress.taskType.assetHubAiModifyCharacter',\n  [TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: 'progress.taskType.assetHubAiModifyLocation',\n  [TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: 'progress.taskType.assetHubReferenceToCharacter',\n}\n\nconst STAGE_LABELS: Record<string, string> = {\n  received: 'progress.stage.received',\n  generate_character_image: 'progress.stage.generateCharacterImage',\n  generate_location_image: 'progress.stage.generateLocationImage',\n  generate_panel_candidate: 'progress.stage.generatePanelCandidate',\n  generate_panel_video: 'progress.stage.generatePanelVideo',\n  generate_voice_submit: 'progress.stage.generateVoiceSubmit',\n  generate_voice_persist: 'progress.stage.generateVoicePersist',\n  voice_design_submit: 'progress.stage.voiceDesignSubmit',\n  voice_design_done: 'progress.stage.voiceDesignDone',\n  submit_lip_sync: 'progress.stage.submitLipSync',\n  persist_lip_sync: 'progress.stage.persistLipSync',\n  storyboard_clip: 'progress.stage.storyboardClip',\n  regenerate_storyboard_prepare: 'progress.stage.regenerateStoryboardPrepare',\n  regenerate_storyboard_persist: 'progress.stage.regenerateStoryboardPersist',\n  story_to_script_prepare: 'progress.stage.storyToScriptPrepare',\n  story_to_script_step: 'progress.stage.storyToScriptStep',\n  story_to_script_persist: 'progress.stage.storyToScriptPersist',\n  story_to_script_persist_done: 'progress.stage.storyToScriptPersistDone',\n  script_to_storyboard_prepare: 'progress.stage.scriptToStoryboardPrepare',\n  script_to_storyboard_step: 'progress.stage.scriptToStoryboardStep',\n  script_to_storyboard_persist: 'progress.stage.scriptToStoryboardPersist',\n  script_to_storyboard_persist_done: 'progress.stage.scriptToStoryboardPersistDone',\n  insert_panel_generate_text: 'progress.stage.insertPanelGenerateText',\n  insert_panel_persist: 'progress.stage.insertPanelPersist',\n  polling_external: 'progress.stage.pollingExternal',\n  enqueue_failed: 'progress.stage.enqueueFailed',\n  llm_proxy_submit: 'progress.stage.llmProxySubmit',\n  llm_proxy_execute: 'progress.stage.llmProxyExecute',\n  llm_proxy_persist: 'progress.stage.llmProxyPersist',\n}\n\nfunction asString(value: unknown): string | null {\n  return typeof value === 'string' && value.trim() ? value.trim() : null\n}\n\nexport function getTaskTypeLabel(taskType?: string | null) {\n  if (!taskType) return 'progress.taskType.generic'\n  return TASK_TYPE_LABELS[taskType] || 'progress.taskType.generic'\n}\n\nexport function getTaskStageLabel(stage?: string | null) {\n  if (!stage) return null\n  return STAGE_LABELS[stage] || stage\n}\n\nexport function buildTaskProgressMessage(params: {\n  eventType?: string | null\n  taskType?: string | null\n  progress?: number | null\n  payload?: Record<string, unknown> | null\n}) {\n  const payloadMessage = asString(params.payload?.message)\n  if (payloadMessage) return payloadMessage\n\n  const stage = asString(params.payload?.stage)\n  const stageLabel = getTaskStageLabel(stage)\n\n  if (params.eventType === TASK_EVENT_TYPE.CREATED) {\n    return 'progress.runtime.taskCreated'\n  }\n  if (params.eventType === TASK_EVENT_TYPE.PROCESSING) {\n    return stageLabel || 'progress.runtime.taskStarted'\n  }\n  if (params.eventType === TASK_EVENT_TYPE.COMPLETED) {\n    return 'progress.runtime.taskCompleted'\n  }\n  if (params.eventType === TASK_EVENT_TYPE.FAILED) {\n    return 'progress.runtime.taskFailed'\n  }\n\n  return stageLabel || 'progress.runtime.taskProcessing'\n}\n"
  },
  {
    "path": "src/lib/task/publisher.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { redis } from '@/lib/redis'\nimport {\n  TASK_EVENT_TYPE,\n  TASK_SSE_EVENT_TYPE,\n  TASK_TYPE,\n  type TaskEventType,\n  type TaskLifecycleEventType,\n  type SSEEvent,\n} from './types'\nimport { coerceTaskIntent, resolveTaskIntent } from './intent'\nimport { mapTaskSSEEventToRunEvents } from '@/lib/run-runtime/task-bridge'\nimport { publishRunEvent } from '@/lib/run-runtime/publisher'\n\nconst CHANNEL_PREFIX = 'task-events:project:'\nconst STREAM_EPHEMERAL_ENABLED = process.env.LLM_STREAM_EPHEMERAL_ENABLED !== 'false'\nconst TASK_TYPES_WITH_DIRECT_RUN_EVENTS = new Set<string>([\n  TASK_TYPE.STORY_TO_SCRIPT_RUN,\n  TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n])\n\ntype TaskEventRow = {\n  id: number\n  taskId: string\n  projectId: string\n  userId: string\n  eventType: string\n  payload: Record<string, unknown> | null\n  createdAt: Date\n}\n\ntype TaskMeta = {\n  id: string\n  type: string\n  targetType: string\n  targetId: string\n  episodeId: string | null\n}\n\ntype TaskEventModel = {\n  create: (args: unknown) => Promise<TaskEventRow>\n  findMany: (args: unknown) => Promise<TaskEventRow[]>\n}\n\ntype TaskModel = {\n  findMany: (args: unknown) => Promise<TaskMeta[]>\n}\n\nconst taskEventModel = (prisma as unknown as { taskEvent: TaskEventModel }).taskEvent\nconst taskModel = (prisma as unknown as { task: TaskModel }).task\n\nfunction createEphemeralId() {\n  return `ephemeral:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`\n}\n\nfunction isLifecycleEventType(value: string): value is TaskLifecycleEventType {\n  return value === TASK_EVENT_TYPE.CREATED ||\n    value === TASK_EVENT_TYPE.PROCESSING ||\n    value === TASK_EVENT_TYPE.COMPLETED ||\n    value === TASK_EVENT_TYPE.FAILED\n}\n\nfunction normalizeLifecycleType(type: TaskEventType): TaskLifecycleEventType {\n  if (isLifecycleEventType(type)) return type\n  return TASK_EVENT_TYPE.PROCESSING\n}\n\nfunction isStreamEventType(type: string) {\n  return type === TASK_SSE_EVENT_TYPE.STREAM\n}\n\nfunction shouldReplayLifecycleRow(type: string) {\n  return isLifecycleEventType(type)\n}\n\nfunction shouldReplayTaskRow(type: string) {\n  return shouldReplayLifecycleRow(type) || isStreamEventType(type)\n}\n\nfunction normalizeLifecyclePayload(\n  type: TaskEventType,\n  taskType: string | null | undefined,\n  payload?: Record<string, unknown> | null,\n): Record<string, unknown> {\n  const next: Record<string, unknown> = { ...(payload || {}) }\n  const lifecycleType = normalizeLifecycleType(type)\n  const payloadUi = next.ui && typeof next.ui === 'object' && !Array.isArray(next.ui)\n    ? (next.ui as Record<string, unknown>)\n    : null\n  next.lifecycleType = lifecycleType\n  next.intent = coerceTaskIntent(next.intent ?? payloadUi?.intent, taskType)\n\n  return next\n}\n\nfunction buildLifecycleEvent(params: {\n  id: string\n  ts: string\n  lifecycleType: TaskEventType\n  taskId: string\n  projectId: string\n  userId: string\n  taskType?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  episodeId?: string | null\n  payload?: Record<string, unknown> | null\n}): SSEEvent {\n  return {\n    id: params.id,\n    type: TASK_SSE_EVENT_TYPE.LIFECYCLE,\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    ts: params.ts,\n    taskType: params.taskType || null,\n    targetType: params.targetType || null,\n    targetId: params.targetId || null,\n    episodeId: params.episodeId || null,\n    payload: normalizeLifecyclePayload(params.lifecycleType, params.taskType, params.payload || null),\n  }\n}\n\nfunction normalizeStreamPayload(\n  taskType: string | null | undefined,\n  payload?: Record<string, unknown> | null,\n): Record<string, unknown> {\n  return {\n    ...(payload || {}),\n    intent: resolveTaskIntent(taskType),\n  }\n}\n\nfunction buildStreamEvent(params: {\n  id: string\n  ts: string\n  taskId: string\n  projectId: string\n  userId: string\n  taskType?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  episodeId?: string | null\n  payload?: Record<string, unknown> | null\n}): SSEEvent {\n  return {\n    id: params.id,\n    type: TASK_SSE_EVENT_TYPE.STREAM,\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    ts: params.ts,\n    taskType: params.taskType || null,\n    targetType: params.targetType || null,\n    targetId: params.targetId || null,\n    episodeId: params.episodeId || null,\n    payload: normalizeStreamPayload(params.taskType, params.payload || null),\n  }\n}\n\nasync function mapRowsToReplayEvents(rows: TaskEventRow[]): Promise<SSEEvent[]> {\n  if (rows.length === 0) return []\n\n  const taskIds = Array.from(new Set(rows.map((row) => row.taskId)))\n  const tasks: TaskMeta[] = taskIds.length\n    ? await taskModel.findMany({\n        where: { id: { in: taskIds } },\n        select: {\n          id: true,\n          type: true,\n          targetType: true,\n          targetId: true,\n          episodeId: true,\n        },\n      })\n    : []\n  const taskMap = new Map<string, TaskMeta>(tasks.map((task) => [task.id, task]))\n\n  return rows.map((row): SSEEvent => {\n    const task = taskMap.get(row.taskId)\n    if (isStreamEventType(row.eventType)) {\n      return buildStreamEvent({\n        id: String(row.id),\n        ts: row.createdAt.toISOString(),\n        taskId: row.taskId,\n        projectId: row.projectId,\n        userId: row.userId,\n        taskType: task?.type || null,\n        targetType: task?.targetType || null,\n        targetId: task?.targetId || null,\n        episodeId: task?.episodeId || null,\n        payload: row.payload || null,\n      })\n    }\n    const lifecycleType = row.eventType as TaskEventType\n    return buildLifecycleEvent({\n      id: String(row.id),\n      ts: row.createdAt.toISOString(),\n      lifecycleType,\n      taskId: row.taskId,\n      projectId: row.projectId,\n      userId: row.userId,\n      taskType: task?.type || null,\n      targetType: task?.targetType || null,\n      targetId: task?.targetId || null,\n      episodeId: task?.episodeId || null,\n      payload: row.payload || null,\n    })\n  })\n}\n\nexport async function listTaskLifecycleEvents(taskId: string, limit = 500) {\n  const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.floor(limit), 1), 5000) : 500\n  const latestRows = await taskEventModel.findMany({\n    where: { taskId },\n    orderBy: { id: 'desc' },\n    take: safeLimit,\n  })\n  const rows = [...latestRows].reverse()\n  const replayRows = rows.filter((row) => shouldReplayTaskRow(row.eventType))\n  return await mapRowsToReplayEvents(replayRows)\n}\n\nexport function getProjectChannel(projectId: string) {\n  return `${CHANNEL_PREFIX}${projectId}`\n}\n\nasync function mirrorTaskEventToRun(message: SSEEvent) {\n  if (message.taskType && TASK_TYPES_WITH_DIRECT_RUN_EVENTS.has(message.taskType)) {\n    return\n  }\n  const runEvents = mapTaskSSEEventToRunEvents(message)\n  if (runEvents.length === 0) return\n  for (const event of runEvents) {\n    await publishRunEvent(event)\n  }\n}\n\nexport async function publishTaskLifecycleEvent(params: {\n  taskId: string\n  projectId: string\n  userId: string\n  lifecycleType: TaskEventType\n  taskType?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  episodeId?: string | null\n  payload?: Record<string, unknown> | null\n  persist?: boolean\n}) {\n  const persist = params.persist !== false\n  const normalizedType = normalizeLifecycleType(params.lifecycleType)\n  const event = persist\n    ? await taskEventModel.create({\n        data: {\n          taskId: params.taskId,\n          projectId: params.projectId,\n          userId: params.userId,\n          eventType: normalizedType,\n          payload: normalizeLifecyclePayload(params.lifecycleType, params.taskType, params.payload || null),\n        },\n      })\n    : null\n  const ts = (event?.createdAt || new Date()).toISOString()\n  const id = event?.id ? String(event.id) : createEphemeralId()\n\n  const message = buildLifecycleEvent({\n    id,\n    ts,\n    lifecycleType: params.lifecycleType,\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    taskType: params.taskType || null,\n    targetType: params.targetType || null,\n    targetId: params.targetId || null,\n    episodeId: params.episodeId || null,\n    payload: params.payload || null,\n  })\n\n  await redis.publish(getProjectChannel(params.projectId), JSON.stringify(message))\n  await mirrorTaskEventToRun(message)\n  return message\n}\n\nexport async function publishTaskEvent(params: {\n  taskId: string\n  projectId: string\n  userId: string\n  type: TaskEventType\n  taskType?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  episodeId?: string | null\n  payload?: Record<string, unknown> | null\n  persist?: boolean\n}) {\n  return await publishTaskLifecycleEvent({\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    lifecycleType: params.type,\n    taskType: params.taskType,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    episodeId: params.episodeId,\n    payload: params.payload,\n    persist: params.persist,\n  })\n}\n\nexport async function publishTaskStreamEvent(params: {\n  taskId: string\n  projectId: string\n  userId: string\n  taskType?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  episodeId?: string | null\n  payload?: Record<string, unknown> | null\n  persist?: boolean\n}) {\n  if (!STREAM_EPHEMERAL_ENABLED) return null\n\n  const persist = params.persist === true\n  const normalizedPayload = normalizeStreamPayload(params.taskType, params.payload || null)\n  const event = persist\n    ? await taskEventModel.create({\n        data: {\n          taskId: params.taskId,\n          projectId: params.projectId,\n          userId: params.userId,\n          eventType: TASK_SSE_EVENT_TYPE.STREAM,\n          payload: normalizedPayload,\n        },\n      })\n    : null\n  const ts = (event?.createdAt || new Date()).toISOString()\n  const id = event?.id ? String(event.id) : createEphemeralId()\n\n  const message = buildStreamEvent({\n    id,\n    ts,\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    taskType: params.taskType || null,\n    targetType: params.targetType || null,\n    targetId: params.targetId || null,\n    episodeId: params.episodeId || null,\n    payload: normalizedPayload,\n  })\n\n  await redis.publish(getProjectChannel(params.projectId), JSON.stringify(message))\n  await mirrorTaskEventToRun(message)\n  return message\n}\n\nexport async function listEventsAfter(projectId: string, afterId: number, limit = 200) {\n  const pageSize = Math.max(limit * 2, 400)\n  const maxScanRows = Math.max(limit * 50, 20_000)\n  let cursor = afterId\n  let scannedRows = 0\n  const collected: TaskEventRow[] = []\n\n  while (collected.length < limit && scannedRows < maxScanRows) {\n    const rows = await taskEventModel.findMany({\n      where: {\n        projectId,\n        id: { gt: cursor },\n      },\n      orderBy: { id: 'asc' },\n      take: pageSize,\n    })\n\n    if (rows.length === 0) break\n    scannedRows += rows.length\n\n    for (const row of rows) {\n      if (!shouldReplayTaskRow(row.eventType)) continue\n      collected.push(row)\n      if (collected.length >= limit) break\n    }\n\n    cursor = rows[rows.length - 1]?.id || cursor\n    if (rows.length < pageSize) break\n  }\n\n  return await mapRowsToReplayEvents(collected.slice(0, limit))\n}\n"
  },
  {
    "path": "src/lib/task/queues.ts",
    "content": "import { JobsOptions, Queue } from 'bullmq'\nimport { queueRedis } from '@/lib/redis'\nimport { QueueType, TaskType, TASK_TYPE, type TaskJobData } from './types'\n\nexport const QUEUE_NAME = {\n  IMAGE: 'waoowaoo-image',\n  VIDEO: 'waoowaoo-video',\n  VOICE: 'waoowaoo-voice',\n  TEXT: 'waoowaoo-text',\n} as const\n\nconst defaultJobOptions: JobsOptions = {\n  removeOnComplete: 500,\n  removeOnFail: 500,\n  attempts: 5,\n  backoff: {\n    type: 'exponential',\n    delay: 2_000,\n  },\n}\n\nexport const imageQueue = new Queue<TaskJobData>(QUEUE_NAME.IMAGE, {\n  connection: queueRedis,\n  defaultJobOptions,\n})\n\nexport const videoQueue = new Queue<TaskJobData>(QUEUE_NAME.VIDEO, {\n  connection: queueRedis,\n  defaultJobOptions,\n})\n\nexport const voiceQueue = new Queue<TaskJobData>(QUEUE_NAME.VOICE, {\n  connection: queueRedis,\n  defaultJobOptions,\n})\n\nexport const textQueue = new Queue<TaskJobData>(QUEUE_NAME.TEXT, {\n  connection: queueRedis,\n  defaultJobOptions,\n})\n\nconst ALL_QUEUES = [imageQueue, videoQueue, voiceQueue, textQueue]\n\nconst IMAGE_TYPES = new Set<TaskType>([\n  TASK_TYPE.IMAGE_PANEL,\n  TASK_TYPE.IMAGE_CHARACTER,\n  TASK_TYPE.IMAGE_LOCATION,\n  TASK_TYPE.PANEL_VARIANT,\n  TASK_TYPE.MODIFY_ASSET_IMAGE,\n  TASK_TYPE.REGENERATE_GROUP,\n  TASK_TYPE.ASSET_HUB_IMAGE,\n  TASK_TYPE.ASSET_HUB_MODIFY,\n])\n\nconst VIDEO_TYPES = new Set<TaskType>([TASK_TYPE.VIDEO_PANEL, TASK_TYPE.LIP_SYNC])\nconst VOICE_TYPES = new Set<TaskType>([\n  TASK_TYPE.VOICE_LINE,\n  TASK_TYPE.VOICE_DESIGN,\n  TASK_TYPE.ASSET_HUB_VOICE_DESIGN,\n])\n\nconst SINGLE_ATTEMPT_TASK_TYPES = new Set<TaskType>([\n  TASK_TYPE.STORY_TO_SCRIPT_RUN,\n  TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n])\n\nexport function getQueueTypeByTaskType(type: TaskType): QueueType {\n  if (IMAGE_TYPES.has(type)) return 'image'\n  if (VIDEO_TYPES.has(type)) return 'video'\n  if (VOICE_TYPES.has(type)) return 'voice'\n  return 'text'\n}\n\nexport function getQueueByType(type: QueueType) {\n  switch (type) {\n    case 'image':\n      return imageQueue\n    case 'video':\n      return videoQueue\n    case 'voice':\n      return voiceQueue\n    case 'text':\n    default:\n      return textQueue\n  }\n}\n\nexport async function addTaskJob(data: TaskJobData, opts?: JobsOptions) {\n  const queueType = getQueueTypeByTaskType(data.type)\n  const queue = getQueueByType(queueType)\n  const priority = typeof opts?.priority === 'number' ? opts.priority : 0\n  const attempts = SINGLE_ATTEMPT_TASK_TYPES.has(data.type)\n    ? 1\n    : (typeof opts?.attempts === 'number' ? opts.attempts : undefined)\n  return await queue.add(data.type, data, {\n    jobId: data.taskId,\n    priority,\n    ...(opts || {}),\n    ...(attempts !== undefined ? { attempts } : {}),\n  })\n}\n\nexport async function removeTaskJob(taskId: string) {\n  for (const queue of ALL_QUEUES) {\n    const job = await queue.getJob(taskId)\n    if (!job) continue\n    await job.remove()\n    return true\n  }\n  return false\n}\n"
  },
  {
    "path": "src/lib/task/reconcile.ts",
    "content": "/**\n * Task Reconciliation — DB ↔ BullMQ 状态对账\n *\n * 解决 DB 任务状态与 BullMQ Job 状态脱节导致的任务永久卡死问题。\n * 提供三个层次的对账能力：\n *   1. isJobAlive   — 单任务即时检查（供 createTask 去重时调用）\n *   2. reconcileActiveTasks — 批量对账（供 watchdog 定时调用）\n *   3. startTaskWatchdog    — 定时巡检入口（在 instrumentation.ts 启动）\n */\n\nimport { prisma } from '@/lib/prisma'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport { TASK_STATUS, TASK_EVENT_TYPE } from './types'\nimport { publishTaskEvent } from './publisher'\nimport { rollbackTaskBillingForTask } from './service'\nimport {\n    imageQueue,\n    videoQueue,\n    voiceQueue,\n    textQueue,\n} from './queues'\n\n// ────────────────────── 常量 ──────────────────────\n\nconst ACTIVE_STATUSES = [TASK_STATUS.QUEUED, TASK_STATUS.PROCESSING]\n\n/** watchdog 巡检间隔 */\nconst WATCHDOG_INTERVAL_MS = 60_000\n\n/** processing 心跳超时阈值 */\nconst PROCESSING_TIMEOUT_MS = 5 * 60_000\n\n/** 每次对账扫描上限 */\nconst RECONCILE_BATCH_SIZE = 200\n\n/** terminal 态短暂竞态保护窗口，避免 worker 刚结束时被误判为孤儿任务 */\nconst TERMINAL_RECONCILE_GRACE_MS = 90_000\n\n/** missing 态短暂竞态保护窗口，避免 createTask→enqueue 之间被误判为孤儿任务 */\nconst MISSING_RECONCILE_GRACE_MS = 30_000\n\n// ────────────────────── BullMQ Job 状态检查 ──────────────────────\n\ntype JobState = 'alive' | 'terminal' | 'missing'\n\nconst ALL_QUEUES = [imageQueue, videoQueue, voiceQueue, textQueue]\n\n/**\n * 检查 BullMQ 中某个 Job 的真实状态。\n * - alive:    Job 存在且仍可执行（waiting / active / delayed / waiting-children）\n * - terminal: Job 存在但已终态（completed / failed）\n * - missing:  Job 在所有队列中均不存在\n */\nasync function getJobState(taskId: string): Promise<JobState> {\n    for (const queue of ALL_QUEUES) {\n        try {\n            const job = await queue.getJob(taskId)\n            if (!job) continue\n            const state = await job.getState()\n            if (state === 'completed' || state === 'failed') {\n                return 'terminal'\n            }\n            // waiting | active | delayed | waiting-children → 仍然活着\n            return 'alive'\n        } catch {\n            // 单个队列查询失败不影响其他队列\n            continue\n        }\n    }\n    return 'missing'\n}\n\n/**\n * 检查 BullMQ Job 是否仍然活着。\n * 供 createTask 去重时调用——如果 Job 已死，则不应复用旧的 active 任务。\n */\nexport async function isJobAlive(taskId: string): Promise<boolean> {\n    const state = await getJobState(taskId)\n    return state === 'alive'\n}\n\n// ────────────────────── 孤儿任务终止 ──────────────────────\n\n/**\n * 将一个孤儿任务标记为 failed 并发送 SSE 事件通知前端。\n */\nasync function failOrphanedTask(\n    task: {\n        id: string\n        userId: string\n        projectId: string\n        episodeId: string | null\n        type: string\n        targetType: string\n        targetId: string\n        billingInfo: unknown\n    },\n    reason: string,\n): Promise<boolean> {\n    const rollbackResult = await rollbackTaskBillingForTask({\n        taskId: task.id,\n        billingInfo: task.billingInfo,\n    })\n    const compensationFailed = rollbackResult.attempted && !rollbackResult.rolledBack\n    const errorCode = compensationFailed ? 'BILLING_COMPENSATION_FAILED' : 'RECONCILE_ORPHAN'\n    const errorMessage = compensationFailed\n        ? `${reason}; billing rollback failed`\n        : reason\n\n    const result = await prisma.task.updateMany({\n        where: {\n            id: task.id,\n            status: { in: ACTIVE_STATUSES },\n        },\n        data: {\n            status: TASK_STATUS.FAILED,\n            errorCode,\n            errorMessage,\n            finishedAt: new Date(),\n            heartbeatAt: null,\n            dedupeKey: null,\n        },\n    })\n\n    if (result.count > 0) {\n        // 发送 FAILED 事件，触发前端 SSE 更新 + 数据刷新\n        await publishTaskEvent({\n            taskId: task.id,\n            projectId: task.projectId,\n            userId: task.userId,\n            type: TASK_EVENT_TYPE.FAILED,\n            taskType: task.type,\n            targetType: task.targetType,\n            targetId: task.targetId,\n            episodeId: task.episodeId,\n            payload: {\n                stage: 'reconciled',\n                stageLabel: '任务已自动恢复',\n                message: errorMessage,\n                compensationFailed,\n            },\n            persist: false,\n        })\n    }\n\n    return result.count > 0\n}\n\n// ────────────────────── 批量对账 ──────────────────────\n\n/**\n * 对账所有 DB 中 active 的任务与 BullMQ 的真实状态。\n * 任何 DB 里 active 但 BullMQ 里 terminal / missing 的任务会被标记为 failed。\n */\nexport async function reconcileActiveTasks(): Promise<string[]> {\n    const now = Date.now()\n    const activeTasks = await prisma.task.findMany({\n        where: {\n            status: { in: ACTIVE_STATUSES },\n        },\n        select: {\n            id: true,\n            userId: true,\n            projectId: true,\n            episodeId: true,\n            type: true,\n            targetType: true,\n            targetId: true,\n            billingInfo: true,\n            updatedAt: true,\n        },\n        orderBy: { createdAt: 'asc' },\n        take: RECONCILE_BATCH_SIZE,\n    })\n\n    if (activeTasks.length === 0) return []\n\n    const reconciled: string[] = []\n    for (const task of activeTasks) {\n        const jobState = await getJobState(task.id)\n        if (jobState === 'alive') continue\n        if (\n            jobState === 'terminal'\n            && now - task.updatedAt.getTime() < TERMINAL_RECONCILE_GRACE_MS\n        ) {\n            continue\n        }\n        if (\n            jobState === 'missing'\n            && now - task.updatedAt.getTime() < MISSING_RECONCILE_GRACE_MS\n        ) {\n            continue\n        }\n\n        const reason =\n            jobState === 'terminal'\n                ? 'Queue job already terminated but DB was not updated'\n                : 'Queue job missing (likely lost during restart)'\n\n        const failed = await failOrphanedTask(task, reason)\n        if (failed) {\n            reconciled.push(task.id)\n        }\n    }\n\n    return reconciled\n}\n\n// ────────────────────── Watchdog ──────────────────────\n\nlet watchdogTimer: ReturnType<typeof setInterval> | null = null\n\n/**\n * 启动任务 watchdog 定时器。\n * 每个巡检周期执行：\n *   1. sweepStaleTasks — 心跳超时的 processing 任务 → failed\n *   2. reconcileActiveTasks — DB active 但 BullMQ 已死的任务 → failed\n */\nexport function startTaskWatchdog() {\n    if (watchdogTimer) return\n\n    const logger = createScopedLogger({ module: 'task.watchdog' })\n    logger.info({\n        action: 'watchdog.start',\n        message: `Task watchdog started (interval: ${WATCHDOG_INTERVAL_MS}ms)`,\n    })\n\n    watchdogTimer = setInterval(async () => {\n        try {\n            // 1. 清理心跳超时的 processing 任务（已有逻辑，此前未被调用）\n            const { sweepStaleTasks } = await import('./service')\n            const sweptProcessing = await sweepStaleTasks({\n                processingThresholdMs: PROCESSING_TIMEOUT_MS,\n            })\n            for (const task of sweptProcessing) {\n                await publishTaskEvent({\n                    taskId: task.id,\n                    projectId: task.projectId,\n                    userId: task.userId,\n                    type: TASK_EVENT_TYPE.FAILED,\n                    taskType: task.type,\n                    targetType: task.targetType,\n                    targetId: task.targetId,\n                    episodeId: task.episodeId || null,\n                    payload: {\n                        stage: 'watchdog_timeout',\n                        stageLabel: '任务超时已终止',\n                        message: task.errorMessage,\n                        errorCode: task.errorCode,\n                        compensationFailed: task.errorCode === 'BILLING_COMPENSATION_FAILED',\n                    },\n                    persist: false,\n                })\n            }\n\n            // 2. 对账 DB vs BullMQ\n            const reconciled = await reconcileActiveTasks()\n\n            const total = sweptProcessing.length + reconciled.length\n            if (total > 0) {\n                logger.info({\n                    action: 'watchdog.cycle',\n                    message: `Watchdog: ${sweptProcessing.length} heartbeat-timeout, ${reconciled.length} orphan-reconciled`,\n                })\n            }\n        } catch (error) {\n            logger.error({\n                action: 'watchdog.error',\n                message: 'Watchdog cycle failed',\n                error:\n                    error instanceof Error\n                        ? { name: error.name, message: error.message, stack: error.stack }\n                        : { message: String(error) },\n            })\n        }\n    }, WATCHDOG_INTERVAL_MS)\n}\n"
  },
  {
    "path": "src/lib/task/resolve-locale.ts",
    "content": "import type { NextRequest } from 'next/server'\nimport { ApiError } from '@/lib/api-errors'\nimport { locales, type Locale } from '@/i18n/routing'\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction normalizeCandidate(raw: string): Locale | null {\n  const normalized = raw.trim().toLowerCase()\n  if (!normalized) return null\n\n  for (const locale of locales) {\n    if (normalized === locale || normalized.startsWith(`${locale}-`)) {\n      return locale\n    }\n  }\n  return null\n}\n\nfunction readLocaleFromPayload(body?: unknown): Locale | null {\n  const payload = toObject(body)\n  const meta = toObject(payload.meta)\n  const candidates: unknown[] = [meta.locale, payload.locale]\n  for (const candidate of candidates) {\n    if (typeof candidate !== 'string') continue\n    const locale = normalizeCandidate(candidate)\n    if (locale) return locale\n  }\n  return null\n}\n\nfunction readLocaleFromHeader(request: NextRequest): Locale | null {\n  const raw = request.headers.get('accept-language') || ''\n  if (!raw) return null\n  const first = raw.split(',')[0]?.trim() || ''\n  if (!first) return null\n  return normalizeCandidate(first)\n}\n\nexport function resolveTaskLocaleFromBody(body?: unknown): Locale | null {\n  return readLocaleFromPayload(body)\n}\n\nexport function resolveTaskLocale(request: NextRequest, body?: unknown): Locale | null {\n  const payloadLocale = resolveTaskLocaleFromBody(body)\n  if (payloadLocale) return payloadLocale\n  return readLocaleFromHeader(request)\n}\n\nexport function resolveRequiredTaskLocale(request: NextRequest, body?: unknown): Locale {\n  const locale = resolveTaskLocale(request, body)\n  if (!locale) {\n    throw new ApiError('INVALID_PARAMS', {\n      code: 'TASK_LOCALE_REQUIRED',\n      field: 'meta.locale',\n    })\n  }\n  return locale\n}\n"
  },
  {
    "path": "src/lib/task/service.ts",
    "content": "import { Prisma } from '@prisma/client'\nimport { prisma } from '@/lib/prisma'\nimport { withPrismaRetry } from '@/lib/prisma-retry'\nimport { rollbackTaskBilling } from '@/lib/billing'\nimport { locales } from '@/i18n/routing'\nimport { TASK_STATUS, type CreateTaskInput, type TaskBillingInfo, type TaskStatus } from './types'\n\nconst ACTIVE_STATUSES: TaskStatus[] = [TASK_STATUS.QUEUED, TASK_STATUS.PROCESSING]\nconst taskModel = prisma.task\n\n/**\n * 校验 BullMQ Job 是否仍然活着。\n * 检查失败时（如 Redis 不可用）安全降级为 true，不阻塞正常创建流程。\n */\nasync function verifyJobAlive(taskId: string): Promise<boolean> {\n  try {\n    const { isJobAlive } = await import('./reconcile')\n    return await isJobAlive(taskId)\n  } catch {\n    // Redis 异常等不可控情况 → 降级信任 DB 状态\n    return true\n  }\n}\n\nfunction isPrismaKnownError(error: unknown): error is { code?: string } {\n  return typeof error === 'object' && error !== null && 'code' in error\n}\n\nfunction isActiveStatus(status: string) {\n  return status === TASK_STATUS.QUEUED || status === TASK_STATUS.PROCESSING\n}\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction normalizeLocale(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  const normalized = value.trim().toLowerCase()\n  if (!normalized) return null\n  for (const locale of locales) {\n    if (normalized === locale || normalized.startsWith(`${locale}-`)) {\n      return locale\n    }\n  }\n  return null\n}\n\nfunction hasTaskLocale(payload: unknown): boolean {\n  const payloadObject = toObject(payload)\n  const payloadMeta = toObject(payloadObject.meta)\n  const locale = normalizeLocale(payloadMeta.locale) || normalizeLocale(payloadObject.locale)\n  return locale !== null\n}\n\nfunction toNullableJson(value?: Prisma.InputJsonValue | Record<string, unknown> | TaskBillingInfo | null) {\n  if (value === undefined) return undefined\n  if (value === null) return Prisma.JsonNull\n  return value as Prisma.InputJsonValue\n}\n\nfunction parseTaskBillingInfo(raw: unknown): TaskBillingInfo | null {\n  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null\n  if (!('billable' in raw)) return null\n  const billable = (raw as { billable?: unknown }).billable\n  if (typeof billable !== 'boolean') return null\n  return raw as TaskBillingInfo\n}\n\nfunction needsRollback(info: TaskBillingInfo | null): info is Extract<TaskBillingInfo, { billable: true }> {\n  if (!info || !info.billable) return false\n  if (!info.freezeId) return false\n  if (info.modeSnapshot === 'OFF' || info.modeSnapshot === 'SHADOW') return false\n  if (info.status === 'settled' || info.status === 'rolled_back') return false\n  return true\n}\n\ntype TaskBillingRollbackResult = {\n  attempted: boolean\n  rolledBack: boolean\n  billingInfo: TaskBillingInfo | null\n}\n\nfunction resolveCompensationFailure(\n  rollback: TaskBillingRollbackResult,\n  fallbackCode: string,\n  fallbackMessage: string,\n) {\n  if (!rollback.attempted || rollback.rolledBack) {\n    return {\n      errorCode: fallbackCode,\n      errorMessage: fallbackMessage,\n    }\n  }\n  return {\n    errorCode: 'BILLING_COMPENSATION_FAILED',\n    errorMessage: `${fallbackMessage}; billing rollback failed`,\n  }\n}\n\nasync function failTaskWithMissingLocale(task: {\n  id: string\n  billingInfo: unknown\n}) {\n  const rollbackResult = await rollbackTaskBillingForTask({\n    taskId: task.id,\n    billingInfo: task.billingInfo,\n  })\n  const failure = resolveCompensationFailure(\n    rollbackResult,\n    'TASK_LOCALE_REQUIRED',\n    'task locale is missing',\n  )\n\n  await taskModel.update({\n    where: { id: task.id },\n    data: {\n      status: TASK_STATUS.FAILED,\n      errorCode: failure.errorCode,\n      errorMessage: failure.errorMessage,\n      finishedAt: new Date(),\n      heartbeatAt: null,\n      dedupeKey: null,\n    },\n  })\n}\n\nexport async function rollbackTaskBillingForTask(params: {\n  taskId: string\n  billingInfo?: unknown\n}): Promise<TaskBillingRollbackResult> {\n  const current =\n    params.billingInfo === undefined\n      ? await taskModel.findUnique({\n        where: { id: params.taskId },\n        select: { billingInfo: true },\n      })\n      : { billingInfo: params.billingInfo }\n\n  const billingInfo = parseTaskBillingInfo(current?.billingInfo ?? null)\n  if (!needsRollback(billingInfo)) {\n    return {\n      attempted: false,\n      rolledBack: true,\n      billingInfo,\n    }\n  }\n\n  const nextInfo = (await rollbackTaskBilling({\n    id: params.taskId,\n    billingInfo,\n  })) as TaskBillingInfo\n\n  await updateTaskBillingInfo(params.taskId, nextInfo)\n\n  return {\n    attempted: true,\n    rolledBack: nextInfo.billable ? nextInfo.status === 'rolled_back' : true,\n    billingInfo: nextInfo,\n  }\n}\n\nexport async function createTask(input: CreateTaskInput) {\n  const model = taskModel\n\n  if (input.dedupeKey) {\n    const existing = await model.findFirst({\n      where: {\n        dedupeKey: input.dedupeKey,\n      },\n      orderBy: { createdAt: 'desc' },\n    })\n\n    if (existing) {\n      if (isActiveStatus(existing.status)) {\n        if (!hasTaskLocale(existing.payload)) {\n          await failTaskWithMissingLocale(existing)\n        } else {\n          // 校验 BullMQ Job 是否真的还活着，防止 DB 与队列状态脱节导致永久卡死\n          const jobAlive = await verifyJobAlive(existing.id)\n          if (jobAlive) {\n            return { task: existing, deduped: true as const }\n          }\n\n          const rollbackResult = await rollbackTaskBillingForTask({\n            taskId: existing.id,\n            billingInfo: existing.billingInfo,\n          })\n          const failure = resolveCompensationFailure(\n            rollbackResult,\n            'RECONCILE_ORPHAN',\n            'Queue job lost, replaced by new task',\n          )\n\n          // Job 已死（terminal / missing）→ 终止孤儿任务，释放 dedupeKey，继续创建新任务\n          await model.update({\n            where: { id: existing.id },\n            data: {\n              status: TASK_STATUS.FAILED,\n              errorCode: failure.errorCode,\n              errorMessage: failure.errorMessage,\n              finishedAt: new Date(),\n              heartbeatAt: null,\n              dedupeKey: null,\n            },\n          })\n        }\n      } else {\n        // dedupeKey is unique in DB. Release terminal-task key so a new task can be created.\n        await model.update({\n          where: { id: existing.id },\n          data: { dedupeKey: null },\n        })\n      }\n    }\n  }\n\n  const createData = {\n    userId: input.userId,\n    projectId: input.projectId,\n    episodeId: input.episodeId || null,\n    type: input.type,\n    targetType: input.targetType,\n    targetId: input.targetId,\n    status: TASK_STATUS.QUEUED,\n    progress: 0,\n    attempt: 0,\n    maxAttempts: input.maxAttempts ?? 5,\n    priority: input.priority ?? 0,\n    dedupeKey: input.dedupeKey || null,\n    payload: toNullableJson(input.payload ?? null),\n    billingInfo: toNullableJson(input.billingInfo ?? null),\n    queuedAt: new Date(),\n  }\n\n  try {\n    const task = await model.create({ data: createData })\n    return { task, deduped: false as const }\n  } catch (error: unknown) {\n    if (input.dedupeKey && isPrismaKnownError(error) && error.code === 'P2002') {\n      const collided = await model.findFirst({\n        where: { dedupeKey: input.dedupeKey },\n        orderBy: { createdAt: 'desc' },\n      })\n\n      if (collided) {\n        if (isActiveStatus(collided.status)) {\n          if (!hasTaskLocale(collided.payload)) {\n            await failTaskWithMissingLocale(collided)\n          } else {\n            // P2002 竞态路径：同样校验 BullMQ Job 状态\n            const jobAlive = await verifyJobAlive(collided.id)\n            if (jobAlive) {\n              return { task: collided, deduped: true as const }\n            }\n\n            const rollbackResult = await rollbackTaskBillingForTask({\n              taskId: collided.id,\n              billingInfo: collided.billingInfo,\n            })\n            const failure = resolveCompensationFailure(\n              rollbackResult,\n              'RECONCILE_ORPHAN',\n              'Queue job lost, replaced by new task',\n            )\n\n            await model.update({\n              where: { id: collided.id },\n              data: {\n                status: TASK_STATUS.FAILED,\n                errorCode: failure.errorCode,\n                errorMessage: failure.errorMessage,\n                finishedAt: new Date(),\n                heartbeatAt: null,\n                dedupeKey: null,\n              },\n            })\n          }\n        } else {\n          await model.update({\n            where: { id: collided.id },\n            data: { dedupeKey: null },\n          })\n        }\n\n        const task = await model.create({ data: createData })\n        return { task, deduped: false as const }\n      }\n    }\n\n    throw error\n  }\n}\n\nexport async function getTaskById(taskId: string) {\n  return await taskModel.findUnique({ where: { id: taskId } })\n}\n\nexport async function queryTasks(filters: {\n  projectId?: string\n  targetType?: string\n  targetId?: string\n  status?: TaskStatus[]\n  type?: string[]\n  limit?: number\n}) {\n  return await taskModel.findMany({\n    where: {\n      ...(filters.projectId ? { projectId: filters.projectId } : {}),\n      ...(filters.targetType ? { targetType: filters.targetType } : {}),\n      ...(filters.targetId ? { targetId: filters.targetId } : {}),\n      ...(filters.status?.length ? { status: { in: filters.status } } : {}),\n      ...(filters.type?.length ? { type: { in: filters.type } } : {}),\n    },\n    orderBy: { createdAt: 'desc' },\n    take: filters.limit ?? 50,\n  })\n}\n\nexport async function getActiveTasksForTarget(params: {\n  targetType: string\n  targetId: string\n  projectId?: string\n}) {\n  return await taskModel.findMany({\n    where: {\n      targetType: params.targetType,\n      targetId: params.targetId,\n      ...(params.projectId ? { projectId: params.projectId } : {}),\n      status: { in: [...ACTIVE_STATUSES] },\n    },\n    orderBy: { createdAt: 'desc' },\n  })\n}\n\nexport async function markTaskEnqueueFailed(taskId: string, error: string) {\n  return await taskModel.update({\n    where: { id: taskId },\n    data: {\n      enqueueAttempts: { increment: 1 },\n      lastEnqueueError: error.slice(0, 500),\n    },\n  })\n}\n\nexport async function markTaskEnqueued(taskId: string) {\n  return await taskModel.update({\n    where: { id: taskId },\n    data: {\n      enqueuedAt: new Date(),\n      lastEnqueueError: null,\n    },\n  })\n}\n\nexport async function updateTaskBillingInfo(taskId: string, billingInfo: TaskBillingInfo | null) {\n  return await taskModel.update({\n    where: { id: taskId },\n    data: {\n      billingInfo: toNullableJson(billingInfo as unknown as Prisma.InputJsonValue),\n    },\n  })\n}\n\nexport async function updateTaskPayload(taskId: string, payload: Record<string, unknown> | null) {\n  return await taskModel.update({\n    where: { id: taskId },\n    data: {\n      payload: toNullableJson(payload as unknown as Prisma.InputJsonValue),\n    },\n  })\n}\n\nfunction activeTaskWhere(taskId: string) {\n  return {\n    id: taskId,\n    status: { in: [...ACTIVE_STATUSES] },\n  }\n}\n\nexport async function isTaskActive(taskId: string) {\n  const task = await withPrismaRetry(() =>\n    taskModel.findUnique({\n      where: { id: taskId },\n      select: { status: true },\n    })\n  )\n  if (!task) return false\n  return isActiveStatus(task.status)\n}\n\nexport async function tryMarkTaskProcessing(taskId: string, externalId?: string | null) {\n  const result = await taskModel.updateMany({\n    where: activeTaskWhere(taskId),\n    data: {\n      status: TASK_STATUS.PROCESSING,\n      startedAt: new Date(),\n      heartbeatAt: new Date(),\n      externalId: externalId || null,\n      attempt: { increment: 1 },\n    },\n  })\n  return result.count > 0\n}\n\nexport async function trySetTaskExternalId(taskId: string, externalId: string) {\n  const value = typeof externalId === 'string' ? externalId.trim() : ''\n  if (!value) return false\n  const result = await taskModel.updateMany({\n    where: {\n      ...activeTaskWhere(taskId),\n      OR: [\n        { externalId: null },\n        { externalId: '' },\n      ],\n    },\n    data: {\n      externalId: value,\n    },\n  })\n  return result.count > 0\n}\n\nexport async function touchTaskHeartbeat(taskId: string) {\n  const result = await taskModel.updateMany({\n    where: activeTaskWhere(taskId),\n    data: { heartbeatAt: new Date() },\n  })\n  return result.count > 0\n}\n\nexport async function tryUpdateTaskProgress(taskId: string, progress: number, payload?: Record<string, unknown> | null) {\n  const result = await taskModel.updateMany({\n    where: activeTaskWhere(taskId),\n    data: {\n      progress,\n      ...(payload ? { payload: toNullableJson(payload) } : {}),\n    },\n  })\n  return result.count > 0\n}\n\nexport async function tryMarkTaskCompleted(taskId: string, resultPayload?: Record<string, unknown> | null) {\n  const result = await taskModel.updateMany({\n    where: activeTaskWhere(taskId),\n    data: {\n      status: TASK_STATUS.COMPLETED,\n      progress: 100,\n      result: toNullableJson(resultPayload ?? null),\n      finishedAt: new Date(),\n      heartbeatAt: null,\n    },\n  })\n  return result.count > 0\n}\n\nexport async function tryMarkTaskFailed(taskId: string, errorCode: string, errorMessage: string) {\n  const result = await taskModel.updateMany({\n    where: activeTaskWhere(taskId),\n    data: {\n      status: TASK_STATUS.FAILED,\n      errorCode: errorCode.slice(0, 80),\n      errorMessage: errorMessage.slice(0, 2000),\n      finishedAt: new Date(),\n      heartbeatAt: null,\n    },\n  })\n  return result.count > 0\n}\n\nexport async function tryMarkTaskCanceled(taskId: string, errorCode: string, errorMessage: string) {\n  const result = await taskModel.updateMany({\n    where: activeTaskWhere(taskId),\n    data: {\n      status: TASK_STATUS.CANCELED,\n      errorCode: errorCode.slice(0, 80),\n      errorMessage: errorMessage.slice(0, 2000),\n      finishedAt: new Date(),\n      heartbeatAt: null,\n    },\n  })\n  return result.count > 0\n}\n\nexport async function markTaskProcessing(taskId: string, externalId?: string | null) {\n  return await tryMarkTaskProcessing(taskId, externalId)\n}\n\nexport async function updateTaskProgress(taskId: string, progress: number, payload?: Record<string, unknown> | null) {\n  return await tryUpdateTaskProgress(taskId, progress, payload)\n}\n\nexport async function markTaskCompleted(taskId: string, result?: Record<string, unknown> | null) {\n  return await tryMarkTaskCompleted(taskId, result)\n}\n\nexport async function markTaskFailed(taskId: string, errorCode: string, errorMessage: string) {\n  return await tryMarkTaskFailed(taskId, errorCode, errorMessage)\n}\n\nexport async function markTaskCanceled(taskId: string, errorCode: string, errorMessage: string) {\n  return await tryMarkTaskCanceled(taskId, errorCode, errorMessage)\n}\n\nexport async function cancelTask(taskId: string, reason = 'Task cancelled by user') {\n  const snapshot = await taskModel.findUnique({\n    where: { id: taskId },\n    select: {\n      id: true,\n      status: true,\n      billingInfo: true,\n    },\n  })\n  if (!snapshot) {\n    return {\n      task: null,\n      cancelled: false,\n    }\n  }\n\n  const active = isActiveStatus(snapshot.status)\n  const rollbackResult = active\n    ? await rollbackTaskBillingForTask({\n      taskId: taskId,\n      billingInfo: snapshot.billingInfo,\n    })\n    : {\n      attempted: false,\n      rolledBack: true,\n      billingInfo: parseTaskBillingInfo(snapshot.billingInfo),\n    }\n\n  const failure = resolveCompensationFailure(rollbackResult, 'TASK_CANCELLED', reason)\n  const cancelled = await tryMarkTaskCanceled(taskId, failure.errorCode, failure.errorMessage)\n  const task = await taskModel.findUnique({ where: { id: taskId } })\n  return {\n    task,\n    cancelled,\n  }\n}\n\nexport async function sweepStaleTasks(params: {\n  processingThresholdMs: number\n  limit?: number\n}) {\n  const limit = Math.max(1, params.limit || 200)\n  const processingBefore = new Date(Date.now() - Math.max(1, params.processingThresholdMs))\n\n  const staleProcessing = await taskModel.findMany({\n    where: {\n      status: TASK_STATUS.PROCESSING,\n      OR: [\n        { heartbeatAt: { lt: processingBefore } },\n        {\n          heartbeatAt: null,\n          startedAt: { lt: processingBefore },\n        },\n        {\n          heartbeatAt: null,\n          startedAt: null,\n          updatedAt: { lt: processingBefore },\n        },\n      ],\n    },\n    orderBy: { updatedAt: 'asc' },\n    take: limit,\n    select: {\n      id: true,\n      userId: true,\n      projectId: true,\n      episodeId: true,\n      type: true,\n      targetType: true,\n      targetId: true,\n      billingInfo: true,\n    },\n  })\n\n  if (staleProcessing.length === 0) return []\n\n  const finishedAt = new Date()\n  const timedOut: Array<typeof staleProcessing[number] & {\n    errorCode: string\n    errorMessage: string\n  }> = []\n  for (const task of staleProcessing) {\n    const rollbackResult = await rollbackTaskBillingForTask({\n      taskId: task.id,\n      billingInfo: task.billingInfo,\n    })\n    const failure = resolveCompensationFailure(\n      rollbackResult,\n      'WATCHDOG_TIMEOUT',\n      'Task heartbeat timeout',\n    )\n\n    const updated = await taskModel.updateMany({\n      where: {\n        id: task.id,\n        status: TASK_STATUS.PROCESSING,\n      },\n      data: {\n        status: TASK_STATUS.FAILED,\n        errorCode: failure.errorCode,\n        errorMessage: failure.errorMessage,\n        finishedAt,\n        heartbeatAt: null,\n      },\n    })\n    if (updated.count > 0) {\n      timedOut.push({\n        ...task,\n        errorCode: failure.errorCode,\n        errorMessage: failure.errorMessage,\n      })\n    }\n  }\n\n  return timedOut\n}\n\nexport async function dismissFailedTasks(taskIds: string[], userId: string) {\n  if (taskIds.length === 0) return 0\n  const result = await taskModel.updateMany({\n    where: {\n      id: { in: taskIds },\n      userId,\n      status: TASK_STATUS.FAILED,\n    },\n    data: {\n      status: TASK_STATUS.DISMISSED,\n    },\n  })\n  return result.count\n}\n"
  },
  {
    "path": "src/lib/task/state-service.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { normalizeTaskError } from '@/lib/errors/normalize'\nimport { coerceTaskIntent, type TaskIntent } from './intent'\n\nexport type TaskTargetQuery = {\n  targetType: string\n  targetId: string\n  types?: string[]\n}\n\nexport type TaskTargetPhase = 'idle' | 'queued' | 'processing' | 'completed' | 'failed'\n\nexport type TaskTargetState = {\n  targetType: string\n  targetId: string\n  phase: TaskTargetPhase\n  runningTaskId: string | null\n  runningTaskType: string | null\n  intent: TaskIntent\n  hasOutputAtStart: boolean | null\n  progress: number | null\n  stage: string | null\n  stageLabel: string | null\n  lastError: {\n    code: string\n    message: string\n  } | null\n  updatedAt: string | null\n}\n\nconst ACTIVE_STATUS = new Set(['queued', 'processing'])\n\nexport function pairKey(targetType: string, targetId: string) {\n  return `${targetType}:${targetId}`\n}\n\nexport function asObject(value: unknown): Record<string, unknown> | null {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n  return value as Record<string, unknown>\n}\n\nexport function asNonEmptyString(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  return trimmed.length > 0 ? trimmed : null\n}\n\nexport function asBoolean(value: unknown): boolean | null {\n  if (typeof value === 'boolean') return value\n  return null\n}\n\nexport function toProgress(value: unknown): number | null {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return null\n  const rounded = Math.floor(value)\n  if (rounded < 0) return 0\n  if (rounded > 100) return 100\n  return rounded\n}\n\nexport function extractTaskStateFields(task: {\n  type: string\n  progress: number\n  payload: unknown\n}) {\n  const payload = asObject(task.payload)\n  const payloadUi = asObject(payload?.ui)\n  return {\n    stage: asNonEmptyString(payload?.stage),\n    stageLabel: asNonEmptyString(payload?.stageLabel),\n    hasOutputAtStart: asBoolean(payloadUi?.hasOutputAtStart),\n    intent: coerceTaskIntent(payloadUi?.intent ?? payload?.intent, task.type),\n    progress: toProgress(task.progress),\n  }\n}\n\nexport function normalizeFailedError(task: {\n  errorCode: string | null\n  errorMessage: string | null\n}) {\n  const normalized = normalizeTaskError(task.errorCode, task.errorMessage)\n  if (!normalized) return null\n  return {\n    code: normalized.code,\n    message: normalized.message,\n  }\n}\n\nexport function buildIdleState(target: TaskTargetQuery): TaskTargetState {\n  return {\n    targetType: target.targetType,\n    targetId: target.targetId,\n    phase: 'idle',\n    runningTaskId: null,\n    runningTaskType: null,\n    intent: 'process',\n    hasOutputAtStart: null,\n    progress: null,\n    stage: null,\n    stageLabel: null,\n    lastError: null,\n    updatedAt: null,\n  }\n}\n\nexport function resolveTargetState(\n  target: TaskTargetQuery,\n  tasks: Array<{\n    id: string\n    type: string\n    status: string\n    progress: number\n    payload: unknown\n    errorCode: string | null\n    errorMessage: string | null\n    updatedAt: Date\n  }>,\n): TaskTargetState {\n  const allowedTypes = target.types?.length ? new Set(target.types) : null\n  const filtered = allowedTypes\n    ? tasks.filter((task) => allowedTypes.has(task.type))\n    : tasks\n\n  if (filtered.length === 0) return buildIdleState(target)\n\n  const running = filtered.find((task) => ACTIVE_STATUS.has(task.status)) || null\n  const terminal = filtered.find((task) =>\n    task.status === 'completed' || task.status === 'failed' || task.status === 'canceled'\n  ) || null\n  const latest = running || terminal\n\n  if (!latest) return buildIdleState(target)\n\n  const latestFields = extractTaskStateFields(latest)\n\n  if (running) {\n    const runningFields = extractTaskStateFields(running)\n    return {\n      targetType: target.targetType,\n      targetId: target.targetId,\n      phase: running.status === 'processing' ? 'processing' : 'queued',\n      runningTaskId: running.id,\n      runningTaskType: running.type,\n      intent: runningFields.intent,\n      hasOutputAtStart: runningFields.hasOutputAtStart,\n      progress: runningFields.progress,\n      stage: runningFields.stage,\n      stageLabel: runningFields.stageLabel,\n      lastError: null,\n      updatedAt: running.updatedAt.toISOString(),\n    }\n  }\n\n  if (latest.status === 'completed') {\n    return {\n      targetType: target.targetType,\n      targetId: target.targetId,\n      phase: 'completed',\n      runningTaskId: null,\n      runningTaskType: latest.type,\n      intent: latestFields.intent,\n      hasOutputAtStart: latestFields.hasOutputAtStart,\n      progress: 100,\n      stage: latestFields.stage,\n      stageLabel: latestFields.stageLabel,\n      lastError: null,\n      updatedAt: latest.updatedAt.toISOString(),\n    }\n  }\n\n  return {\n    targetType: target.targetType,\n    targetId: target.targetId,\n    phase: 'failed',\n    runningTaskId: null,\n    runningTaskType: latest.type,\n    intent: latestFields.intent,\n    hasOutputAtStart: latestFields.hasOutputAtStart,\n    progress: null,\n    stage: latestFields.stage,\n    stageLabel: latestFields.stageLabel,\n    lastError: normalizeFailedError(latest),\n    updatedAt: latest.updatedAt.toISOString(),\n  }\n}\n\n/**\n * 单次查询的 OR 条件上限。\n * 过大的 OR 列表 + ORDER BY 会导致 MySQL sort buffer 溢出（Error 1038）。\n */\nconst QUERY_BATCH_SIZE = 50\n\nexport async function queryTaskTargetStates(params: {\n  projectId: string\n  userId: string\n  targets: TaskTargetQuery[]\n}): Promise<TaskTargetState[]> {\n  if (!params.targets.length) return []\n\n  const pairEntries = new Map<string, { targetType: string; targetId: string }>()\n  const typeUnion = new Set<string>()\n\n  for (const target of params.targets) {\n    pairEntries.set(pairKey(target.targetType, target.targetId), {\n      targetType: target.targetType,\n      targetId: target.targetId,\n    })\n    for (const type of target.types || []) {\n      if (type) typeUnion.add(type)\n    }\n  }\n\n  const pairs = Array.from(pairEntries.values())\n  if (pairs.length === 0) return params.targets.map((target) => buildIdleState(target))\n\n  const typeFilter = typeUnion.size > 0 ? { type: { in: Array.from(typeUnion) } } : {}\n\n  // 分批查询，避免 MySQL sort buffer 溢出\n  const allRows: Array<{\n    id: string\n    type: string\n    status: string\n    progress: number\n    payload: unknown\n    errorCode: string | null\n    errorMessage: string | null\n    targetType: string\n    targetId: string\n    updatedAt: Date\n  }> = []\n\n  for (let i = 0; i < pairs.length; i += QUERY_BATCH_SIZE) {\n    const batch = pairs.slice(i, i + QUERY_BATCH_SIZE)\n    const rows = await prisma.task.findMany({\n      where: {\n        projectId: params.projectId,\n        userId: params.userId,\n        OR: batch.map((item) => ({\n          targetType: item.targetType,\n          targetId: item.targetId,\n        })),\n        status: {\n          in: ['queued', 'processing', 'completed', 'failed', 'canceled'],\n        },\n        ...typeFilter,\n      },\n      // 不在数据库层排序，改为应用层排序以避免 sort buffer 溢出\n      select: {\n        id: true,\n        type: true,\n        status: true,\n        progress: true,\n        payload: true,\n        errorCode: true,\n        errorMessage: true,\n        targetType: true,\n        targetId: true,\n        updatedAt: true,\n      },\n    })\n    allRows.push(...rows)\n  }\n\n  // 应用层按 updatedAt desc 排序（每个 target 组内排序即可）\n  const grouped = new Map<string, typeof allRows>()\n  for (const row of allRows) {\n    const key = pairKey(row.targetType, row.targetId)\n    const existing = grouped.get(key)\n    if (existing) {\n      existing.push(row)\n    } else {\n      grouped.set(key, [row])\n    }\n  }\n\n  // 对每组按 updatedAt desc 排序\n  for (const group of grouped.values()) {\n    group.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())\n  }\n\n  return params.targets.map((target) =>\n    resolveTargetState(\n      target,\n      grouped.get(pairKey(target.targetType, target.targetId)) || [],\n    ),\n  )\n}\n"
  },
  {
    "path": "src/lib/task/submitter.ts",
    "content": "import { createScopedLogger } from '@/lib/logging/core'\nimport { addTaskJob } from './queues'\nimport { publishTaskEvent } from './publisher'\nimport {\n  createTask,\n  getTaskById,\n  markTaskEnqueueFailed,\n  markTaskEnqueued,\n  markTaskFailed,\n  rollbackTaskBillingForTask,\n  updateTaskBillingInfo,\n  updateTaskPayload,\n} from './service'\nimport { TASK_EVENT_TYPE, TASK_STATUS, TASK_TYPE, type TaskBillingInfo, type TaskType } from './types'\nimport {\n  buildDefaultTaskBillingInfo,\n  getBillingMode,\n  InsufficientBalanceError,\n  isBillableTaskType,\n  prepareTaskBilling,\n} from '@/lib/billing'\nimport { ApiError } from '@/lib/api-errors'\nimport { getTaskFlowMeta } from '@/lib/llm-observe/stage-pipeline'\nimport type { Locale } from '@/i18n/routing'\nimport { attachTaskToRun, createRun, findReusableActiveRun } from '@/lib/run-runtime/service'\nimport { isAiTaskType, workflowTypeFromTaskType } from '@/lib/run-runtime/workflow'\n\nconst RUN_CENTRIC_TASK_TYPES = new Set<TaskType>([\n  TASK_TYPE.STORY_TO_SCRIPT_RUN,\n  TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n])\n\nfunction isRunCentricTaskType(type: TaskType): boolean {\n  return RUN_CENTRIC_TASK_TYPES.has(type)\n}\n\nexport function toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction resolveRunIdFromPayload(payload: unknown): string | null {\n  const obj = toObject(payload)\n  const runId = typeof obj.runId === 'string' ? obj.runId.trim() : ''\n  if (runId) return runId\n  const meta = toObject(obj.meta)\n  const runIdFromMeta = typeof meta.runId === 'string' ? meta.runId.trim() : ''\n  return runIdFromMeta || null\n}\n\nexport function normalizeTaskPayload(type: TaskType, payload?: Record<string, unknown> | null) {\n  const nextPayload = {\n    ...(payload || {}),\n  }\n  const flowMeta = getTaskFlowMeta(type)\n  const payloadMeta = toObject(nextPayload.meta)\n  const flowId =\n    typeof nextPayload.flowId === 'string' && nextPayload.flowId.trim()\n      ? nextPayload.flowId.trim()\n      : flowMeta.flowId\n  const flowStageTitle =\n    typeof nextPayload.flowStageTitle === 'string' && nextPayload.flowStageTitle.trim()\n      ? nextPayload.flowStageTitle.trim()\n      : flowMeta.flowStageTitle\n  const flowStageIndex =\n    typeof nextPayload.flowStageIndex === 'number' && Number.isFinite(nextPayload.flowStageIndex)\n      ? Math.max(1, Math.floor(nextPayload.flowStageIndex))\n      : flowMeta.flowStageIndex\n  const flowStageTotal =\n    typeof nextPayload.flowStageTotal === 'number' && Number.isFinite(nextPayload.flowStageTotal)\n      ? Math.max(flowStageIndex, Math.floor(nextPayload.flowStageTotal))\n      : Math.max(flowStageIndex, flowMeta.flowStageTotal)\n\n  return {\n    ...nextPayload,\n    flowId,\n    flowStageIndex,\n    flowStageTotal,\n    flowStageTitle,\n    meta: {\n      ...payloadMeta,\n      flowId:\n        typeof payloadMeta.flowId === 'string' && payloadMeta.flowId.trim()\n          ? payloadMeta.flowId.trim()\n          : flowId,\n      flowStageIndex:\n        typeof payloadMeta.flowStageIndex === 'number' && Number.isFinite(payloadMeta.flowStageIndex)\n          ? Math.max(1, Math.floor(payloadMeta.flowStageIndex))\n          : flowStageIndex,\n      flowStageTotal:\n        typeof payloadMeta.flowStageTotal === 'number' && Number.isFinite(payloadMeta.flowStageTotal)\n          ? Math.max(1, Math.floor(payloadMeta.flowStageTotal))\n          : flowStageTotal,\n      flowStageTitle:\n        typeof payloadMeta.flowStageTitle === 'string' && payloadMeta.flowStageTitle.trim()\n          ? payloadMeta.flowStageTitle.trim()\n          : flowStageTitle,\n    },\n  }\n}\n\nexport async function submitTask(params: {\n  userId: string\n  locale: Locale\n  projectId: string\n  episodeId?: string | null\n  type: TaskType\n  targetType: string\n  targetId: string\n  payload?: Record<string, unknown> | null\n  dedupeKey?: string | null\n  priority?: number\n  maxAttempts?: number\n  billingInfo?: TaskBillingInfo | null\n  requestId?: string | null\n}) {\n  const logger = createScopedLogger({\n    module: 'task.submitter',\n    action: 'task.submit',\n    requestId: params.requestId || undefined,\n    projectId: params.projectId,\n    userId: params.userId,\n  })\n\n  const normalizedPayloadBase = normalizeTaskPayload(params.type, params.payload || null)\n  const normalizedPayloadMeta = toObject(normalizedPayloadBase.meta)\n  const normalizedPayload = {\n    ...normalizedPayloadBase,\n    meta: {\n      ...normalizedPayloadMeta,\n      locale: params.locale,\n    },\n  }\n  const computedBillingInfo = isBillableTaskType(params.type)\n    ? buildDefaultTaskBillingInfo(params.type, normalizedPayload)\n    : null\n  const resolvedBillingInfo = computedBillingInfo || params.billingInfo || null\n  const runCentricTask = isRunCentricTaskType(params.type)\n  const workflowType = workflowTypeFromTaskType(params.type)\n  const reusableRun = runCentricTask\n    ? await findReusableActiveRun({\n        userId: params.userId,\n        projectId: params.projectId,\n        workflowType,\n        targetType: params.targetType,\n        targetId: params.targetId,\n      })\n    : null\n\n  if (runCentricTask && reusableRun?.taskId) {\n    const existingTask = await getTaskById(reusableRun.taskId)\n    if (\n      existingTask\n      && (existingTask.status === TASK_STATUS.QUEUED || existingTask.status === TASK_STATUS.PROCESSING)\n    ) {\n      return {\n        success: true,\n        async: true,\n        taskId: existingTask.id,\n        runId: reusableRun.id,\n        status: existingTask.status,\n        deduped: true as const,\n      }\n    }\n  }\n\n  const { task, deduped } = await createTask({\n    userId: params.userId,\n    projectId: params.projectId,\n    episodeId: params.episodeId || null,\n    type: params.type,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    payload: normalizedPayload,\n    dedupeKey: runCentricTask ? null : (params.dedupeKey || null),\n    priority: params.priority,\n    maxAttempts: params.maxAttempts,\n    billingInfo: resolvedBillingInfo || null,\n  })\n  let runId = reusableRun?.id || resolveRunIdFromPayload(task.payload)\n  if (!deduped && reusableRun && runId) {\n    const payloadWithRunId = {\n      ...normalizedPayload,\n      runId,\n      meta: {\n        ...toObject(normalizedPayload.meta),\n        runId,\n      },\n    }\n    await updateTaskPayload(task.id, payloadWithRunId)\n    await attachTaskToRun(runId, task.id)\n  } else if (!deduped && isAiTaskType(params.type) && !runId) {\n    const run = await createRun({\n      userId: params.userId,\n      projectId: params.projectId,\n      episodeId: params.episodeId || null,\n      workflowType,\n      taskType: params.type,\n      taskId: task.id,\n      targetType: params.targetType,\n      targetId: params.targetId,\n      input: normalizedPayload,\n    })\n    runId = run.id\n    const payloadWithRunId = {\n      ...normalizedPayload,\n      runId,\n      meta: {\n        ...toObject(normalizedPayload.meta),\n        runId,\n      },\n    }\n    await updateTaskPayload(task.id, payloadWithRunId)\n    await attachTaskToRun(run.id, task.id)\n  }\n\n  let preparedBillingInfo = (task.billingInfo || resolvedBillingInfo || null) as TaskBillingInfo | null\n  if (!deduped && isBillableTaskType(params.type) && preparedBillingInfo?.billable !== true) {\n    const billingMode = await getBillingMode()\n    if (billingMode === 'ENFORCE') {\n      await markTaskFailed(task.id, 'INVALID_PARAMS', `missing server-generated billingInfo for billable task type: ${params.type}`)\n      throw new ApiError('INVALID_PARAMS', {\n        message: `missing server-generated billingInfo for billable task type: ${params.type}`,\n      })\n    }\n    logger.warn({\n      action: 'task.submit.billing_info_missing_non_enforce',\n      message: `missing billingInfo ignored in ${billingMode} mode`,\n      taskId: task.id,\n      details: {\n        type: params.type,\n        billingMode,\n      },\n    })\n  }\n\n  if (!deduped && preparedBillingInfo) {\n    try {\n      preparedBillingInfo = (await prepareTaskBilling({\n        id: task.id,\n        userId: params.userId,\n        projectId: params.projectId,\n        billingInfo: preparedBillingInfo,\n      })) as TaskBillingInfo | null\n      if (preparedBillingInfo) {\n        await updateTaskBillingInfo(task.id, preparedBillingInfo)\n      }\n    } catch (error) {\n      if (error instanceof InsufficientBalanceError) {\n        await markTaskFailed(task.id, 'INSUFFICIENT_BALANCE', error.message)\n        throw new ApiError('INSUFFICIENT_BALANCE', {\n          message: error.message,\n          required: error.required,\n          available: error.available,\n        })\n      }\n      await markTaskFailed(task.id, 'INTERNAL_ERROR', error instanceof Error ? error.message : String(error))\n      throw error\n    }\n  }\n\n  if (!deduped) {\n    const payloadForEvent = runId\n      ? {\n          ...normalizedPayload,\n          runId,\n          meta: {\n            ...toObject(normalizedPayload.meta),\n            runId,\n          },\n        }\n      : normalizedPayload\n    await publishTaskEvent({\n      taskId: task.id,\n      projectId: params.projectId,\n      userId: params.userId,\n      type: TASK_EVENT_TYPE.CREATED,\n      taskType: params.type,\n      targetType: params.targetType,\n      targetId: params.targetId,\n      episodeId: params.episodeId || null,\n      payload: {\n        ...payloadForEvent,\n        billing: preparedBillingInfo || null,\n        trace: {\n          requestId: params.requestId || null,\n        },\n      },\n    })\n  }\n  logger.info({\n    action: 'task.submit.created',\n    message: 'task created',\n    taskId: task.id,\n    details: {\n      type: params.type,\n      targetType: params.targetType,\n      targetId: params.targetId,\n    },\n  })\n\n  if (!deduped) {\n    try {\n      await addTaskJob({\n        taskId: task.id,\n        type: params.type,\n        locale: params.locale,\n        projectId: params.projectId,\n        episodeId: params.episodeId || null,\n        targetType: params.targetType,\n        targetId: params.targetId,\n        payload: runId\n          ? {\n              ...normalizedPayload,\n              runId,\n              meta: {\n                ...toObject(normalizedPayload.meta),\n                runId,\n              },\n            }\n          : normalizedPayload,\n        billingInfo: preparedBillingInfo || null,\n        userId: params.userId,\n        trace: {\n          requestId: params.requestId || null,\n        },\n      }, {\n        priority: typeof task.priority === 'number' ? task.priority : 0,\n      })\n      await markTaskEnqueued(task.id)\n      logger.info({\n        action: 'task.submit.enqueued',\n        message: 'task enqueued',\n        taskId: task.id,\n      })\n    } catch (error: unknown) {\n      const message = error instanceof Error ? error.message : String(error)\n      await markTaskEnqueueFailed(task.id, message || 'queue.add failed')\n      const rollbackResult = await rollbackTaskBillingForTask({\n        taskId: task.id,\n        billingInfo: preparedBillingInfo,\n      })\n      const compensationFailed = rollbackResult.attempted && !rollbackResult.rolledBack\n      const failedCode = compensationFailed ? 'BILLING_COMPENSATION_FAILED' : 'ENQUEUE_FAILED'\n      const failedMessage = compensationFailed\n        ? `${message || 'queue add failed'}; billing rollback failed`\n        : (message || 'queue add failed')\n      await markTaskFailed(task.id, failedCode, failedMessage)\n      await publishTaskEvent({\n        taskId: task.id,\n        projectId: params.projectId,\n        userId: params.userId,\n        type: TASK_EVENT_TYPE.FAILED,\n        taskType: params.type,\n        targetType: params.targetType,\n        targetId: params.targetId,\n        episodeId: params.episodeId || null,\n        payload: {\n          stage: 'enqueue_failed',\n          stageLabel: 'progress.stage.enqueueFailed',\n          message: failedMessage,\n          compensationFailed,\n          errorCode: failedCode,\n        },\n        persist: false,\n      })\n      logger.error({\n        action: 'task.submit.enqueue_failed',\n        message: failedMessage,\n        taskId: task.id,\n        errorCode: compensationFailed ? 'INTERNAL_ERROR' : 'EXTERNAL_ERROR',\n        retryable: false,\n        details: {\n          compensationFailed,\n        },\n        error:\n          error instanceof Error\n            ? {\n                name: error.name,\n                message: error.message,\n                stack: error.stack,\n              }\n              : {\n                message: String(error),\n              },\n      })\n      throw new ApiError(compensationFailed ? 'INTERNAL_ERROR' : 'EXTERNAL_ERROR', {\n        message: failedMessage,\n        taskId: task.id,\n      })\n    }\n  }\n\n  return {\n    success: true,\n    async: true,\n    taskId: task.id,\n    runId,\n    status: task.status,\n    deduped,\n  }\n}\n"
  },
  {
    "path": "src/lib/task/types.ts",
    "content": "import type { Locale } from '@/i18n/routing'\n\nexport const TASK_STATUS = {\n  QUEUED: 'queued',\n  PROCESSING: 'processing',\n  COMPLETED: 'completed',\n  FAILED: 'failed',\n  CANCELED: 'canceled',\n  DISMISSED: 'dismissed',\n} as const\n\nexport type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]\n\nexport const TASK_EVENT_TYPE = {\n  CREATED: 'task.created',\n  PROCESSING: 'task.processing',\n  PROGRESS: 'task.progress',\n  COMPLETED: 'task.completed',\n  FAILED: 'task.failed',\n} as const\n\nexport type TaskEventType = (typeof TASK_EVENT_TYPE)[keyof typeof TASK_EVENT_TYPE]\n\nexport const TASK_SSE_EVENT_TYPE = {\n  LIFECYCLE: 'task.lifecycle',\n  STREAM: 'task.stream',\n} as const\n\nexport type TaskSSEEventType = (typeof TASK_SSE_EVENT_TYPE)[keyof typeof TASK_SSE_EVENT_TYPE]\n\nexport const TASK_LIFECYCLE_EVENT_TYPES = [\n  TASK_EVENT_TYPE.CREATED,\n  TASK_EVENT_TYPE.PROCESSING,\n  TASK_EVENT_TYPE.COMPLETED,\n  TASK_EVENT_TYPE.FAILED,\n] as const\n\nexport type TaskLifecycleEventType = (typeof TASK_LIFECYCLE_EVENT_TYPES)[number]\n\nexport const TASK_TYPE = {\n  IMAGE_PANEL: 'image_panel',\n  IMAGE_CHARACTER: 'image_character',\n  IMAGE_LOCATION: 'image_location',\n  VIDEO_PANEL: 'video_panel',\n  LIP_SYNC: 'lip_sync',\n  VOICE_LINE: 'voice_line',\n  VOICE_DESIGN: 'voice_design',\n  ASSET_HUB_VOICE_DESIGN: 'asset_hub_voice_design',\n  REGENERATE_STORYBOARD_TEXT: 'regenerate_storyboard_text',\n  INSERT_PANEL: 'insert_panel',\n  PANEL_VARIANT: 'panel_variant',\n  MODIFY_ASSET_IMAGE: 'modify_asset_image',\n  REGENERATE_GROUP: 'regenerate_group',\n  ASSET_HUB_IMAGE: 'asset_hub_image',\n  ASSET_HUB_MODIFY: 'asset_hub_modify',\n  ANALYZE_NOVEL: 'analyze_novel',\n  STORY_TO_SCRIPT_RUN: 'story_to_script_run',\n  SCRIPT_TO_STORYBOARD_RUN: 'script_to_storyboard_run',\n  CLIPS_BUILD: 'clips_build',\n  SCREENPLAY_CONVERT: 'screenplay_convert',\n  VOICE_ANALYZE: 'voice_analyze',\n  ANALYZE_GLOBAL: 'analyze_global',\n  AI_MODIFY_APPEARANCE: 'ai_modify_appearance',\n  AI_MODIFY_LOCATION: 'ai_modify_location',\n  AI_MODIFY_SHOT_PROMPT: 'ai_modify_shot_prompt',\n  ANALYZE_SHOT_VARIANTS: 'analyze_shot_variants',\n  AI_CREATE_CHARACTER: 'ai_create_character',\n  AI_CREATE_LOCATION: 'ai_create_location',\n  REFERENCE_TO_CHARACTER: 'reference_to_character',\n  CHARACTER_PROFILE_CONFIRM: 'character_profile_confirm',\n  CHARACTER_PROFILE_BATCH_CONFIRM: 'character_profile_batch_confirm',\n  EPISODE_SPLIT_LLM: 'episode_split_llm',\n  ASSET_HUB_AI_DESIGN_CHARACTER: 'asset_hub_ai_design_character',\n  ASSET_HUB_AI_DESIGN_LOCATION: 'asset_hub_ai_design_location',\n  ASSET_HUB_AI_MODIFY_CHARACTER: 'asset_hub_ai_modify_character',\n  ASSET_HUB_AI_MODIFY_LOCATION: 'asset_hub_ai_modify_location',\n  ASSET_HUB_REFERENCE_TO_CHARACTER: 'asset_hub_reference_to_character',\n} as const\n\nexport type TaskType = (typeof TASK_TYPE)[keyof typeof TASK_TYPE]\n\nexport type QueueType = 'image' | 'video' | 'voice' | 'text'\n\nexport type BillingMode = 'OFF' | 'SHADOW' | 'ENFORCE'\n\nexport type TaskBillingInfo =\n  | {\n    billable: false\n    source?: 'task'\n    status?: 'skipped'\n  }\n  | {\n    billable: true\n    source: 'task'\n    taskType: TaskType\n    apiType: 'text' | 'image' | 'video' | 'voice' | 'voice-design' | 'lip-sync'\n    model: string\n    quantity: number\n    unit: 'token' | 'image' | 'video' | 'second' | 'call'\n    maxFrozenCost: number\n    pricingVersion?: string\n    action: string\n    metadata?: Record<string, unknown>\n    billingKey?: string\n    freezeId?: string | null\n    modeSnapshot?: BillingMode | null\n    status?: 'skipped' | 'quoted' | 'frozen' | 'settled' | 'rolled_back' | 'failed'\n    chargedCost?: number\n  }\n\nexport type TaskJobData = {\n  taskId: string\n  type: TaskType\n  locale: Locale\n  projectId: string\n  episodeId?: string | null\n  targetType: string\n  targetId: string\n  payload?: Record<string, unknown> | null\n  billingInfo?: TaskBillingInfo | null\n  userId: string\n  trace?: {\n    requestId?: string | null\n  } | null\n}\n\nexport type SSEEvent = {\n  id: string\n  type: TaskSSEEventType\n  taskId: string\n  projectId: string\n  userId: string\n  ts: string\n  taskType?: string | null\n  targetType?: string | null\n  targetId?: string | null\n  episodeId?: string | null\n  payload?: (Record<string, unknown> & {\n    lifecycleType?: TaskLifecycleEventType\n  }) | null\n}\n\nexport type CreateTaskInput = {\n  userId: string\n  projectId: string\n  episodeId?: string | null\n  type: TaskType\n  targetType: string\n  targetId: string\n  payload?: Record<string, unknown> | null\n  dedupeKey?: string | null\n  priority?: number\n  maxAttempts?: number\n  billingInfo?: TaskBillingInfo | null\n}\n"
  },
  {
    "path": "src/lib/task/ui-payload.ts",
    "content": "function asObject(value: unknown): Record<string, unknown> | null {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n  return value as Record<string, unknown>\n}\n\nexport function withTaskUiPayload(\n  payload: unknown,\n  patch: Record<string, unknown>,\n): Record<string, unknown> {\n  const base = asObject(payload) || {}\n  const baseUi = asObject(base.ui) || {}\n  return {\n    ...base,\n    ui: {\n      ...baseUi,\n      ...patch,\n    },\n  }\n}\n"
  },
  {
    "path": "src/lib/update-check.ts",
    "content": "export interface ParsedSemver {\n  major: number\n  minor: number\n  patch: number\n}\n\nexport interface GithubRelease {\n  tagName: string\n  htmlUrl: string\n  name: string | null\n  publishedAt: string | null\n}\n\nexport type UpdateCheckResult =\n  | { kind: 'no-release' }\n  | { kind: 'no-update'; latestVersion: string; release: GithubRelease }\n  | { kind: 'update-available'; latestVersion: string; release: GithubRelease }\n  | {\n      kind: 'error'\n      reason: 'network' | 'http' | 'invalid-response' | 'invalid-version'\n      message: string\n      status?: number\n    }\n\nconst SEMVER_PATTERN = /^v?(\\d+)\\.(\\d+)\\.(\\d+)(?:[-+].*)?$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null\n}\n\nexport function normalizeSemverTag(value: string): string {\n  const trimmed = value.trim()\n  const match = trimmed.match(SEMVER_PATTERN)\n  if (!match) {\n    throw new Error(`Invalid semver tag: ${value}`)\n  }\n\n  const major = Number.parseInt(match[1] ?? '', 10)\n  const minor = Number.parseInt(match[2] ?? '', 10)\n  const patch = Number.parseInt(match[3] ?? '', 10)\n\n  if ([major, minor, patch].some((segment) => Number.isNaN(segment))) {\n    throw new Error(`Invalid semver tag: ${value}`)\n  }\n\n  return `${major}.${minor}.${patch}`\n}\n\nexport function parseSemver(value: string): ParsedSemver {\n  const normalized = normalizeSemverTag(value)\n  const [majorText, minorText, patchText] = normalized.split('.')\n\n  return {\n    major: Number.parseInt(majorText ?? '', 10),\n    minor: Number.parseInt(minorText ?? '', 10),\n    patch: Number.parseInt(patchText ?? '', 10),\n  }\n}\n\nexport function compareSemver(left: string, right: string): number {\n  const a = parseSemver(left)\n  const b = parseSemver(right)\n\n  if (a.major !== b.major) return a.major > b.major ? 1 : -1\n  if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1\n  if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1\n  return 0\n}\n\nexport function shouldPulseUpdate(latestVersion: string, mutedVersion: string | null): boolean {\n  return latestVersion !== mutedVersion\n}\n\nfunction parseGithubReleasePayload(payload: unknown): GithubRelease {\n  if (!isRecord(payload)) {\n    throw new Error('GitHub release payload must be an object')\n  }\n\n  const tagName = payload.tag_name\n  const htmlUrl = payload.html_url\n  const name = payload.name\n  const publishedAt = payload.published_at\n\n  if (typeof tagName !== 'string' || tagName.trim().length === 0) {\n    throw new Error('GitHub release payload missing tag_name')\n  }\n\n  if (typeof htmlUrl !== 'string' || htmlUrl.trim().length === 0) {\n    throw new Error('GitHub release payload missing html_url')\n  }\n\n  if (name !== null && typeof name !== 'string' && typeof name !== 'undefined') {\n    throw new Error('GitHub release payload has invalid name')\n  }\n\n  if (publishedAt !== null && typeof publishedAt !== 'string' && typeof publishedAt !== 'undefined') {\n    throw new Error('GitHub release payload has invalid published_at')\n  }\n\n  return {\n    tagName: tagName.trim(),\n    htmlUrl: htmlUrl.trim(),\n    name: typeof name === 'string' ? name : null,\n    publishedAt: typeof publishedAt === 'string' ? publishedAt : null,\n  }\n}\n\nexport interface CheckGithubReleaseUpdateInput {\n  repository: string\n  currentVersion: string\n  signal?: AbortSignal\n  fetchImpl?: typeof fetch\n}\n\nexport async function checkGithubReleaseUpdate({\n  repository,\n  currentVersion,\n  signal,\n  fetchImpl,\n}: CheckGithubReleaseUpdateInput): Promise<UpdateCheckResult> {\n  const fetcher = fetchImpl ?? fetch\n\n  try {\n    normalizeSemverTag(currentVersion)\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Invalid current version'\n    return {\n      kind: 'error',\n      reason: 'invalid-version',\n      message,\n    }\n  }\n\n  const endpoint = `https://api.github.com/repos/${repository}/releases/latest`\n\n  let response: Response\n  try {\n    response = await fetcher(endpoint, {\n      method: 'GET',\n      headers: {\n        Accept: 'application/vnd.github+json',\n      },\n      signal,\n      cache: 'no-store',\n    })\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'GitHub request failed'\n    return {\n      kind: 'error',\n      reason: 'network',\n      message,\n    }\n  }\n\n  if (response.status === 404) {\n    return { kind: 'no-release' }\n  }\n\n  if (!response.ok) {\n    return {\n      kind: 'error',\n      reason: 'http',\n      message: `GitHub request failed with status ${response.status}`,\n      status: response.status,\n    }\n  }\n\n  let payload: unknown\n  try {\n    payload = await response.json()\n  } catch {\n    return {\n      kind: 'error',\n      reason: 'invalid-response',\n      message: 'GitHub release response is not valid JSON',\n    }\n  }\n\n  let release: GithubRelease\n  try {\n    release = parseGithubReleasePayload(payload)\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'GitHub release payload is invalid'\n    return {\n      kind: 'error',\n      reason: 'invalid-response',\n      message,\n    }\n  }\n\n  let latestVersion = ''\n  try {\n    latestVersion = normalizeSemverTag(release.tagName)\n  } catch (error) {\n    const message = error instanceof Error ? error.message : 'Release tag is not valid semver'\n    return {\n      kind: 'error',\n      reason: 'invalid-version',\n      message,\n    }\n  }\n\n  return compareSemver(latestVersion, currentVersion) > 0\n    ? { kind: 'update-available', latestVersion, release }\n    : { kind: 'no-update', latestVersion, release }\n}\n"
  },
  {
    "path": "src/lib/user-api/llm-test-connection.ts",
    "content": "import OpenAI from 'openai'\nimport { ApiError } from '@/lib/api-errors'\n\ntype SupportedProvider =\n  | 'openrouter'\n  | 'google'\n  | 'anthropic'\n  | 'openai'\n  | 'bailian'\n  | 'siliconflow'\n  | 'openai-compatible'\n  | 'gemini-compatible'\n  | 'custom'\n\ntype TestConnectionPayload = {\n  provider?: string\n  apiKey?: string\n  baseUrl?: string\n  region?: string\n  model?: string\n}\n\nexport type LlmConnectionTestResult = {\n  provider: SupportedProvider\n  message: string\n  model?: string\n  answer?: string\n}\n\nfunction normalizeProvider(payload: TestConnectionPayload): SupportedProvider {\n  const provider = typeof payload.provider === 'string' ? payload.provider.trim().toLowerCase() : ''\n  if (!provider) {\n    if (typeof payload.baseUrl === 'string' && payload.baseUrl.trim()) return 'custom'\n    throw new ApiError('INVALID_PARAMS', { message: '缺少必要参数 provider' })\n  }\n\n  switch (provider) {\n    case 'openrouter':\n    case 'google':\n    case 'anthropic':\n    case 'openai':\n    case 'openai-compatible':\n    case 'gemini-compatible':\n    case 'bailian':\n    case 'siliconflow':\n    case 'custom':\n      return provider\n    default:\n      throw new ApiError('INVALID_PARAMS', { message: `不支持的渠道: ${provider}` })\n  }\n}\n\nfunction requireApiKey(payload: TestConnectionPayload): string {\n  const apiKey = typeof payload.apiKey === 'string' ? payload.apiKey.trim() : ''\n  if (!apiKey) {\n    throw new ApiError('INVALID_PARAMS', { message: '缺少必要参数 apiKey' })\n  }\n  return apiKey\n}\n\nfunction requireBaseUrl(payload: TestConnectionPayload): string {\n  const baseUrl = typeof payload.baseUrl === 'string' ? payload.baseUrl.trim() : ''\n  if (!baseUrl) {\n    throw new ApiError('INVALID_PARAMS', { message: '自定义渠道需要提供 baseUrl' })\n  }\n  return baseUrl\n}\n\nasync function testGoogleAI(apiKey: string): Promise<void> {\n  const response = await fetch(\n    `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`,\n    { method: 'GET' },\n  )\n  if (!response.ok) {\n    const error = await response.text()\n    throw new Error(`Google AI 认证失败: ${error}`)\n  }\n}\n\nasync function testOpenAICompatibleConnection(params: {\n  apiKey: string\n  baseURL?: string\n  model?: string\n  defaultHeaders?: Record<string, string>\n}): Promise<Pick<LlmConnectionTestResult, 'model' | 'answer'>> {\n  const client = new OpenAI({\n    apiKey: params.apiKey,\n    baseURL: params.baseURL,\n    timeout: 30000,\n    defaultHeaders: params.defaultHeaders,\n  })\n\n  if (params.model) {\n    const response = await client.chat.completions.create({\n      model: params.model,\n      messages: [{ role: 'user', content: '1+1等于几？只回答数字' }],\n      max_tokens: 10,\n      temperature: 0,\n    })\n    const answer = response.choices[0]?.message?.content?.trim() || ''\n    return {\n      model: response.model || params.model,\n      answer,\n    }\n  }\n\n  await client.models.list()\n  return {}\n}\n\nasync function testBailianProbe(apiKey: string): Promise<{ model?: string }> {\n  const response = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/models', {\n    method: 'GET',\n    headers: { Authorization: `Bearer ${apiKey}` },\n  })\n  if (!response.ok) {\n    const error = await response.text()\n    throw new Error(`Bailian probe failed (${response.status}): ${error}`)\n  }\n  const data = await response.json() as { data?: Array<{ id?: string }> }\n  const firstModel = Array.isArray(data.data) ? data.data.find((item) => typeof item.id === 'string')?.id : undefined\n  return { model: firstModel }\n}\n\nasync function testSiliconFlowProbe(apiKey: string): Promise<{ model?: string; answer?: string }> {\n  const modelsResponse = await fetch('https://api.siliconflow.cn/v1/models', {\n    method: 'GET',\n    headers: { Authorization: `Bearer ${apiKey}` },\n  })\n  if (!modelsResponse.ok) {\n    const error = await modelsResponse.text()\n    throw new Error(`SiliconFlow models probe failed (${modelsResponse.status}): ${error}`)\n  }\n\n  const modelData = await modelsResponse.json() as { data?: Array<{ id?: string }> }\n  const firstModel = Array.isArray(modelData.data) ? modelData.data.find((item) => typeof item.id === 'string')?.id : undefined\n\n  const userInfoResponse = await fetch('https://api.siliconflow.cn/v1/user/info', {\n    method: 'GET',\n    headers: { Authorization: `Bearer ${apiKey}` },\n  })\n  if (!userInfoResponse.ok) {\n    const error = await userInfoResponse.text()\n    throw new Error(`SiliconFlow user info probe failed (${userInfoResponse.status}): ${error}`)\n  }\n  const info = await userInfoResponse.json() as { balance?: unknown; data?: { balance?: unknown } }\n  const rawBalance = info.balance ?? info.data?.balance\n  const balance = typeof rawBalance === 'number'\n    ? String(rawBalance)\n    : typeof rawBalance === 'string' && rawBalance.trim()\n      ? rawBalance.trim()\n      : undefined\n\n  return {\n    model: firstModel,\n    answer: typeof balance === 'string' ? `balance=${balance}` : 'userinfo_ok',\n  }\n}\n\nexport async function testLlmConnection(payload: TestConnectionPayload): Promise<LlmConnectionTestResult> {\n  const provider = normalizeProvider(payload)\n  const apiKey = requireApiKey(payload)\n  const requestedModel = typeof payload.model === 'string' ? payload.model.trim() : ''\n\n  switch (provider) {\n    case 'openrouter': {\n      const tested = await testOpenAICompatibleConnection({\n        apiKey,\n        baseURL: 'https://openrouter.ai/api/v1',\n        model: requestedModel || undefined,\n      })\n      return { provider, message: 'openrouter 连接成功', ...tested }\n    }\n    case 'google':\n      await testGoogleAI(apiKey)\n      return { provider, message: 'google 连接成功' }\n    case 'anthropic': {\n      const tested = await testOpenAICompatibleConnection({\n        apiKey,\n        baseURL: 'https://api.anthropic.com/v1',\n        model: requestedModel || 'claude-3-haiku-20240307',\n        defaultHeaders: { 'anthropic-version': '2023-06-01' },\n      })\n      return { provider, message: 'anthropic 连接成功', ...tested }\n    }\n    case 'openai': {\n      const tested = await testOpenAICompatibleConnection({\n        apiKey,\n        model: requestedModel || undefined,\n      })\n      return { provider, message: 'openai 连接成功', ...tested }\n    }\n    case 'bailian': {\n      const tested = await testBailianProbe(apiKey)\n      return { provider, message: 'bailian 连接成功', ...tested }\n    }\n    case 'siliconflow': {\n      const tested = await testSiliconFlowProbe(apiKey)\n      return { provider, message: 'siliconflow 连接成功', ...tested }\n    }\n    case 'openai-compatible': {\n      const tested = await testOpenAICompatibleConnection({\n        apiKey,\n        baseURL: requireBaseUrl(payload),\n        model: requestedModel || undefined,\n      })\n      return { provider, message: 'openai-compatible 连接成功', ...tested }\n    }\n    case 'gemini-compatible': {\n      const tested = await testOpenAICompatibleConnection({\n        apiKey,\n        baseURL: requireBaseUrl(payload),\n        model: requestedModel || undefined,\n      })\n      return { provider, message: 'gemini-compatible 连接成功', ...tested }\n    }\n    case 'custom': {\n      const tested = await testOpenAICompatibleConnection({\n        apiKey,\n        baseURL: requireBaseUrl(payload),\n        model: requestedModel || undefined,\n      })\n      return { provider, message: 'custom 连接成功', ...tested }\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/user-api/model-llm-protocol-probe.ts",
    "content": "import { getProviderKey } from '@/lib/api-config'\nimport { resolveOpenAICompatClientConfig } from '@/lib/model-gateway/openai-compat/common'\n\nconst PROBE_TIMEOUT_MS = 15_000\n\ntype ProbeEndpoint = 'responses' | 'chat-completions'\ntype ProbeOutcome =\n  | 'supported'\n  | 'unsupported'\n  | 'auth_fail'\n  | 'rate_limited'\n  | 'provider_error'\n  | 'network_error'\n  | 'timeout'\n  | 'inconclusive'\n\nexport interface ModelLlmProtocolProbeTrace {\n  endpoint: ProbeEndpoint\n  url: string\n  outcome: ProbeOutcome\n  status?: number\n  note: string\n  bodySnippet?: string\n}\n\nexport interface ModelLlmProtocolProbeInput {\n  userId: string\n  providerId: string\n  modelId: string\n}\n\nexport interface ModelLlmProtocolProbeSuccess {\n  success: true\n  protocol: 'responses' | 'chat-completions'\n  checkedAt: string\n  traces: ModelLlmProtocolProbeTrace[]\n}\n\nexport interface ModelLlmProtocolProbeFailure {\n  success: false\n  code: 'PROBE_INCONCLUSIVE' | 'PROBE_AUTH_FAILED'\n  message: string\n  traces: ModelLlmProtocolProbeTrace[]\n}\n\nexport type ModelLlmProtocolProbeResult =\n  | ModelLlmProtocolProbeSuccess\n  | ModelLlmProtocolProbeFailure\n\ntype EndpointProbeResult = {\n  outcome: ProbeOutcome\n  status?: number\n  bodySnippet?: string\n  note: string\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction trimString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction toEndpoint(baseUrl: string, path: string): string {\n  return `${baseUrl.replace(/\\/+$/, '')}/${path.replace(/^\\/+/, '')}`\n}\n\nfunction toBodySnippet(bodyText: string): string | undefined {\n  const trimmed = bodyText.trim()\n  if (!trimmed) return undefined\n  return trimmed.slice(0, 500)\n}\n\nfunction inferKeywordUnsupported(bodyText: string): boolean {\n  const lower = bodyText.toLowerCase()\n  return (\n    lower.includes('unsupported')\n    || lower.includes('not found')\n    || lower.includes('unknown endpoint')\n    || lower.includes('endpoint not found')\n    || lower.includes('not implemented')\n    || lower.includes('no such endpoint')\n    || lower.includes('unrecognized request url')\n    || lower.includes('不支持')\n    || lower.includes('未找到')\n    || lower.includes('未知端点')\n  )\n}\n\nfunction isAbortLikeError(error: unknown): boolean {\n  if (!isRecord(error)) return false\n  const name = trimString(error.name)\n  const message = trimString(error.message).toLowerCase()\n  if (name === 'AbortError' || name === 'TimeoutError') return true\n  return message.includes('aborted') || message.includes('timeout')\n}\n\nasync function probeEndpoint(params: {\n  endpoint: ProbeEndpoint\n  url: string\n  apiKey: string\n  body: Record<string, unknown>\n}): Promise<EndpointProbeResult> {\n  try {\n    const response = await fetch(params.url, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        Authorization: `Bearer ${params.apiKey}`,\n      },\n      body: JSON.stringify(params.body),\n      signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),\n    })\n\n    const bodyText = await response.text().catch(() => '')\n    const bodySnippet = toBodySnippet(bodyText)\n\n    if (response.ok) {\n      return {\n        outcome: 'supported',\n        status: response.status,\n        bodySnippet,\n        note: `${params.endpoint} probe succeeded`,\n      }\n    }\n\n    if (response.status === 401 || response.status === 403) {\n      return {\n        outcome: 'auth_fail',\n        status: response.status,\n        bodySnippet,\n        note: `${params.endpoint} authentication failed`,\n      }\n    }\n\n    if (response.status === 404 || response.status === 405 || response.status === 501) {\n      return {\n        outcome: 'unsupported',\n        status: response.status,\n        bodySnippet,\n        note: `${params.endpoint} endpoint unsupported`,\n      }\n    }\n\n    if (response.status === 429) {\n      return {\n        outcome: 'rate_limited',\n        status: response.status,\n        bodySnippet,\n        note: `${params.endpoint} rate limited`,\n      }\n    }\n\n    if (response.status >= 500 && response.status < 600) {\n      if (inferKeywordUnsupported(bodyText)) {\n        return {\n          outcome: 'unsupported',\n          status: response.status,\n          bodySnippet,\n          note: `${params.endpoint} provider returned not-implemented style error`,\n        }\n      }\n      return {\n        outcome: 'provider_error',\n        status: response.status,\n        bodySnippet,\n        note: `${params.endpoint} provider error`,\n      }\n    }\n\n    if (response.status === 400 || response.status === 422) {\n      const unsupported = inferKeywordUnsupported(bodyText)\n      return {\n        outcome: unsupported ? 'unsupported' : 'inconclusive',\n        status: response.status,\n        bodySnippet,\n        note: unsupported\n          ? `${params.endpoint} request indicates unsupported endpoint`\n          : `${params.endpoint} request rejected without unsupported keywords`,\n      }\n    }\n\n    return {\n      outcome: 'inconclusive',\n      status: response.status,\n      bodySnippet,\n      note: `${params.endpoint} returned inconclusive status`,\n    }\n  } catch (error) {\n    if (isAbortLikeError(error)) {\n      return {\n        outcome: 'timeout',\n        note: `${params.endpoint} probe timeout`,\n      }\n    }\n\n    return {\n      outcome: 'network_error',\n      note: `${params.endpoint} probe network error: ${trimString((error as Error).message) || 'unknown'}`,\n    }\n  }\n}\n\nfunction toTrace(\n  endpoint: ProbeEndpoint,\n  url: string,\n  result: EndpointProbeResult,\n): ModelLlmProtocolProbeTrace {\n  return {\n    endpoint,\n    url,\n    outcome: result.outcome,\n    ...(typeof result.status === 'number' ? { status: result.status } : {}),\n    note: result.note,\n    ...(result.bodySnippet ? { bodySnippet: result.bodySnippet } : {}),\n  }\n}\n\nexport async function probeModelLlmProtocol(\n  input: ModelLlmProtocolProbeInput,\n): Promise<ModelLlmProtocolProbeResult> {\n  if (getProviderKey(input.providerId) !== 'openai-compatible') {\n    throw new Error(`MODEL_LLM_PROTOCOL_PROBE_PROVIDER_UNSUPPORTED: ${input.providerId}`)\n  }\n\n  const modelId = trimString(input.modelId)\n  if (!modelId) {\n    throw new Error('MODEL_LLM_PROTOCOL_PROBE_MODEL_ID_REQUIRED')\n  }\n\n  const clientConfig = await resolveOpenAICompatClientConfig(input.userId, input.providerId)\n  const responsesUrl = toEndpoint(clientConfig.baseUrl, '/responses')\n  const chatCompletionsUrl = toEndpoint(clientConfig.baseUrl, '/chat/completions')\n\n  const traces: ModelLlmProtocolProbeTrace[] = []\n  const checkedAt = new Date().toISOString()\n\n  const responsesResult = await probeEndpoint({\n    endpoint: 'responses',\n    url: responsesUrl,\n    apiKey: clientConfig.apiKey,\n    body: {\n      model: modelId,\n      input: [{\n        role: 'user',\n        content: [{ type: 'input_text', text: 'ping' }],\n      }],\n      max_output_tokens: 8,\n      temperature: 0,\n    },\n  })\n  traces.push(toTrace('responses', responsesUrl, responsesResult))\n\n  if (responsesResult.outcome === 'supported') {\n    return {\n      success: true,\n      protocol: 'responses',\n      checkedAt,\n      traces,\n    }\n  }\n\n  const chatResult = await probeEndpoint({\n    endpoint: 'chat-completions',\n    url: chatCompletionsUrl,\n    apiKey: clientConfig.apiKey,\n    body: {\n      model: modelId,\n      messages: [{ role: 'user', content: 'ping' }],\n      max_tokens: 8,\n      temperature: 0,\n    },\n  })\n  traces.push(toTrace('chat-completions', chatCompletionsUrl, chatResult))\n\n  if (chatResult.outcome === 'supported') {\n    return {\n      success: true,\n      protocol: 'chat-completions',\n      checkedAt,\n      traces,\n    }\n  }\n\n  if (responsesResult.outcome === 'auth_fail' && chatResult.outcome === 'auth_fail') {\n    return {\n      success: false,\n      code: 'PROBE_AUTH_FAILED',\n      message: 'responses/chat authentication failed',\n      traces,\n    }\n  }\n\n  return {\n    success: false,\n    code: 'PROBE_INCONCLUSIVE',\n    message: 'model llm protocol probe inconclusive',\n    traces,\n  }\n}\n"
  },
  {
    "path": "src/lib/user-api/model-template/index.ts",
    "content": "export type { ModelTemplateValidationIssue } from './schema'\nexport { parseOpenAICompatMediaTemplate } from './schema'\nexport { validateOpenAICompatMediaTemplate } from './validator'\nexport { saveModelTemplateConfiguration } from './save'\nexport { probeMediaTemplate } from './probe'\nexport type { MediaTemplateProbeResult, MediaTemplateProbeTrace } from './probe'\n"
  },
  {
    "path": "src/lib/user-api/model-template/probe.ts",
    "content": "import type { OpenAICompatMediaTemplate } from '@/lib/openai-compat-media-template'\nimport { resolveOpenAICompatClientConfig } from '@/lib/model-gateway/openai-compat/common'\nimport {\n  buildRenderedTemplateRequest,\n  buildTemplateVariables,\n  extractTemplateError,\n  normalizeResponseJson,\n  readJsonPath,\n} from '@/lib/openai-compat-template-runtime'\n\nexport interface MediaTemplateProbeTrace {\n  endpoint: 'create' | 'status'\n  url: string\n  method: string\n  status?: number\n  note: string\n  bodySnippet?: string\n}\n\nexport type MediaTemplateProbeResult =\n  | {\n    success: true\n    verified: true\n    checkedAt: string\n    traces: MediaTemplateProbeTrace[]\n  }\n  | {\n    success: false\n    verified: false\n    code: 'MODEL_TEMPLATE_PROBE_FAILED'\n    checkedAt: string\n    traces: MediaTemplateProbeTrace[]\n    message: string\n  }\n\nfunction toSnippet(raw: string): string | undefined {\n  const trimmed = raw.trim()\n  if (!trimmed) return undefined\n  return trimmed.slice(0, 500)\n}\n\nexport async function probeMediaTemplate(input: {\n  userId: string\n  providerId: string\n  modelId: string\n  template: OpenAICompatMediaTemplate\n  samplePrompt?: string\n  sampleImage?: string\n}): Promise<MediaTemplateProbeResult> {\n  const config = await resolveOpenAICompatClientConfig(input.userId, input.providerId)\n  const checkedAt = new Date().toISOString()\n  const traces: MediaTemplateProbeTrace[] = []\n\n  const variables = buildTemplateVariables({\n    model: input.modelId,\n    prompt: input.samplePrompt || 'probe',\n    image: input.sampleImage || '',\n  })\n\n  const createRequest = await buildRenderedTemplateRequest({\n    baseUrl: config.baseUrl,\n    endpoint: input.template.create,\n    variables,\n    defaultAuthHeader: `Bearer ${config.apiKey}`,\n  })\n\n  const createResponse = await fetch(createRequest.endpointUrl, {\n    method: createRequest.method,\n    headers: createRequest.headers,\n    ...(createRequest.body ? { body: createRequest.body } : {}),\n  })\n  const createRawText = await createResponse.text().catch(() => '')\n  const createPayload = normalizeResponseJson(createRawText)\n\n  traces.push({\n    endpoint: 'create',\n    url: createRequest.endpointUrl,\n    method: createRequest.method,\n    status: createResponse.status,\n    note: createResponse.ok ? 'create succeeded' : 'create failed',\n    ...(toSnippet(createRawText) ? { bodySnippet: toSnippet(createRawText) } : {}),\n  })\n\n  if (!createResponse.ok) {\n    return {\n      success: false,\n      verified: false,\n      code: 'MODEL_TEMPLATE_PROBE_FAILED',\n      checkedAt,\n      traces,\n      message: extractTemplateError(input.template, createPayload, createResponse.status),\n    }\n  }\n\n  if (input.template.mode === 'sync') {\n    const outputUrl = readJsonPath(createPayload, input.template.response.outputUrlPath)\n    const outputUrls = readJsonPath(createPayload, input.template.response.outputUrlsPath)\n    const hasSingle = typeof outputUrl === 'string' && outputUrl.trim().length > 0\n    const hasArray = Array.isArray(outputUrls) && outputUrls.length > 0\n    if (!hasSingle && !hasArray) {\n      return {\n        success: false,\n        verified: false,\n        code: 'MODEL_TEMPLATE_PROBE_FAILED',\n        checkedAt,\n        traces,\n        message: 'sync template probe failed: output url not found',\n      }\n    }\n    return {\n      success: true,\n      verified: true,\n      checkedAt,\n      traces,\n    }\n  }\n\n  const taskIdRaw = readJsonPath(createPayload, input.template.response.taskIdPath)\n  const taskId = typeof taskIdRaw === 'string' ? taskIdRaw.trim() : ''\n  if (!taskId || !input.template.status) {\n    return {\n      success: false,\n      verified: false,\n      code: 'MODEL_TEMPLATE_PROBE_FAILED',\n      checkedAt,\n      traces,\n      message: 'async template probe failed: task_id or status endpoint missing',\n    }\n  }\n\n  const statusVariables = buildTemplateVariables({\n    model: input.modelId,\n    prompt: input.samplePrompt || 'probe',\n    taskId,\n  })\n  const statusRequest = await buildRenderedTemplateRequest({\n    baseUrl: config.baseUrl,\n    endpoint: input.template.status,\n    variables: statusVariables,\n    defaultAuthHeader: `Bearer ${config.apiKey}`,\n  })\n\n  const statusResponse = await fetch(statusRequest.endpointUrl, {\n    method: statusRequest.method,\n    headers: statusRequest.headers,\n  })\n  const statusRawText = await statusResponse.text().catch(() => '')\n  traces.push({\n    endpoint: 'status',\n    url: statusRequest.endpointUrl,\n    method: statusRequest.method,\n    status: statusResponse.status,\n    note: statusResponse.ok ? 'status succeeded' : 'status failed',\n    ...(toSnippet(statusRawText) ? { bodySnippet: toSnippet(statusRawText) } : {}),\n  })\n\n  if (!statusResponse.ok) {\n    return {\n      success: false,\n      verified: false,\n      code: 'MODEL_TEMPLATE_PROBE_FAILED',\n      checkedAt,\n      traces,\n      message: `status probe failed: ${statusResponse.status}`,\n    }\n  }\n\n  return {\n    success: true,\n    verified: true,\n    checkedAt,\n    traces,\n  }\n}\n"
  },
  {
    "path": "src/lib/user-api/model-template/save.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { composeModelKey } from '@/lib/model-config-contract'\nimport { getProviderKey } from '@/lib/api-config'\nimport type { OpenAICompatMediaTemplate } from '@/lib/openai-compat-media-template'\n\ntype StoredModelType = 'llm' | 'image' | 'video' | 'audio' | 'lipsync'\n\ntype StoredModelRecord = Record<string, unknown> & {\n  modelId: string\n  modelKey: string\n  name: string\n  type: StoredModelType\n  provider: string\n}\n\nexport interface SaveModelTemplateInput {\n  userId: string\n  providerId: string\n  modelId: string\n  name: string\n  type: 'image' | 'video'\n  template: OpenAICompatMediaTemplate\n  source: 'ai' | 'manual'\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction isStoredModelType(value: string): value is StoredModelType {\n  return value === 'llm' || value === 'image' || value === 'video' || value === 'audio' || value === 'lipsync'\n}\n\nfunction parseStoredModels(raw: string | null | undefined): StoredModelRecord[] {\n  if (!raw) return []\n\n  let parsed: unknown\n  try {\n    parsed = JSON.parse(raw) as unknown\n  } catch {\n    throw new Error('MODEL_TEMPLATE_SAVE_CONFLICT: customModels payload is invalid JSON')\n  }\n  if (!Array.isArray(parsed)) {\n    throw new Error('MODEL_TEMPLATE_SAVE_CONFLICT: customModels payload is invalid')\n  }\n\n  const result: StoredModelRecord[] = []\n  for (let index = 0; index < parsed.length; index += 1) {\n    const item = parsed[index]\n    if (!isRecord(item)) continue\n\n    const provider = readTrimmedString(item.provider)\n    const modelId = readTrimmedString(item.modelId)\n    const modelKey = readTrimmedString(item.modelKey) || composeModelKey(provider, modelId)\n    const name = readTrimmedString(item.name) || modelId\n    const typeRaw = readTrimmedString(item.type)\n    if (!provider || !modelId || !modelKey || !name || !isStoredModelType(typeRaw)) continue\n\n    result.push({\n      ...item,\n      provider,\n      modelId,\n      modelKey,\n      name,\n      type: typeRaw,\n    })\n  }\n  return result\n}\n\nfunction hasProvider(rawProviders: string | null | undefined, providerId: string): boolean {\n  if (!rawProviders) return false\n  let parsed: unknown\n  try {\n    parsed = JSON.parse(rawProviders) as unknown\n  } catch {\n    return false\n  }\n  if (!Array.isArray(parsed)) return false\n  return parsed.some((item) => isRecord(item) && readTrimmedString(item.id) === providerId)\n}\n\nexport async function saveModelTemplateConfiguration(input: SaveModelTemplateInput): Promise<{\n  modelKey: string\n}> {\n  if (getProviderKey(input.providerId) !== 'openai-compatible') {\n    throw new Error('MODEL_TEMPLATE_SAVE_PROVIDER_INVALID')\n  }\n  if (input.template.mediaType !== input.type) {\n    throw new Error('MODEL_TEMPLATE_SAVE_MEDIATYPE_MISMATCH')\n  }\n\n  const modelId = input.modelId.trim()\n  const modelName = input.name.trim()\n  if (!modelId || !modelName) {\n    throw new Error('MODEL_TEMPLATE_SAVE_INVALID_MODEL')\n  }\n\n  const pref = await prisma.userPreference.findUnique({\n    where: { userId: input.userId },\n    select: {\n      customModels: true,\n      customProviders: true,\n    },\n  })\n\n  if (!hasProvider(pref?.customProviders, input.providerId)) {\n    throw new Error('MODEL_TEMPLATE_SAVE_PROVIDER_NOT_FOUND')\n  }\n\n  const modelKey = composeModelKey(input.providerId, modelId)\n  const models = parseStoredModels(pref?.customModels)\n  const checkedAt = new Date().toISOString()\n  const existingIndex = models.findIndex((model) => model.modelKey === modelKey)\n  if (existingIndex >= 0) {\n    const existing = models[existingIndex]\n    if (!existing || existing.provider !== input.providerId || existing.type !== input.type) {\n      throw new Error('MODEL_TEMPLATE_SAVE_CONFLICT')\n    }\n  }\n\n  const baseRecord: StoredModelRecord = existingIndex >= 0\n    ? models[existingIndex] as StoredModelRecord\n    : {\n      modelId,\n      modelKey,\n      name: modelName,\n      type: input.type,\n      provider: input.providerId,\n    }\n\n  const nextRecord: StoredModelRecord = {\n    ...baseRecord,\n    modelId,\n    modelKey,\n    name: modelName,\n    type: input.type,\n    provider: input.providerId,\n    compatMediaTemplate: input.template,\n    compatMediaTemplateCheckedAt: checkedAt,\n    compatMediaTemplateSource: input.source,\n    enabled: baseRecord.enabled === false ? false : true,\n  }\n\n  const nextModels = existingIndex >= 0\n    ? models.map((model, index) => (index === existingIndex ? nextRecord : model))\n    : [...models, nextRecord]\n\n  await prisma.userPreference.upsert({\n    where: { userId: input.userId },\n    create: {\n      userId: input.userId,\n      customModels: JSON.stringify(nextModels),\n      customProviders: pref?.customProviders || JSON.stringify([]),\n    },\n    update: {\n      customModels: JSON.stringify(nextModels),\n    },\n  })\n\n  return { modelKey }\n}\n"
  },
  {
    "path": "src/lib/user-api/model-template/schema.ts",
    "content": "import type {\n  OpenAICompatMediaTemplate,\n  TemplateBodyValue,\n  TemplateEndpoint,\n  TemplatePollingConfig,\n  TemplateResponseMap,\n} from '@/lib/openai-compat-media-template'\nimport { TEMPLATE_PLACEHOLDER_ALLOWLIST } from '@/lib/openai-compat-media-template'\n\ntype ValidationCode =\n  | 'MODEL_TEMPLATE_INVALID'\n  | 'MODEL_TEMPLATE_UNMAPPABLE'\n\nexport interface ModelTemplateValidationIssue {\n  code: ValidationCode\n  field: string\n  message: string\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction readTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction isFiniteInteger(value: unknown): value is number {\n  return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value)\n}\n\nfunction isTemplateHttpMethod(value: unknown): value is TemplateEndpoint['method'] {\n  return value === 'GET' || value === 'POST' || value === 'PUT' || value === 'PATCH' || value === 'DELETE'\n}\n\nfunction isTemplateContentType(value: unknown): value is NonNullable<TemplateEndpoint['contentType']> {\n  return value === 'application/json'\n    || value === 'multipart/form-data'\n    || value === 'application/x-www-form-urlencoded'\n}\n\nfunction isBodyValue(value: unknown): value is TemplateBodyValue {\n  if (value === null) return true\n  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return true\n  if (Array.isArray(value)) return value.every((item) => isBodyValue(item))\n  if (!isRecord(value)) return false\n  return Object.values(value).every((item) => isBodyValue(item))\n}\n\nfunction validatePlaceholdersInString(\n  value: string,\n  field: string,\n  issues: ModelTemplateValidationIssue[],\n) {\n  const regex = /\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}/g\n  let match = regex.exec(value)\n  while (match) {\n    const key = match[1] || ''\n    if (!TEMPLATE_PLACEHOLDER_ALLOWLIST.has(key)) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_INVALID',\n        field,\n        message: `Unsupported placeholder: ${key}`,\n      })\n    }\n    match = regex.exec(value)\n  }\n}\n\nfunction walkTemplateBody(\n  value: TemplateBodyValue,\n  field: string,\n  issues: ModelTemplateValidationIssue[],\n) {\n  if (typeof value === 'string') {\n    validatePlaceholdersInString(value, field, issues)\n    return\n  }\n  if (value === null || typeof value === 'number' || typeof value === 'boolean') return\n  if (Array.isArray(value)) {\n    for (let index = 0; index < value.length; index += 1) {\n      walkTemplateBody(value[index] as TemplateBodyValue, `${field}[${index}]`, issues)\n    }\n    return\n  }\n  for (const [key, nestedValue] of Object.entries(value)) {\n    walkTemplateBody(nestedValue, `${field}.${key}`, issues)\n  }\n}\n\nfunction readTemplateHeaders(\n  value: unknown,\n  field: string,\n  issues: ModelTemplateValidationIssue[],\n): Record<string, string> | undefined {\n  if (value === undefined || value === null) return undefined\n  if (!isRecord(value)) {\n    issues.push({ code: 'MODEL_TEMPLATE_INVALID', field, message: 'headers must be an object' })\n    return undefined\n  }\n\n  const headers: Record<string, string> = {}\n  for (const [key, headerValue] of Object.entries(value)) {\n    const trimmedKey = key.trim()\n    const trimmedValue = readTrimmedString(headerValue)\n    if (!trimmedKey || !trimmedValue) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_INVALID',\n        field: `${field}.${key}`,\n        message: 'header key/value must be non-empty string',\n      })\n      continue\n    }\n    headers[trimmedKey] = trimmedValue\n  }\n  return Object.keys(headers).length > 0 ? headers : undefined\n}\n\nfunction readTemplateEndpoint(\n  value: unknown,\n  field: string,\n  options: { allowBody: boolean },\n  issues: ModelTemplateValidationIssue[],\n): TemplateEndpoint | null {\n  if (!isRecord(value)) {\n    issues.push({ code: 'MODEL_TEMPLATE_INVALID', field, message: 'endpoint must be an object' })\n    return null\n  }\n\n  const methodRaw = value.method\n  if (!isTemplateHttpMethod(methodRaw)) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field: `${field}.method`,\n      message: 'method must be one of GET/POST/PUT/PATCH/DELETE',\n    })\n  }\n\n  const path = readTrimmedString(value.path)\n  if (!path) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field: `${field}.path`,\n      message: 'path is required',\n    })\n  }\n\n  const contentTypeRaw = value.contentType\n  if (contentTypeRaw !== undefined && !isTemplateContentType(contentTypeRaw)) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field: `${field}.contentType`,\n      message: 'unsupported contentType',\n    })\n  }\n\n  const headers = readTemplateHeaders(value.headers, `${field}.headers`, issues)\n\n  let bodyTemplate: TemplateBodyValue | undefined\n  if (value.bodyTemplate !== undefined) {\n    if (!options.allowBody) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_INVALID',\n        field: `${field}.bodyTemplate`,\n        message: 'bodyTemplate is not allowed for this endpoint',\n      })\n    } else if (!isBodyValue(value.bodyTemplate)) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_INVALID',\n        field: `${field}.bodyTemplate`,\n        message: 'bodyTemplate must be valid JSON value',\n      })\n    } else {\n      bodyTemplate = value.bodyTemplate\n      walkTemplateBody(bodyTemplate, `${field}.bodyTemplate`, issues)\n    }\n  }\n\n  const multipartFileFields = readOptionalStringArray(\n    value.multipartFileFields,\n    `${field}.multipartFileFields`,\n    issues,\n  )\n  if (multipartFileFields && contentTypeRaw !== 'multipart/form-data') {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field: `${field}.multipartFileFields`,\n      message: 'multipartFileFields requires contentType multipart/form-data',\n    })\n  }\n\n  if (!isTemplateHttpMethod(methodRaw) || !path) return null\n  return {\n    method: methodRaw,\n    path,\n    ...(isTemplateContentType(contentTypeRaw) ? { contentType: contentTypeRaw } : {}),\n    ...(headers ? { headers } : {}),\n    ...(bodyTemplate !== undefined ? { bodyTemplate } : {}),\n    ...(multipartFileFields ? { multipartFileFields } : {}),\n  }\n}\n\nfunction readResponseMap(\n  value: unknown,\n  field: string,\n  issues: ModelTemplateValidationIssue[],\n): TemplateResponseMap | null {\n  if (!isRecord(value)) {\n    issues.push({ code: 'MODEL_TEMPLATE_INVALID', field, message: 'response map must be an object' })\n    return null\n  }\n\n  const output: TemplateResponseMap = {}\n  const keys: Array<keyof TemplateResponseMap & string> = [\n    'taskIdPath',\n    'statusPath',\n    'outputUrlPath',\n    'outputUrlsPath',\n    'errorPath',\n  ]\n\n  for (const key of keys) {\n    const raw = value[key]\n    if (raw === undefined || raw === null) continue\n    const path = readTrimmedString(raw)\n    if (!path) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_INVALID',\n        field: `${field}.${key}`,\n        message: 'path must be non-empty string',\n      })\n      continue\n    }\n    if (!path.startsWith('$.')) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_INVALID',\n        field: `${field}.${key}`,\n        message: 'path must start with $.',\n      })\n      continue\n    }\n    output[key] = path\n  }\n\n  return output\n}\n\nfunction readStringArray(\n  value: unknown,\n  field: string,\n  issues: ModelTemplateValidationIssue[],\n): string[] | null {\n  if (!Array.isArray(value) || value.length === 0) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field,\n      message: 'must be non-empty array of strings',\n    })\n    return null\n  }\n  const result: string[] = []\n  for (let index = 0; index < value.length; index += 1) {\n    const item = readTrimmedString(value[index])\n    if (!item) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_INVALID',\n        field: `${field}[${index}]`,\n        message: 'must be non-empty string',\n      })\n      continue\n    }\n    result.push(item)\n  }\n  return result.length > 0 ? result : null\n}\n\nfunction readOptionalStringArray(\n  value: unknown,\n  field: string,\n  issues: ModelTemplateValidationIssue[],\n): string[] | undefined {\n  if (value === undefined || value === null) return undefined\n  if (!Array.isArray(value)) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field,\n      message: 'must be an array of strings',\n    })\n    return undefined\n  }\n\n  const result: string[] = []\n  for (let index = 0; index < value.length; index += 1) {\n    const item = readTrimmedString(value[index])\n    if (!item) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_INVALID',\n        field: `${field}[${index}]`,\n        message: 'must be non-empty string',\n      })\n      continue\n    }\n    result.push(item)\n  }\n  return result\n}\n\nfunction readPollingConfig(\n  value: unknown,\n  field: string,\n  issues: ModelTemplateValidationIssue[],\n): TemplatePollingConfig | null {\n  if (!isRecord(value)) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field,\n      message: 'polling config must be an object',\n    })\n    return null\n  }\n\n  const intervalMs = value.intervalMs\n  const timeoutMs = value.timeoutMs\n  const doneStates = readStringArray(value.doneStates, `${field}.doneStates`, issues)\n  const failStates = readStringArray(value.failStates, `${field}.failStates`, issues)\n\n  if (!isFiniteInteger(intervalMs) || intervalMs <= 0) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field: `${field}.intervalMs`,\n      message: 'intervalMs must be positive integer',\n    })\n  }\n\n  if (!isFiniteInteger(timeoutMs) || timeoutMs <= 0) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field: `${field}.timeoutMs`,\n      message: 'timeoutMs must be positive integer',\n    })\n  }\n\n  if (!isFiniteInteger(intervalMs) || !isFiniteInteger(timeoutMs) || !doneStates || !failStates) {\n    return null\n  }\n\n  return {\n    intervalMs,\n    timeoutMs,\n    doneStates,\n    failStates,\n  }\n}\n\nfunction validateModeSpecificRequirements(\n  template: OpenAICompatMediaTemplate,\n  issues: ModelTemplateValidationIssue[],\n) {\n  if (\n    (template.create.method === 'POST' || template.create.method === 'PUT' || template.create.method === 'PATCH')\n    && template.create.bodyTemplate === undefined\n  ) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_UNMAPPABLE',\n      field: 'create.bodyTemplate',\n      message: `${template.create.method} create endpoint requires bodyTemplate`,\n    })\n  }\n\n  if (template.create.contentType === 'multipart/form-data' && template.create.multipartFileFields) {\n    if (!isRecord(template.create.bodyTemplate)) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_UNMAPPABLE',\n        field: 'create.bodyTemplate',\n        message: 'multipart create endpoint requires object bodyTemplate',\n      })\n    } else {\n      for (const fieldPath of template.create.multipartFileFields) {\n        const [topLevelField] = fieldPath.split('.')\n        if (!topLevelField || !(topLevelField in template.create.bodyTemplate)) {\n          issues.push({\n            code: 'MODEL_TEMPLATE_UNMAPPABLE',\n            field: 'create.multipartFileFields',\n            message: `multipart file field not found in bodyTemplate: ${fieldPath}`,\n          })\n        }\n      }\n    }\n  }\n\n  if (template.mode === 'async') {\n    if (!template.status) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_UNMAPPABLE',\n        field: 'status',\n        message: 'async mode requires status endpoint',\n      })\n    }\n    if (!template.response.taskIdPath) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_UNMAPPABLE',\n        field: 'response.taskIdPath',\n        message: 'async mode requires response.taskIdPath',\n      })\n    }\n    if (!template.response.statusPath) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_UNMAPPABLE',\n        field: 'response.statusPath',\n        message: 'async mode requires response.statusPath',\n      })\n    }\n    if (!template.polling) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_UNMAPPABLE',\n        field: 'polling',\n        message: 'async mode requires polling config',\n      })\n    }\n    if (template.status && !/\\{\\{\\s*task_id\\s*\\}\\}/.test(template.status.path)) {\n      issues.push({\n        code: 'MODEL_TEMPLATE_UNMAPPABLE',\n        field: 'status.path',\n        message: 'async status endpoint path must include {{task_id}} placeholder',\n      })\n    }\n    return\n  }\n\n  if (!template.response.outputUrlPath && !template.response.outputUrlsPath) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_UNMAPPABLE',\n      field: 'response',\n      message: 'sync mode requires outputUrlPath or outputUrlsPath',\n    })\n  }\n}\n\n\nexport function parseOpenAICompatMediaTemplate(raw: unknown): {\n  template: OpenAICompatMediaTemplate | null\n  issues: ModelTemplateValidationIssue[]\n} {\n  const issues: ModelTemplateValidationIssue[] = []\n\n  if (!isRecord(raw)) {\n    return {\n      template: null,\n      issues: [{ code: 'MODEL_TEMPLATE_INVALID', field: 'template', message: 'template must be an object' }],\n    }\n  }\n\n  if (raw.version !== 1) {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field: 'version',\n      message: 'version must be 1',\n    })\n  }\n\n  const mediaType = raw.mediaType\n  if (mediaType !== 'image' && mediaType !== 'video') {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field: 'mediaType',\n      message: 'mediaType must be image or video',\n    })\n  }\n\n  const mode = raw.mode\n  if (mode !== 'sync' && mode !== 'async') {\n    issues.push({\n      code: 'MODEL_TEMPLATE_INVALID',\n      field: 'mode',\n      message: 'mode must be sync or async',\n    })\n  }\n\n  const create = readTemplateEndpoint(raw.create, 'create', { allowBody: true }, issues)\n  const status = raw.status === undefined\n    ? undefined\n    : readTemplateEndpoint(raw.status, 'status', { allowBody: false }, issues) || undefined\n  const content = raw.content === undefined\n    ? undefined\n    : readTemplateEndpoint(raw.content, 'content', { allowBody: false }, issues) || undefined\n  const response = raw.response === undefined\n    ? {}\n    : readResponseMap(raw.response, 'response', issues)\n  const polling = raw.polling === undefined\n    ? undefined\n    : readPollingConfig(raw.polling, 'polling', issues) || undefined\n\n  if (issues.length > 0 || !create || !response || (mode !== 'sync' && mode !== 'async') || (mediaType !== 'image' && mediaType !== 'video')) {\n    return { template: null, issues }\n  }\n\n  const normalizedTemplate: OpenAICompatMediaTemplate = {\n    version: 1,\n    mediaType,\n    mode,\n    create,\n    ...(status ? { status } : {}),\n    ...(content ? { content } : {}),\n    response,\n    ...(polling ? { polling } : {}),\n  }\n  validateModeSpecificRequirements(normalizedTemplate, issues)\n  if (issues.length > 0) {\n    return { template: null, issues }\n  }\n  return { template: normalizedTemplate, issues: [] }\n}\n"
  },
  {
    "path": "src/lib/user-api/model-template/validator.ts",
    "content": "import type { OpenAICompatMediaTemplate } from '@/lib/openai-compat-media-template'\nimport {\n  parseOpenAICompatMediaTemplate,\n  type ModelTemplateValidationIssue,\n} from './schema'\n\nfunction hasHttpProtocol(path: string): boolean {\n  return path.startsWith('http://') || path.startsWith('https://')\n}\n\nfunction isRelativePath(path: string): boolean {\n  return path.startsWith('/')\n}\n\nfunction validatePath(path: string, field: string): ModelTemplateValidationIssue | null {\n  const trimmed = path.trim()\n  if (!trimmed) {\n    return {\n      code: 'MODEL_TEMPLATE_INVALID',\n      field,\n      message: 'path must be non-empty',\n    }\n  }\n\n  if (!hasHttpProtocol(trimmed) && !isRelativePath(trimmed)) {\n    return {\n      code: 'MODEL_TEMPLATE_INVALID',\n      field,\n      message: 'path must be absolute URL or relative path',\n    }\n  }\n  return null\n}\n\nfunction validateEndpointPaths(template: OpenAICompatMediaTemplate): ModelTemplateValidationIssue[] {\n  const issues: ModelTemplateValidationIssue[] = []\n  const createPathIssue = validatePath(template.create.path, 'create.path')\n  if (createPathIssue) issues.push(createPathIssue)\n  if (template.status) {\n    const statusPathIssue = validatePath(template.status.path, 'status.path')\n    if (statusPathIssue) issues.push(statusPathIssue)\n  }\n  if (template.content) {\n    const contentPathIssue = validatePath(template.content.path, 'content.path')\n    if (contentPathIssue) issues.push(contentPathIssue)\n  }\n  return issues\n}\n\nexport function validateOpenAICompatMediaTemplate(raw: unknown): {\n  ok: boolean\n  template: OpenAICompatMediaTemplate | null\n  issues: ModelTemplateValidationIssue[]\n} {\n  const parsed = parseOpenAICompatMediaTemplate(raw)\n  if (!parsed.template) {\n    return { ok: false, template: null, issues: parsed.issues }\n  }\n  const endpointIssues = validateEndpointPaths(parsed.template)\n  if (endpointIssues.length > 0) {\n    return {\n      ok: false,\n      template: null,\n      issues: [...parsed.issues, ...endpointIssues],\n    }\n  }\n  return { ok: true, template: parsed.template, issues: [] }\n}\n\n"
  },
  {
    "path": "src/lib/user-api/provider-test.ts",
    "content": "import OpenAI from 'openai'\nimport { setProxy } from '../../../lib/prompts/proxy'\n\nexport type TestStepName = 'models' | 'textGen' | 'imageGen' | 'credits' | 'audioGen'\nexport type TestStepStatus = 'pass' | 'fail' | 'skip'\n\nexport interface TestStep {\n  name: TestStepName\n  status: TestStepStatus\n  message: string\n  model?: string\n  detail?: string\n}\n\nexport interface TestProviderResult {\n  success: boolean\n  steps: TestStep[]\n}\n\ntype PresetProviderType = 'ark' | 'google' | 'openrouter' | 'minimax' | 'fal' | 'vidu'\n  | 'bailian'\n  | 'siliconflow'\ntype CompatibleProviderType = 'openai-compatible' | 'gemini-compatible'\n\ntype TestProviderPayload = {\n  apiType: CompatibleProviderType | PresetProviderType\n  baseUrl?: string\n  apiKey: string\n  llmModel?: string\n}\n\nfunction classifyProbeFailure(status: number): { status: TestStepStatus; message: string } {\n  if (status === 401 || status === 403) {\n    return { status: 'fail', message: `Authentication failed (${status})` }\n  }\n  if (status === 429) {\n    return { status: 'fail', message: `Rate limited (${status})` }\n  }\n  return { status: 'fail', message: `Provider error (${status})` }\n}\n\nfunction toNetworkErrorMessage(error: unknown): string {\n  const raw = toErrorMessage(error)\n  return `Network error: ${raw}`\n}\n\n// ---------------------------------------------------------------------------\n// OpenAI-compatible\n// ---------------------------------------------------------------------------\n\ntype CompatibleProbeOutcome = 'pass' | 'unsupported' | 'auth_fail' | 'rate_limited' | 'provider_error' | 'network_fail'\n\ninterface CompatibleProbeResult {\n  step: TestStep\n  outcome: CompatibleProbeOutcome\n}\n\ninterface ProbeAttempt {\n  url: string\n  status?: number\n  note: string\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nfunction sanitizeBaseUrl(baseUrl: string): string {\n  return baseUrl.trim().replace(/\\/+$/, '')\n}\n\nfunction buildCompatibleProbeUrls(baseUrl: string, paths: string[]): string[] {\n  const normalizedBase = sanitizeBaseUrl(baseUrl)\n  const baseVariants = new Set<string>([normalizedBase])\n  if (normalizedBase.endsWith('/v1')) {\n    const rootBase = normalizedBase.slice(0, -3)\n    if (rootBase) baseVariants.add(rootBase)\n  } else {\n    baseVariants.add(`${normalizedBase}/v1`)\n  }\n\n  const urls = new Set<string>()\n  for (const baseVariant of baseVariants) {\n    for (const path of paths) {\n      urls.add(`${baseVariant}${path}`)\n    }\n  }\n  return Array.from(urls)\n}\n\nfunction toAttemptDetail(attempts: ProbeAttempt[]): string {\n  return attempts\n    .map((attempt) => {\n      const printableUrl = attempt.url.replace(/^https?:\\/\\/[^/]+/i, '')\n      const statusText = typeof attempt.status === 'number' ? ` ${attempt.status}` : ''\n      return `${printableUrl}${statusText} ${attempt.note}`.trim()\n    })\n    .join(' | ')\n    .slice(0, 500)\n}\n\nfunction parseModelCount(payload: unknown): number | null {\n  if (!isRecord(payload)) return null\n  const data = payload.data\n  if (Array.isArray(data)) return data.length\n  const models = payload.models\n  if (Array.isArray(models)) return models.length\n  const result = payload.result\n  if (Array.isArray(result)) return result.length\n  return null\n}\n\nfunction parseCreditsMessage(payload: unknown): string | null {\n  if (!isRecord(payload)) return null\n\n  const data = isRecord(payload.data) ? payload.data : null\n  const creditGrants = isRecord(payload.credit_grants) ? payload.credit_grants : null\n  const totalCredits = typeof data?.total_credits === 'number' ? data.total_credits : null\n  const totalUsage = typeof data?.total_usage === 'number' ? data.total_usage : null\n  if (typeof totalCredits === 'number' && typeof totalUsage === 'number') {\n    return `Balance: ${(totalCredits - totalUsage).toFixed(2)}`\n  }\n\n  const balanceCandidate = payload.balance ?? data?.balance ?? creditGrants?.total_available\n  if (typeof balanceCandidate === 'number' && Number.isFinite(balanceCandidate)) {\n    return `Balance: ${balanceCandidate}`\n  }\n  if (typeof balanceCandidate === 'string' && balanceCandidate.trim()) {\n    return `Balance: ${balanceCandidate.trim()}`\n  }\n\n  const remains = payload.remains\n  if (Array.isArray(remains)) {\n    const first = remains[0]\n    if (isRecord(first) && typeof first.credit_remain === 'number') {\n      return `Balance: ${first.credit_remain}`\n    }\n  }\n\n  return null\n}\n\nasync function runCompatibleGetProbe(params: {\n  stepName: 'models' | 'credits'\n  urls: string[]\n  apiKey: string\n  onSuccessMessage: (payload: unknown) => string\n  unsupportedMessage: string\n}): Promise<CompatibleProbeResult> {\n  const attempts: ProbeAttempt[] = []\n  const headers = { Authorization: `Bearer ${params.apiKey}` }\n  let providerFailure: { status: number; detail: string } | null = null\n\n  for (const url of params.urls) {\n    try {\n      const response = await fetch(url, {\n        method: 'GET',\n        headers,\n        signal: AbortSignal.timeout(15_000),\n      })\n      const bodyText = await response.text().catch(() => '')\n      attempts.push({\n        url,\n        status: response.status,\n        note: response.ok ? 'ok' : 'failed',\n      })\n\n      if (response.ok) {\n        let parsedBody: unknown = null\n        if (bodyText.trim()) {\n          try {\n            parsedBody = JSON.parse(bodyText) as unknown\n          } catch {\n            parsedBody = null\n          }\n        }\n        return {\n          outcome: 'pass',\n          step: {\n            name: params.stepName,\n            status: 'pass',\n            message: params.onSuccessMessage(parsedBody),\n            detail: attempts.length > 1 ? toAttemptDetail(attempts) : undefined,\n          },\n        }\n      }\n\n      if (response.status === 401 || response.status === 403) {\n        return {\n          outcome: 'auth_fail',\n          step: {\n            name: params.stepName,\n            status: 'fail',\n            message: `Authentication failed (${response.status})`,\n            detail: bodyText.slice(0, 500) || toAttemptDetail(attempts),\n          },\n        }\n      }\n\n      if (response.status === 429) {\n        return {\n          outcome: 'rate_limited',\n          step: {\n            name: params.stepName,\n            status: 'fail',\n            message: `Rate limited (${response.status})`,\n            detail: bodyText.slice(0, 500) || toAttemptDetail(attempts),\n          },\n        }\n      }\n\n      const unsupportedStatus = response.status === 404 || response.status === 405 || response.status === 501\n      if (!unsupportedStatus) {\n        providerFailure = {\n          status: response.status,\n          detail: bodyText.slice(0, 500),\n        }\n      }\n    } catch (error) {\n      attempts.push({ url, note: `network: ${toErrorMessage(error)}` })\n      return {\n        outcome: 'network_fail',\n        step: {\n          name: params.stepName,\n          status: 'fail',\n          message: toNetworkErrorMessage(error),\n          detail: toAttemptDetail(attempts),\n        },\n      }\n    }\n  }\n\n  if (providerFailure) {\n    return {\n      outcome: 'provider_error',\n      step: {\n        name: params.stepName,\n        status: 'fail',\n        message: `Provider error (${providerFailure.status})`,\n        detail: providerFailure.detail || toAttemptDetail(attempts),\n      },\n    }\n  }\n\n  return {\n    outcome: 'unsupported',\n    step: {\n      name: params.stepName,\n      status: 'skip',\n      message: params.unsupportedMessage,\n      detail: toAttemptDetail(attempts),\n    },\n  }\n}\n\nasync function runCompatibleLlmFallback(baseUrl: string, apiKey: string, llmModel: string): Promise<TestStep> {\n  try {\n    const client = new OpenAI({\n      apiKey,\n      baseURL: baseUrl,\n      timeout: 30_000,\n    })\n    const response = await client.chat.completions.create({\n      model: llmModel,\n      messages: [{ role: 'user', content: 'hi' }],\n      max_tokens: 20,\n      temperature: 0,\n    })\n    const answer = response.choices[0]?.message?.content?.trim() || ''\n    return {\n      name: 'textGen',\n      status: 'pass',\n      model: llmModel,\n      message: answer ? `Response: ${answer.slice(0, 80)}` : 'LLM fallback succeeded',\n    }\n  } catch (error) {\n    return {\n      name: 'textGen',\n      status: 'fail',\n      model: llmModel,\n      message: toErrorMessage(error),\n    }\n  }\n}\n\nasync function testCompatibleProvider(baseUrl: string, apiKey: string, llmModel?: string): Promise<TestProviderResult> {\n  const modelProbe = await runCompatibleGetProbe({\n    stepName: 'models',\n    urls: buildCompatibleProbeUrls(baseUrl, ['/models']),\n    apiKey,\n    onSuccessMessage: (payload) => {\n      const count = parseModelCount(payload)\n      return typeof count === 'number' ? `Found ${count} models` : 'Models endpoint reachable'\n    },\n    unsupportedMessage: 'Model list endpoint not supported by this compatible provider',\n  })\n\n  const creditProbe = await runCompatibleGetProbe({\n    stepName: 'credits',\n    urls: buildCompatibleProbeUrls(baseUrl, ['/credits', '/user/info', '/dashboard/billing/credit_grants']),\n    apiKey,\n    onSuccessMessage: (payload) => parseCreditsMessage(payload) || 'Credits endpoint reachable',\n    unsupportedMessage: 'Credits endpoint not supported by this compatible provider',\n  })\n\n  const steps: TestStep[] = [modelProbe.step, creditProbe.step]\n  const hasPassStep = modelProbe.outcome === 'pass' || creditProbe.outcome === 'pass'\n  if (hasPassStep) {\n    return { success: true, steps }\n  }\n\n  const allUnsupported = modelProbe.outcome === 'unsupported' && creditProbe.outcome === 'unsupported'\n  if (!allUnsupported) {\n    return { success: false, steps }\n  }\n\n  const fallbackModel = typeof llmModel === 'string' ? llmModel.trim() : ''\n  if (!fallbackModel) {\n    steps.push({\n      name: 'textGen',\n      status: 'fail',\n      message: 'No free probe endpoint detected. Please configure an LLM model first, then retry / 未发现可用的免费探测接口，请先配置 LLM 模型后再测试',\n    })\n    return { success: false, steps }\n  }\n\n  const llmStep = await runCompatibleLlmFallback(baseUrl, apiKey, fallbackModel)\n  steps.push(llmStep)\n  return {\n    success: llmStep.status === 'pass',\n    steps,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Types for Gemini response\n// ---------------------------------------------------------------------------\n\ninterface GeminiInlineData {\n  mimeType?: string\n  mime_type?: string\n  data: string\n}\n\ninterface GeminiPart {\n  text?: string\n  inlineData?: GeminiInlineData\n  inline_data?: GeminiInlineData\n}\n\ninterface GeminiGenerateContentResponse {\n  candidates?: Array<{\n    content?: {\n      parts?: GeminiPart[]\n    }\n  }>\n  error?: {\n    message?: string\n    code?: number\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Volcengine Ark\n// ---------------------------------------------------------------------------\n\nasync function testArkProvider(apiKey: string): Promise<TestProviderResult> {\n  const steps: TestStep[] = []\n  // 和 src/lib/ark-llm.ts 的 arkResponsesCompletion 保持一致，使用字节原生 Responses API\n  const model = 'doubao-seed-2-0-lite-260215'\n\n  try {\n    const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/responses', {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        Authorization: `Bearer ${apiKey}`,\n      },\n      body: JSON.stringify({\n        model,\n        input: [\n          {\n            role: 'user',\n            content: [{ type: 'input_text', text: '你好' }],\n          },\n        ],\n      }),\n      signal: AbortSignal.timeout(30_000),\n    })\n\n    if (!response.ok) {\n      const errorText = await response.text().catch(() => '')\n      steps.push({\n        name: 'textGen',\n        status: 'fail',\n        model,\n        message: `HTTP ${response.status}`,\n        detail: errorText.slice(0, 500),\n      })\n      return { success: false, steps }\n    }\n\n    const data = await response.json() as Record<string, unknown>\n    // 和 ark-llm.ts 一样提取 output_text\n    const outputText = typeof data.output_text === 'string'\n      ? data.output_text\n      : ''\n    const text = outputText.trim().slice(0, 80) || 'OK'\n    steps.push({\n      name: 'textGen',\n      status: 'pass',\n      model,\n      message: `Response: ${text}`,\n    })\n    return { success: true, steps }\n  } catch (error) {\n    steps.push({\n      name: 'textGen',\n      status: 'fail',\n      model,\n      message: toErrorMessage(error),\n    })\n    return { success: false, steps }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Google AI Studio (official)\n// ---------------------------------------------------------------------------\n\nasync function testGoogleOfficial(apiKey: string): Promise<TestProviderResult> {\n  await setProxy()\n  console.log('[provider-test] testGoogleOfficial')\n  const steps: TestStep[] = []\n  const model = 'gemini-3-flash-preview'\n\n  try {\n    const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`\n    const response = await fetch(url, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'x-goog-api-key': apiKey,\n      },\n      body: JSON.stringify({\n        contents: [{ parts: [{ text: '你好' }] }],\n      }),\n      signal: AbortSignal.timeout(30_000),\n    })\n\n    if (!response.ok) {\n      const errorText = await response.text().catch(() => '')\n      steps.push({\n        name: 'textGen',\n        status: 'fail',\n        model,\n        message: `HTTP ${response.status}`,\n        detail: errorText.slice(0, 500),\n      })\n      return { success: false, steps }\n    }\n\n    const data = await response.json() as GeminiGenerateContentResponse\n    if (data.error) {\n      steps.push({\n        name: 'textGen',\n        status: 'fail',\n        model,\n        message: data.error.message || 'API error',\n        detail: JSON.stringify(data.error).slice(0, 500),\n      })\n      return { success: false, steps }\n    }\n\n    const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''\n    steps.push({\n      name: 'textGen',\n      status: 'pass',\n      model,\n      message: text ? `Response: ${text.slice(0, 80)}` : 'OK',\n    })\n    return { success: true, steps }\n  } catch (error) {\n    steps.push({\n      name: 'textGen',\n      status: 'fail',\n      model,\n      message: toErrorMessage(error),\n    })\n    return { success: false, steps }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// OpenRouter\n// ---------------------------------------------------------------------------\n\nasync function testOpenRouterProvider(apiKey: string): Promise<TestProviderResult> {\n  const steps: TestStep[] = []\n  await setProxy()\n\n  try {\n    const response = await fetch('https://openrouter.ai/api/v1/credits', {\n      headers: { Authorization: `Bearer ${apiKey}` },\n      signal: AbortSignal.timeout(15_000),\n    })\n\n    if (!response.ok) {\n      const errorText = await response.text().catch(() => '')\n      steps.push({\n        name: 'credits',\n        status: 'fail',\n        message: `HTTP ${response.status}`,\n        detail: errorText.slice(0, 500),\n      })\n      return { success: false, steps }\n    }\n\n    const data = await response.json() as { data?: { total_credits?: number; total_usage?: number } }\n    const credits = data.data?.total_credits\n    const usage = data.data?.total_usage\n    const remaining = typeof credits === 'number' && typeof usage === 'number'\n      ? (credits - usage).toFixed(2)\n      : undefined\n\n    steps.push({\n      name: 'credits',\n      status: 'pass',\n      message: remaining !== undefined ? `Balance: $${remaining}` : 'OK',\n    })\n    return { success: true, steps }\n  } catch (error) {\n    steps.push({\n      name: 'credits',\n      status: 'fail',\n      message: toErrorMessage(error),\n    })\n    return { success: false, steps }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// MiniMax\n// ---------------------------------------------------------------------------\n\nasync function testMiniMaxProvider(apiKey: string): Promise<TestProviderResult> {\n  const steps: TestStep[] = []\n  const model = 'MiniMax-M2.5'\n\n  try {\n    const client = new OpenAI({\n      apiKey,\n      baseURL: 'https://api.minimaxi.com/v1',\n      timeout: 30_000,\n    })\n    const response = await client.chat.completions.create({\n      model,\n      messages: [{ role: 'user', content: 'hi' }],\n      max_tokens: 20,\n      temperature: 0,\n    })\n    const answer = response.choices[0]?.message?.content?.trim() || ''\n    steps.push({\n      name: 'textGen',\n      status: 'pass',\n      model,\n      message: answer ? `Response: ${answer.slice(0, 80)}` : 'OK',\n    })\n    return { success: true, steps }\n  } catch (error) {\n    steps.push({\n      name: 'textGen',\n      status: 'fail',\n      model,\n      message: toErrorMessage(error),\n    })\n    return { success: false, steps }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// FAL.ai\n// ---------------------------------------------------------------------------\n\nasync function testFalProvider(apiKey: string): Promise<TestProviderResult> {\n  await setProxy()\n  console.log('[provider-test] testFalProvider')\n  const steps: TestStep[] = []\n\n  // 🔥 使用免费的 GET /v1/models 端点验证 API Key，不消耗实际资源\n  try {\n    const response = await fetch('https://api.fal.ai/v1/models?limit=1', {\n      headers: {\n        'Authorization': `Key ${apiKey}`,\n      },\n      signal: AbortSignal.timeout(15_000),\n    })\n\n    if (!response.ok) {\n      const errorText = await response.text().catch(() => '')\n      steps.push({\n        name: 'models',\n        status: 'fail',\n        message: `HTTP ${response.status}`,\n        detail: errorText.slice(0, 500),\n      })\n      return { success: false, steps }\n    }\n\n    const data = await response.json() as { models?: Array<{ endpoint_id?: string }> }\n    const modelCount = data.models?.length ?? 0\n    steps.push({\n      name: 'models',\n      status: 'pass',\n      message: `API Key valid (${modelCount} models returned)`,\n    })\n    return { success: true, steps }\n  } catch (error) {\n    steps.push({\n      name: 'models',\n      status: 'fail',\n      message: toErrorMessage(error),\n    })\n    return { success: false, steps }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Vidu (生数科技)\n// ---------------------------------------------------------------------------\n\nasync function testViduProvider(apiKey: string): Promise<TestProviderResult> {\n  console.log('[provider-test] testViduProvider')\n  const steps: TestStep[] = []\n\n  // 🔥 使用免费的 GET /ent/v2/credits 积分查询端点，不消耗任何资源\n  try {\n    const response = await fetch('https://api.vidu.cn/ent/v2/credits', {\n      headers: {\n        'Authorization': `Token ${apiKey}`,\n      },\n      signal: AbortSignal.timeout(15_000),\n    })\n\n    if (!response.ok) {\n      const errorText = await response.text().catch(() => '')\n      steps.push({\n        name: 'credits',\n        status: 'fail',\n        message: response.status === 403\n          ? 'Authentication failed — check API Key'\n          : `HTTP ${response.status}`,\n        detail: errorText.slice(0, 500) || undefined,\n      })\n      return { success: false, steps }\n    }\n\n    const data = await response.json() as {\n      remains?: Array<{ type?: string; credit_remain?: number }>\n    }\n    const creditRemain = data.remains?.[0]?.credit_remain\n    const balanceText = typeof creditRemain === 'number'\n      ? `Balance: ${creditRemain} credits`\n      : 'OK'\n\n    steps.push({\n      name: 'credits',\n      status: 'pass',\n      message: balanceText,\n    })\n    return { success: true, steps }\n  } catch (error) {\n    steps.push({\n      name: 'credits',\n      status: 'fail',\n      message: toErrorMessage(error),\n    })\n    return { success: false, steps }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// SiliconFlow (zero-inference probes)\n// ---------------------------------------------------------------------------\n\nasync function testSiliconFlowProvider(apiKey: string): Promise<TestProviderResult> {\n  const steps: TestStep[] = []\n  const headers = { Authorization: `Bearer ${apiKey}` }\n\n  try {\n    const modelResponse = await fetch('https://api.siliconflow.cn/v1/models', {\n      method: 'GET',\n      headers,\n      signal: AbortSignal.timeout(20_000),\n    })\n    if (!modelResponse.ok) {\n      const fail = classifyProbeFailure(modelResponse.status)\n      const detail = await modelResponse.text().catch(() => '')\n      steps.push({\n        name: 'models',\n        status: fail.status,\n        message: fail.message,\n        detail: detail.slice(0, 500),\n      })\n      steps.push({\n        name: 'credits',\n        status: 'skip',\n        message: 'Skipped because model probe failed',\n      })\n      return { success: false, steps }\n    }\n    const modelData = await modelResponse.json() as { data?: Array<{ id?: string }> }\n    const count = Array.isArray(modelData.data) ? modelData.data.length : 0\n    steps.push({\n      name: 'models',\n      status: 'pass',\n      message: `Found ${count} models`,\n    })\n  } catch (error) {\n    steps.push({\n      name: 'models',\n      status: 'fail',\n      message: toNetworkErrorMessage(error),\n    })\n    steps.push({\n      name: 'credits',\n      status: 'skip',\n      message: 'Skipped because model probe failed',\n    })\n    return { success: false, steps }\n  }\n\n  try {\n    const infoResponse = await fetch('https://api.siliconflow.cn/v1/user/info', {\n      method: 'GET',\n      headers,\n      signal: AbortSignal.timeout(20_000),\n    })\n    if (!infoResponse.ok) {\n      const fail = classifyProbeFailure(infoResponse.status)\n      const detail = await infoResponse.text().catch(() => '')\n      steps.push({\n        name: 'credits',\n        status: fail.status,\n        message: fail.message,\n        detail: detail.slice(0, 500),\n      })\n      return { success: false, steps }\n    }\n    const infoData = await infoResponse.json() as { balance?: unknown; data?: { balance?: unknown } }\n    const rawBalance = infoData.balance ?? infoData.data?.balance\n    const balance = typeof rawBalance === 'number'\n      ? String(rawBalance)\n      : typeof rawBalance === 'string' && rawBalance.trim()\n        ? rawBalance.trim()\n        : undefined\n    steps.push({\n      name: 'credits',\n      status: 'pass',\n      message: typeof balance === 'string' ? `Balance: ${balance}` : 'User info reachable',\n    })\n    return { success: true, steps }\n  } catch (error) {\n    steps.push({\n      name: 'credits',\n      status: 'fail',\n      message: toNetworkErrorMessage(error),\n    })\n    return { success: false, steps }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Bailian (zero-inference probes)\n// ---------------------------------------------------------------------------\n\nasync function testBailianProvider(apiKey: string): Promise<TestProviderResult> {\n  const steps: TestStep[] = []\n  const headers = { Authorization: `Bearer ${apiKey}` }\n\n  try {\n    const modelResponse = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/models', {\n      method: 'GET',\n      headers,\n      signal: AbortSignal.timeout(20_000),\n    })\n    if (!modelResponse.ok) {\n      const fail = classifyProbeFailure(modelResponse.status)\n      const detail = await modelResponse.text().catch(() => '')\n      steps.push({\n        name: 'models',\n        status: fail.status,\n        message: fail.message,\n        detail: detail.slice(0, 500),\n      })\n      steps.push({\n        name: 'credits',\n        status: 'skip',\n        message: 'Not supported by Bailian probe API',\n      })\n      return { success: false, steps }\n    }\n    const modelData = await modelResponse.json() as { data?: Array<{ id?: string }> }\n    const count = Array.isArray(modelData.data) ? modelData.data.length : 0\n    steps.push({\n      name: 'models',\n      status: 'pass',\n      message: `Found ${count} models`,\n    })\n    steps.push({\n      name: 'credits',\n      status: 'skip',\n      message: 'Not supported by Bailian probe API',\n    })\n    return { success: true, steps }\n  } catch (error) {\n    steps.push({\n      name: 'models',\n      status: 'fail',\n      message: toNetworkErrorMessage(error),\n    })\n    steps.push({\n      name: 'credits',\n      status: 'skip',\n      message: 'Not supported by Bailian probe API',\n    })\n    return { success: false, steps }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport async function testProviderConnection(payload: TestProviderPayload): Promise<TestProviderResult> {\n  const { apiType, baseUrl, apiKey, llmModel } = payload\n\n  if (!apiKey) {\n    return {\n      success: false,\n      steps: [{ name: 'models', status: 'fail', message: 'Missing apiKey' }],\n    }\n  }\n\n  // Compatible providers require baseUrl\n  if ((apiType === 'openai-compatible' || apiType === 'gemini-compatible') && !baseUrl) {\n    return {\n      success: false,\n      steps: [{ name: 'models', status: 'fail', message: 'Missing baseUrl' }],\n    }\n  }\n\n  switch (apiType) {\n    case 'openai-compatible':\n      return testCompatibleProvider(baseUrl!, apiKey, llmModel)\n    case 'gemini-compatible':\n      return testCompatibleProvider(baseUrl!, apiKey, llmModel)\n    case 'ark':\n      return testArkProvider(apiKey)\n    case 'google':\n      return testGoogleOfficial(apiKey)\n    case 'openrouter':\n      return testOpenRouterProvider(apiKey)\n    case 'minimax':\n      return testMiniMaxProvider(apiKey)\n    case 'fal':\n      return testFalProvider(apiKey)\n    case 'vidu':\n      return testViduProvider(apiKey)\n    case 'bailian':\n      return testBailianProvider(apiKey)\n    case 'siliconflow':\n      return testSiliconFlowProvider(apiKey)\n    default:\n      return {\n        success: false,\n        steps: [{ name: 'models', status: 'fail', message: `Unsupported API type: ${apiType}` }],\n      }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction toErrorMessage(error: unknown): string {\n  if (error instanceof Error) {\n    // Clean up OpenAI SDK errors\n    if (error.message.includes('fetch failed') || error.message.includes('ECONNREFUSED') || error.message.includes('ENOTFOUND'))\n      return 'Network error — check your internet connection / 网络连接失败，请检查网络后重试'\n    if (error.message.includes('Connection error')) return 'Network error — temporary connection failure, please retry / 网络抖动，请稍后重试'\n    if (error.message.includes('401')) return 'Authentication failed — check API Key'\n    if (error.message.includes('403')) return 'Access denied — check API Key permissions'\n    if (error.message.includes('timeout') || error.name === 'TimeoutError') return 'Request timed out'\n    return error.message.slice(0, 200)\n  }\n  return String(error).slice(0, 200)\n}\n"
  },
  {
    "path": "src/lib/voice/generate-voice-line.ts",
    "content": "import { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { fal } from '@fal-ai/client'\nimport { prisma } from '@/lib/prisma'\nimport { getAudioApiKey, getProviderConfig, getProviderKey, resolveModelSelectionOrSingle } from '@/lib/api-config'\nimport { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'\nimport { extractStorageKey, getSignedUrl, toFetchableUrl, uploadObject } from '@/lib/storage'\nimport { resolveStorageKeyFromMediaValue } from '@/lib/media/service'\nimport { synthesizeWithBailianTTS } from '@/lib/providers/bailian'\nimport {\n  parseSpeakerVoiceMap,\n  resolveVoiceBindingForProvider,\n  type CharacterVoiceFields,\n  type SpeakerVoiceMap,\n} from '@/lib/voice/provider-voice-binding'\n\ntype CheckCancelled = () => Promise<void>\ntype CharacterVoiceProfile = CharacterVoiceFields & { name: string }\n\nfunction normalizeBailianVoiceGenerationError(errorMessage: string | null | undefined) {\n  const message = typeof errorMessage === 'string' ? errorMessage.trim() : ''\n  if (!message) return 'BAILIAN_AUDIO_GENERATION_FAILED'\n\n  const normalized = message.toLowerCase()\n  if (\n    normalized.includes('bailian_tts_failed(400): invalidparameter') ||\n    normalized.includes('invalidparameter')\n  ) {\n    return '无效音色ID，QwenTTS 必须使用 AI 设计音色'\n  }\n\n  return message\n}\n\nfunction getWavDurationFromBuffer(buffer: Buffer): number {\n  try {\n    const riff = buffer.slice(0, 4).toString('ascii')\n    if (riff !== 'RIFF') {\n      return Math.round((buffer.length * 8) / 128)\n    }\n\n    const byteRate = buffer.readUInt32LE(28)\n    let offset = 12\n    let dataSize = 0\n\n    while (offset < buffer.length - 8) {\n      const chunkId = buffer.slice(offset, offset + 4).toString('ascii')\n      const chunkSize = buffer.readUInt32LE(offset + 4)\n\n      if (chunkId === 'data') {\n        dataSize = chunkSize\n        break\n      }\n\n      offset += 8 + chunkSize\n    }\n\n    if (dataSize > 0 && byteRate > 0) {\n      return Math.round((dataSize / byteRate) * 1000)\n    }\n\n    return Math.round((buffer.length * 8) / 128)\n  } catch {\n    return Math.round((buffer.length * 8) / 128)\n  }\n}\n\nasync function generateVoiceWithIndexTTS2(params: {\n  endpoint: string\n  referenceAudioUrl: string\n  text: string\n  emotionPrompt?: string | null\n  strength?: number\n  falApiKey?: string\n}) {\n  const strength = typeof params.strength === 'number' ? params.strength : 0.4\n\n  _ulogInfo(`IndexTTS2: Generating with reference audio, strength: ${strength}`)\n  if (params.emotionPrompt) {\n    _ulogInfo(`IndexTTS2: Using emotion prompt: ${params.emotionPrompt}`)\n  }\n\n  if (params.falApiKey) {\n    fal.config({ credentials: params.falApiKey })\n  }\n\n  const audioDataUrl = params.referenceAudioUrl.startsWith('data:')\n    ? params.referenceAudioUrl\n    : await normalizeToBase64ForGeneration(params.referenceAudioUrl)\n\n  const input: {\n    audio_url: string\n    prompt: string\n    should_use_prompt_for_emotion: boolean\n    strength: number\n    emotion_prompt?: string\n  } = {\n    audio_url: audioDataUrl,\n    prompt: params.text,\n    should_use_prompt_for_emotion: true,\n    strength,\n  }\n\n  if (params.emotionPrompt?.trim()) {\n    input.emotion_prompt = params.emotionPrompt.trim()\n  }\n\n  const result = await fal.subscribe(params.endpoint, {\n    input,\n    logs: false,\n  })\n\n  const audioUrl = (result as { data?: { audio?: { url?: string } } })?.data?.audio?.url\n  if (!audioUrl) {\n    throw new Error('No audio URL in response')\n  }\n\n  const audioData = await downloadAudioData(audioUrl)\n\n  return {\n    audioData,\n    audioDuration: getWavDurationFromBuffer(audioData),\n  }\n}\n\nfunction matchCharacterBySpeaker(\n  speaker: string,\n  characters: CharacterVoiceProfile[],\n) {\n  const exactMatch = characters.find((character) => character.name === speaker)\n  if (exactMatch) return exactMatch\n  return characters.find((character) => character.name.includes(speaker) || speaker.includes(character.name))\n}\n\nasync function resolveReferenceAudioUrl(referenceAudioUrl: string): Promise<string> {\n  if (referenceAudioUrl.startsWith('http') || referenceAudioUrl.startsWith('data:')) {\n    return referenceAudioUrl\n  }\n  if (referenceAudioUrl.startsWith('/m/')) {\n    const storageKey = await resolveStorageKeyFromMediaValue(referenceAudioUrl)\n    if (!storageKey) {\n      throw new Error(`无法解析参考音频路径: ${referenceAudioUrl}`)\n    }\n    return getSignedUrl(storageKey, 3600)\n  }\n  if (referenceAudioUrl.startsWith('/api/files/')) {\n    const storageKey = extractStorageKey(referenceAudioUrl)\n    return storageKey ? getSignedUrl(storageKey, 3600) : referenceAudioUrl\n  }\n  return getSignedUrl(referenceAudioUrl, 3600)\n}\n\nasync function downloadAudioData(audioUrl: string): Promise<Buffer> {\n  const response = await fetch(toFetchableUrl(audioUrl))\n  if (!response.ok) {\n    throw new Error(`Audio download failed: ${response.status}`)\n  }\n  return Buffer.from(await response.arrayBuffer())\n}\n\nexport async function generateVoiceLine(params: {\n  projectId: string\n  episodeId?: string | null\n  lineId: string\n  userId: string\n  audioModel?: string\n  checkCancelled?: CheckCancelled\n}) {\n  const checkCancelled = params.checkCancelled\n\n  const line = await prisma.novelPromotionVoiceLine.findUnique({\n    where: { id: params.lineId },\n    select: {\n      id: true,\n      episodeId: true,\n      speaker: true,\n      content: true,\n      emotionPrompt: true,\n      emotionStrength: true,\n    },\n  })\n  if (!line) {\n    throw new Error('Voice line not found')\n  }\n\n  const episodeId = params.episodeId || line.episodeId\n  if (!episodeId) {\n    throw new Error('episodeId is required')\n  }\n\n  const [projectData, episode] = await Promise.all([\n    prisma.novelPromotionProject.findUnique({\n      where: { projectId: params.projectId },\n      include: { characters: true },\n    }),\n    prisma.novelPromotionEpisode.findUnique({\n      where: { id: episodeId },\n      select: { speakerVoices: true },\n    }),\n  ])\n\n  if (!projectData) {\n    throw new Error('Novel promotion project not found')\n  }\n\n  const speakerVoices: SpeakerVoiceMap = parseSpeakerVoiceMap(episode?.speakerVoices)\n\n  const character = matchCharacterBySpeaker(line.speaker, projectData.characters || [])\n  const speakerVoice = speakerVoices[line.speaker]\n\n  const text = (line.content || '').trim()\n  if (!text) {\n    throw new Error('Voice line text is empty')\n  }\n\n  const audioSelection = await resolveModelSelectionOrSingle(params.userId, params.audioModel, 'audio')\n  const providerKey = getProviderKey(audioSelection.provider).toLowerCase()\n  const voiceBinding = resolveVoiceBindingForProvider({\n    providerKey,\n    character,\n    speakerVoice,\n  })\n  let generated: { audioData: Buffer; audioDuration: number }\n  if (providerKey === 'fal') {\n    if (!voiceBinding || voiceBinding.provider !== 'fal') {\n      throw new Error('请先为该发言人设置参考音频')\n    }\n\n    const fullAudioUrl = await resolveReferenceAudioUrl(voiceBinding.referenceAudioUrl)\n    const falApiKey = await getAudioApiKey(params.userId, audioSelection.modelKey)\n    generated = await generateVoiceWithIndexTTS2({\n      endpoint: audioSelection.modelId,\n      referenceAudioUrl: fullAudioUrl,\n      text,\n      emotionPrompt: line.emotionPrompt,\n      strength: line.emotionStrength ?? 0.4,\n      falApiKey,\n    })\n  } else if (providerKey === 'bailian') {\n    if (!voiceBinding || voiceBinding.provider !== 'bailian') {\n      const hasUploadedReference =\n        !!character?.customVoiceUrl ||\n        (speakerVoice?.provider === 'fal' && !!speakerVoice.audioUrl)\n      if (hasUploadedReference) {\n        throw new Error('无音色ID，QwenTTS 必须使用 AI 设计音色')\n      }\n      throw new Error('请先为该发言人绑定百炼音色')\n    }\n    const { apiKey } = await getProviderConfig(params.userId, audioSelection.provider)\n    const result = await synthesizeWithBailianTTS({\n      text,\n      voiceId: voiceBinding.voiceId,\n      modelId: audioSelection.modelId,\n      languageType: 'Chinese',\n    }, apiKey)\n    if (!result.success || !result.audioData) {\n      throw new Error(normalizeBailianVoiceGenerationError(result.error))\n    }\n\n    const audioData = result.audioData\n    generated = {\n      audioData,\n      audioDuration: result.audioDuration ?? getWavDurationFromBuffer(audioData),\n    }\n  } else {\n    throw new Error(`AUDIO_PROVIDER_UNSUPPORTED: ${audioSelection.provider}`)\n  }\n\n  const audioKey = `voice/${params.projectId}/${episodeId}/${line.id}.wav`\n  const cosKey = await uploadObject(generated.audioData, audioKey)\n\n  await checkCancelled?.()\n\n  await prisma.novelPromotionVoiceLine.update({\n    where: { id: line.id },\n    data: {\n      audioUrl: cosKey,\n      audioDuration: generated.audioDuration || null,\n    },\n  })\n\n  const signedUrl = getSignedUrl(cosKey, 7200)\n  return {\n    lineId: line.id,\n    audioUrl: signedUrl,\n    storageKey: cosKey,\n    audioDuration: generated.audioDuration || null,\n  }\n}\n\nexport function estimateVoiceLineMaxSeconds(content: string | null | undefined) {\n  const chars = typeof content === 'string' ? content.length : 0\n  return Math.max(5, Math.ceil(chars / 2))\n}\n"
  },
  {
    "path": "src/lib/voice/provider-voice-binding.ts",
    "content": "type VoiceSource = 'character' | 'speaker'\n\nexport type SupportedAudioProviderKey = 'fal' | 'bailian'\n\nexport interface CharacterVoiceFields {\n  customVoiceUrl?: string | null\n  voiceId?: string | null\n}\n\nexport interface RawSpeakerVoiceEntry {\n  provider?: string | null\n  voiceType?: string | null\n  audioUrl?: string | null\n  voiceId?: string | null\n  previewAudioUrl?: string | null\n}\n\nexport type FalSpeakerVoiceEntry = {\n  provider: 'fal'\n  voiceType: string\n  audioUrl: string\n}\n\nexport type BailianSpeakerVoiceEntry = {\n  provider: 'bailian'\n  voiceType: string\n  voiceId: string\n  previewAudioUrl?: string\n}\n\nexport type SpeakerVoiceEntry = FalSpeakerVoiceEntry | BailianSpeakerVoiceEntry\nexport type SpeakerVoiceMap = Record<string, SpeakerVoiceEntry>\n\nexport type FalVoiceGenerationBinding = {\n  provider: 'fal'\n  source: VoiceSource\n  referenceAudioUrl: string\n}\n\nexport type BailianVoiceGenerationBinding = {\n  provider: 'bailian'\n  source: VoiceSource\n  voiceId: string\n}\n\nexport type VoiceGenerationBinding = FalVoiceGenerationBinding | BailianVoiceGenerationBinding\n\nexport type SpeakerVoicePatch =\n  | {\n    provider: 'fal'\n    voiceType?: string\n    audioUrl: string\n  }\n  | {\n    provider: 'bailian'\n    voiceType?: string\n    voiceId: string\n    previewAudioUrl?: string\n  }\n\nfunction readTrimmedString(input: unknown): string | null {\n  if (typeof input !== 'string') return null\n  const value = input.trim()\n  return value.length > 0 ? value : null\n}\n\nfunction normalizeRawSpeakerVoiceEntry(raw: unknown, speaker: string): SpeakerVoiceEntry {\n  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {\n    throw new Error(`SPEAKER_VOICE_ENTRY_INVALID: ${speaker}`)\n  }\n\n  const entry = raw as RawSpeakerVoiceEntry\n  const provider = readTrimmedString(entry.provider)?.toLowerCase() ?? null\n  const voiceType = readTrimmedString(entry.voiceType) ?? 'uploaded'\n  const audioUrl = readTrimmedString(entry.audioUrl)\n  const voiceId = readTrimmedString(entry.voiceId)\n  const previewAudioUrl = readTrimmedString(entry.previewAudioUrl)\n\n  if (provider === 'fal') {\n    if (!audioUrl) {\n      throw new Error(`SPEAKER_VOICE_ENTRY_INVALID_FAL_AUDIO: ${speaker}`)\n    }\n    return {\n      provider: 'fal',\n      voiceType,\n      audioUrl,\n    }\n  }\n\n  if (provider === 'bailian') {\n    if (!voiceId) {\n      throw new Error(`SPEAKER_VOICE_ENTRY_INVALID_BAILIAN_VOICE_ID: ${speaker}`)\n    }\n    const preview = previewAudioUrl || audioUrl\n    return {\n      provider: 'bailian',\n      voiceType,\n      voiceId,\n      ...(preview ? { previewAudioUrl: preview } : {}),\n    }\n  }\n\n  if (provider) {\n    throw new Error(`SPEAKER_VOICE_ENTRY_INVALID_PROVIDER: ${speaker}`)\n  }\n\n  if (voiceId) {\n    const preview = previewAudioUrl || audioUrl\n    return {\n      provider: 'bailian',\n      voiceType,\n      voiceId,\n      ...(preview ? { previewAudioUrl: preview } : {}),\n    }\n  }\n\n  if (audioUrl) {\n    return {\n      provider: 'fal',\n      voiceType,\n      audioUrl,\n    }\n  }\n\n  throw new Error(`SPEAKER_VOICE_ENTRY_MISSING_BINDING: ${speaker}`)\n}\n\nexport function parseSpeakerVoiceMap(raw: string | null | undefined): SpeakerVoiceMap {\n  if (!raw) return {}\n\n  let parsed: unknown\n  try {\n    parsed = JSON.parse(raw)\n  } catch {\n    throw new Error('SPEAKER_VOICES_INVALID_JSON')\n  }\n\n  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n    throw new Error('SPEAKER_VOICES_INVALID_SHAPE')\n  }\n\n  const record = parsed as Record<string, unknown>\n  const result: SpeakerVoiceMap = {}\n  for (const [speaker, value] of Object.entries(record)) {\n    if (!speaker.trim()) {\n      throw new Error('SPEAKER_VOICES_INVALID_SPEAKER')\n    }\n    result[speaker] = normalizeRawSpeakerVoiceEntry(value, speaker)\n  }\n  return result\n}\n\nfunction normalizeProviderKey(providerKey: string): SupportedAudioProviderKey | null {\n  if (providerKey === 'fal' || providerKey === 'bailian') {\n    return providerKey\n  }\n  return null\n}\n\nfunction toFalBinding(source: VoiceSource, referenceAudioUrl: string | null): FalVoiceGenerationBinding | null {\n  if (!referenceAudioUrl) return null\n  return {\n    provider: 'fal',\n    source,\n    referenceAudioUrl,\n  }\n}\n\nfunction toBailianBinding(source: VoiceSource, voiceId: string | null): BailianVoiceGenerationBinding | null {\n  if (!voiceId) return null\n  return {\n    provider: 'bailian',\n    source,\n    voiceId,\n  }\n}\n\nexport function resolveVoiceBindingForProvider(params: {\n  providerKey: string\n  character?: CharacterVoiceFields | null\n  speakerVoice?: SpeakerVoiceEntry | null\n}): VoiceGenerationBinding | null {\n  const providerKey = normalizeProviderKey(params.providerKey)\n  if (!providerKey) return null\n\n  const characterAudioUrl = readTrimmedString(params.character?.customVoiceUrl)\n  const characterVoiceId = readTrimmedString(params.character?.voiceId)\n\n  if (providerKey === 'fal') {\n    const fromCharacter = toFalBinding('character', characterAudioUrl)\n    if (fromCharacter) return fromCharacter\n    if (params.speakerVoice?.provider !== 'fal') return null\n    return toFalBinding('speaker', readTrimmedString(params.speakerVoice.audioUrl))\n  }\n\n  const fromCharacter = toBailianBinding('character', characterVoiceId)\n  if (fromCharacter) return fromCharacter\n  if (params.speakerVoice?.provider !== 'bailian') return null\n  return toBailianBinding('speaker', readTrimmedString(params.speakerVoice.voiceId))\n}\n\nexport function hasVoiceBindingForProvider(params: {\n  providerKey: string\n  character?: CharacterVoiceFields | null\n  speakerVoice?: SpeakerVoiceEntry | null\n}): boolean {\n  return !!resolveVoiceBindingForProvider(params)\n}\n\nexport function hasAnyVoiceBinding(params: {\n  character?: CharacterVoiceFields | null\n  speakerVoice?: SpeakerVoiceEntry | null\n}): boolean {\n  const characterAudioUrl = readTrimmedString(params.character?.customVoiceUrl)\n  const characterVoiceId = readTrimmedString(params.character?.voiceId)\n  if (characterAudioUrl || characterVoiceId) return true\n\n  if (!params.speakerVoice) return false\n  if (params.speakerVoice.provider === 'fal') {\n    return !!readTrimmedString(params.speakerVoice.audioUrl)\n  }\n  return !!readTrimmedString(params.speakerVoice.voiceId)\n}\n\nexport function getSpeakerVoicePreviewUrl(speakerVoice?: SpeakerVoiceEntry | null): string | null {\n  if (!speakerVoice) return null\n  if (speakerVoice.provider === 'fal') {\n    return readTrimmedString(speakerVoice.audioUrl)\n  }\n  return readTrimmedString(speakerVoice.previewAudioUrl)\n}\n"
  },
  {
    "path": "src/lib/word-count.ts",
    "content": "/**\n * 字数统计工具函数\n * \n * 按照 Word 的字数统计规则：\n * - 中文：每个汉字算 1 字\n * - 英文：每个单词算 1 字（用空格分隔）\n * - 空格、换行符、标点不计入字数\n */\n\n/**\n * 计算文本的字数（模拟 Microsoft Word 的字数统计）\n * \n * @param text 输入文本\n * @returns 字数（不是字符数！）\n */\nexport function countWords(text: string): number {\n    if (!text) return 0\n\n    // 处理英文和数字：将连续的英文字母和数字视为一个\"单词\"\n    // 先用正则替换掉英文+数字组成的单词，同时计数\n    let englishWordCount = 0\n    const textWithoutEnglish = text.replace(/[a-zA-Z0-9]+/g, () => {\n        englishWordCount++\n        return '' // 移除英文单词，剩下的就是中文和其他字符\n    })\n\n    // 统计中文字符数量\n    // 使用 Unicode 范围匹配常用汉字 + 扩展 A/B 区\n    const chineseMatches = textWithoutEnglish.match(/[\\u4e00-\\u9fa5\\u3400-\\u4dbf\\u20000-\\u2a6df]/g)\n    const chineseCount = chineseMatches ? chineseMatches.length : 0\n\n    return englishWordCount + chineseCount\n}\n\n/**\n * 计算文本的字符数（包括所有字符）\n * 这相当于 JavaScript 的 string.length\n * \n * @param text 输入文本\n * @returns 字符数\n */\nexport function countCharacters(text: string): number {\n    return text?.length || 0\n}\n\n/**\n * 计算文本的字符数（不含空格）\n * \n * @param text 输入文本\n * @returns 字符数（不含空格）\n */\nexport function countCharactersNoSpaces(text: string): number {\n    if (!text) return 0\n    return text.replace(/\\s/g, '').length\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/analyze-global-parse.ts",
    "content": "import { safeParseJsonObject } from '@/lib/json-repair'\n\nexport const CHUNK_SIZE = 3000\nconst INVALID_LOCATION_KEYWORDS = ['幻想', '抽象', '无明确', '空间锚点', '未说明', '不明确']\n\nexport type CharacterBrief = {\n  id: string\n  name: string\n  aliases: string[]\n  introduction: string\n}\n\nexport type AnalyzeGlobalCharactersData = {\n  new_characters?: Array<Record<string, unknown>>\n  updated_characters?: Array<Record<string, unknown>>\n  characters?: Array<Record<string, unknown>>\n}\n\nexport type AnalyzeGlobalLocationsData = {\n  locations?: Array<Record<string, unknown>>\n}\n\nexport function chunkContent(text: string, maxSize = CHUNK_SIZE): string[] {\n  const chunks: string[] = []\n  const paragraphs = text.split(/\\n\\n+/)\n  let current = ''\n\n  for (const p of paragraphs) {\n    if (current.length + p.length + 2 > maxSize) {\n      if (current.trim()) chunks.push(current.trim())\n      current = p\n    } else {\n      current += (current ? '\\n\\n' : '') + p\n    }\n  }\n  if (current.trim()) chunks.push(current.trim())\n  return chunks\n}\n\nexport function parseJsonResponse(responseText: string): Record<string, unknown> {\n  return safeParseJsonObject(responseText)\n}\n\nexport function readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nexport function toStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value.map((item) => readText(item).trim()).filter(Boolean)\n}\n\nexport function parseAliases(raw: string | null): string[] {\n  if (!raw) return []\n  try {\n    const parsed = JSON.parse(raw)\n    return toStringArray(parsed)\n  } catch {\n    return []\n  }\n}\n\nexport function buildCharactersLibInfo(characters: CharacterBrief[]): string {\n  if (characters.length === 0) return '暂无已有角色'\n  return characters\n    .map((c, i) => {\n      const aliasStr = c.aliases.length > 0 ? `别名：${c.aliases.join('、')}` : '别名：无'\n      const introStr = c.introduction ? `介绍：${c.introduction}` : '介绍：暂无'\n      return `${i + 1}. ${c.name}\\n   ${aliasStr}\\n   ${introStr}`\n    })\n    .join('\\n\\n')\n}\n\nexport function isInvalidLocation(name: string, summary: string): boolean {\n  return INVALID_LOCATION_KEYWORDS.some((keyword) => name.includes(keyword) || summary.includes(keyword))\n}\n\nexport function safeParseCharactersResponse(responseText: string): AnalyzeGlobalCharactersData {\n  try {\n    const parsed = parseJsonResponse(responseText) as AnalyzeGlobalCharactersData\n    if (!parsed.new_characters && Array.isArray(parsed.characters)) {\n      parsed.new_characters = parsed.characters\n    }\n    return parsed\n  } catch {\n    return {}\n  }\n}\n\nexport function safeParseLocationsResponse(responseText: string): AnalyzeGlobalLocationsData {\n  try {\n    return parseJsonResponse(responseText) as AnalyzeGlobalLocationsData\n  } catch {\n    return {}\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/analyze-global-persist.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { removeLocationPromptSuffix } from '@/lib/constants'\nimport {\n  isInvalidLocation,\n  readText,\n  toStringArray,\n  type AnalyzeGlobalCharactersData,\n  type AnalyzeGlobalLocationsData,\n  type CharacterBrief,\n} from './analyze-global-parse'\n\nexport type AnalyzeGlobalStats = {\n  totalChunks: number\n  processedChunks: number\n  newCharacters: number\n  updatedCharacters: number\n  newLocations: number\n  skippedCharacters: number\n  skippedLocations: number\n}\n\nexport function createAnalyzeGlobalStats(totalChunks: number): AnalyzeGlobalStats {\n  return {\n    totalChunks,\n    processedChunks: 0,\n    newCharacters: 0,\n    updatedCharacters: 0,\n    newLocations: 0,\n    skippedCharacters: 0,\n    skippedLocations: 0,\n  }\n}\n\nexport async function persistAnalyzeGlobalChunk(params: {\n  projectInternalId: string\n  charactersData: AnalyzeGlobalCharactersData\n  locationsData: AnalyzeGlobalLocationsData\n  existingCharacters: CharacterBrief[]\n  existingCharacterNames: string[]\n  existingLocationNames: string[]\n  existingLocationInfo: string[]\n  stats: AnalyzeGlobalStats\n}) {\n  for (const char of params.charactersData.new_characters || []) {\n    const name = readText(char.name).trim()\n    const aliases = toStringArray(char.aliases)\n    if (!name) continue\n\n    const nameExists = params.existingCharacterNames.some((item) => item.toLowerCase() === name.toLowerCase())\n    const aliasExists = aliases.some((alias) =>\n      params.existingCharacterNames.some((item) => item.toLowerCase() === alias.toLowerCase()),\n    )\n    if (nameExists || aliasExists) {\n      params.stats.skippedCharacters += 1\n      continue\n    }\n\n    try {\n      const profileData = {\n        role_level: char.role_level,\n        archetype: char.archetype,\n        personality_tags: toStringArray(char.personality_tags),\n        era_period: char.era_period,\n        social_class: char.social_class,\n        occupation: char.occupation,\n        costume_tier: char.costume_tier,\n        suggested_colors: toStringArray(char.suggested_colors),\n        primary_identifier: char.primary_identifier,\n        visual_keywords: toStringArray(char.visual_keywords),\n        gender: char.gender,\n        age_range: char.age_range,\n      }\n\n      const created = await prisma.novelPromotionCharacter.create({\n        data: {\n          novelPromotionProjectId: params.projectInternalId,\n          name,\n          aliases: JSON.stringify(aliases),\n          introduction: readText(char.introduction),\n          profileData: JSON.stringify(profileData),\n          profileConfirmed: false,\n        },\n        select: {\n          id: true,\n        },\n      })\n\n      params.existingCharacters.push({\n        id: created.id,\n        name,\n        aliases,\n        introduction: readText(char.introduction),\n      })\n      params.existingCharacterNames.push(name, ...aliases)\n      params.stats.newCharacters += 1\n    } catch {\n      params.stats.skippedCharacters += 1\n    }\n  }\n\n  for (const update of params.charactersData.updated_characters || []) {\n    const targetName = readText(update.name).trim()\n    if (!targetName) continue\n    const existing = params.existingCharacters.find((item) => item.name.toLowerCase() === targetName.toLowerCase())\n    if (!existing) continue\n\n    try {\n      const updateData: Record<string, unknown> = {}\n      const updatedIntroduction = readText(update.updated_introduction).trim()\n      if (updatedIntroduction) {\n        updateData.introduction = updatedIntroduction\n        existing.introduction = updatedIntroduction\n      }\n\n      const updatedAliases = toStringArray(update.updated_aliases)\n      if (updatedAliases.length > 0) {\n        const newAliases = updatedAliases.filter(\n          (item) => !existing.aliases.some((alias) => alias.toLowerCase() === item.toLowerCase()),\n        )\n        if (newAliases.length > 0) {\n          const merged = [...existing.aliases, ...newAliases]\n          updateData.aliases = JSON.stringify(merged)\n          existing.aliases = merged\n          params.existingCharacterNames.push(...newAliases)\n        }\n      }\n\n      if (Object.keys(updateData).length > 0) {\n        await prisma.novelPromotionCharacter.update({\n          where: { id: existing.id },\n          data: updateData,\n        })\n        params.stats.updatedCharacters += 1\n      }\n    } catch {\n      // skip failed update\n    }\n  }\n\n  for (const loc of params.locationsData.locations || []) {\n    const name = readText(loc.name).trim()\n    const summary = readText(loc.summary)\n    if (!name) continue\n    if (isInvalidLocation(name, summary)) {\n      params.stats.skippedLocations += 1\n      continue\n    }\n\n    const exists = params.existingLocationNames.some((item) => item.toLowerCase() === name.toLowerCase())\n    if (exists) {\n      params.stats.skippedLocations += 1\n      continue\n    }\n\n    try {\n      const descriptionsRaw = Array.isArray(loc.descriptions)\n        ? (loc.descriptions as unknown[])\n        : (readText(loc.description) ? [readText(loc.description)] : [])\n      const descriptions = descriptionsRaw.map((item) => readText(item)).filter(Boolean)\n      const cleanDescriptions = descriptions.map((item) => removeLocationPromptSuffix(item))\n\n      const created = await prisma.novelPromotionLocation.create({\n        data: {\n          novelPromotionProjectId: params.projectInternalId,\n          name,\n          summary: summary || null,\n        },\n        select: {\n          id: true,\n        },\n      })\n\n      for (let j = 0; j < cleanDescriptions.length; j += 1) {\n        await prisma.locationImage.create({\n          data: {\n            locationId: created.id,\n            imageIndex: j,\n            description: cleanDescriptions[j],\n          },\n        })\n      }\n\n      params.existingLocationNames.push(name)\n      params.existingLocationInfo.push(summary ? `${name}(${summary})` : name)\n      params.stats.newLocations += 1\n    } catch {\n      params.stats.skippedLocations += 1\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/analyze-global-prompt.ts",
    "content": "import { buildCharactersLibInfo, type CharacterBrief } from './analyze-global-parse'\nimport type { Locale } from '@/i18n/routing'\nimport { getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'\n\nexport type AnalyzeGlobalPromptTemplates = {\n  characterPromptTemplate: string\n  locationPromptTemplate: string\n}\n\nexport function loadAnalyzeGlobalPromptTemplates(locale: Locale): AnalyzeGlobalPromptTemplates {\n  return {\n    characterPromptTemplate: getPromptTemplate(PROMPT_IDS.NP_AGENT_CHARACTER_PROFILE, locale),\n    locationPromptTemplate: getPromptTemplate(PROMPT_IDS.NP_SELECT_LOCATION, locale),\n  }\n}\n\nexport function buildAnalyzeGlobalPrompts(params: {\n  chunk: string\n  templates: AnalyzeGlobalPromptTemplates\n  existingCharacters: CharacterBrief[]\n  existingLocationInfo: string[]\n}) {\n  const characterPrompt = params.templates.characterPromptTemplate\n    .replace('{input}', params.chunk)\n    .replace('{characters_lib_info}', buildCharactersLibInfo(params.existingCharacters))\n  const locationPrompt = params.templates.locationPromptTemplate\n    .replace('{input}', params.chunk)\n    .replace('{locations_lib_name}', params.existingLocationInfo.join(', ') || '无')\n  return {\n    characterPrompt,\n    locationPrompt,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/analyze-global.ts",
    "content": "import type { Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\nimport {\n  CHUNK_SIZE,\n  chunkContent,\n  parseAliases,\n  readText,\n  safeParseCharactersResponse,\n  safeParseLocationsResponse,\n  type CharacterBrief,\n} from './analyze-global-parse'\nimport { buildAnalyzeGlobalPrompts, loadAnalyzeGlobalPromptTemplates } from './analyze-global-prompt'\nimport { createAnalyzeGlobalStats, persistAnalyzeGlobalChunk } from './analyze-global-persist'\nimport { resolveAnalysisModel } from './resolve-analysis-model'\n\nexport async function handleAnalyzeGlobalTask(job: Job<TaskJobData>) {\n  const projectId = job.data.projectId\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: {\n      id: true,\n      mode: true,\n    },\n  })\n  if (!project) {\n    throw new Error('Project not found')\n  }\n  if (project.mode !== 'novel-promotion') {\n    throw new Error('Not a novel promotion project')\n  }\n\n  const novelData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      characters: true,\n      locations: true,\n      episodes: {\n        orderBy: { episodeNumber: 'asc' },\n        select: {\n          id: true,\n          name: true,\n          novelText: true,\n        },\n      },\n    },\n  })\n  if (!novelData) {\n    throw new Error('Novel promotion data not found')\n  }\n\n  const analysisModel = await resolveAnalysisModel({\n    userId: job.data.userId,\n    projectAnalysisModel: novelData.analysisModel,\n  })\n\n  let allContent = ''\n  if (readText(novelData.globalAssetText).trim()) {\n    allContent += `【全局设定】\\n${readText(novelData.globalAssetText)}\\n\\n`\n  }\n  for (const ep of novelData.episodes) {\n    const text = readText(ep.novelText)\n    if (!text.trim()) continue\n    allContent += `【${ep.name}】\\n${text}\\n\\n`\n  }\n  if (!allContent.trim()) {\n    throw new Error('没有可分析的内容，请先添加剧集或全局设定')\n  }\n\n  const chunks = chunkContent(allContent, CHUNK_SIZE)\n  const templates = loadAnalyzeGlobalPromptTemplates(job.data.locale)\n  const existingCharacters: CharacterBrief[] = novelData.characters.map((item) => ({\n    id: item.id,\n    name: item.name,\n    aliases: parseAliases(item.aliases as string | null),\n    introduction: readText((item as Record<string, unknown>).introduction),\n  }))\n  const existingCharacterNames = existingCharacters.flatMap((item) => [item.name, ...item.aliases])\n  const existingLocationNames = novelData.locations.map((item) => item.name)\n  const existingLocationInfo = novelData.locations.map((item) => {\n    const summary = readText(item.summary)\n    return summary ? `${item.name}(${summary})` : item.name\n  })\n  const stats = createAnalyzeGlobalStats(chunks.length)\n\n  await reportTaskProgress(job, 10, {\n    stage: 'analyze_global_prepare',\n    stageLabel: '准备全局资产分析参数',\n    displayMode: 'detail',\n    message: `共 ${chunks.length} 个切片`,\n  })\n  await assertTaskActive(job, 'analyze_global_prepare')\n\n  const streamContext = createWorkerLLMStreamContext(job, 'analyze_global')\n  const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)\n\n  try {\n    for (let i = 0; i < chunks.length; i += 1) {\n      await assertTaskActive(job, `analyze_global_chunk:${i + 1}`)\n      const chunk = chunks[i]\n      const progress = 15 + Math.min(60, Math.floor(((i + 1) / Math.max(1, chunks.length)) * 60))\n      await reportTaskProgress(job, progress, {\n        stage: 'analyze_global_chunk',\n        stageLabel: '分析全局资产切片',\n        displayMode: 'detail',\n        message: `切片 ${i + 1}/${chunks.length}`,\n        stepId: `analyze_global_chunk_${i + 1}`,\n        stepTitle: `全局资产分析 ${i + 1}/${chunks.length}`,\n        stepIndex: i + 1,\n        stepTotal: chunks.length,\n      })\n\n      const { characterPrompt, locationPrompt } = buildAnalyzeGlobalPrompts({\n        chunk,\n        templates,\n        existingCharacters,\n        existingLocationInfo,\n      })\n\n      const [characterCompletion, locationCompletion] = await withInternalLLMStreamCallbacks(\n        streamCallbacks,\n        async () =>\n          await Promise.all([\n            executeAiTextStep({\n              userId: job.data.userId,\n              model: analysisModel,\n              messages: [{ role: 'user', content: characterPrompt }],\n              temperature: 0.7,\n              projectId,\n              action: 'analyze_global_characters',\n              meta: {\n                stepId: `analyze_global_characters_${i + 1}`,\n                stepTitle: `角色分析 ${i + 1}/${chunks.length}`,\n                stepIndex: i + 1,\n                stepTotal: chunks.length,\n              },\n            }),\n            executeAiTextStep({\n              userId: job.data.userId,\n              model: analysisModel,\n              messages: [{ role: 'user', content: locationPrompt }],\n              temperature: 0.7,\n              projectId,\n              action: 'analyze_global_locations',\n              meta: {\n                stepId: `analyze_global_locations_${i + 1}`,\n                stepTitle: `场景分析 ${i + 1}/${chunks.length}`,\n                stepIndex: i + 1,\n                stepTotal: chunks.length,\n              },\n            }),\n          ]),\n      )\n\n      const characterResponse = characterCompletion.text\n      const locationResponse = locationCompletion.text\n      const charactersData = safeParseCharactersResponse(characterResponse)\n      const locationsData = safeParseLocationsResponse(locationResponse)\n\n      await persistAnalyzeGlobalChunk({\n        projectInternalId: novelData.id,\n        charactersData,\n        locationsData,\n        existingCharacters,\n        existingCharacterNames,\n        existingLocationNames,\n        existingLocationInfo,\n        stats,\n      })\n\n      stats.processedChunks += 1\n    }\n  } finally {\n    await streamCallbacks.flush()\n  }\n\n  await reportTaskProgress(job, 96, {\n    stage: 'analyze_global_done',\n    stageLabel: '全局资产分析完成',\n    displayMode: 'detail',\n  })\n\n  return {\n    success: true,\n    stats: {\n      totalChunks: stats.totalChunks,\n      newCharacters: stats.newCharacters,\n      updatedCharacters: stats.updatedCharacters,\n      newLocations: stats.newLocations,\n      skippedCharacters: stats.skippedCharacters,\n      skippedLocations: stats.skippedLocations,\n      totalCharacters: existingCharacterNames.length,\n      totalLocations: existingLocationNames.length,\n    },\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/analyze-novel.ts",
    "content": "import type { Job } from 'bullmq'\nimport { safeParseJsonObject } from '@/lib/json-repair'\nimport { prisma } from '@/lib/prisma'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { getArtStylePrompt, removeLocationPromptSuffix } from '@/lib/constants'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport { resolveAnalysisModel } from './resolve-analysis-model'\n\nfunction readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nfunction toStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value\n    .map((item) => (typeof item === 'string' ? item.trim() : ''))\n    .filter(Boolean)\n}\n\n/** 按别名匹配：按 '/' 拆分后任一别名精确匹配即为命中 */\nfunction nameMatchesWithAlias(existingName: string, newName: string): boolean {\n  const a = existingName.toLowerCase().trim()\n  const b = newName.toLowerCase().trim()\n  if (a === b) return true\n  const aliasesA = a.split('/').map(s => s.trim()).filter(Boolean)\n  const aliasesB = b.split('/').map(s => s.trim()).filter(Boolean)\n  return aliasesB.some(alias => aliasesA.includes(alias))\n}\n\nfunction parseJsonResponse(responseText: string): Record<string, unknown> {\n  return safeParseJsonObject(responseText)\n}\n\nexport async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as Record<string, unknown>\n  const projectId = job.data.projectId\n\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: {\n      id: true,\n      mode: true,\n    },\n  })\n  if (!project) {\n    throw new Error('Project not found')\n  }\n  if (project.mode !== 'novel-promotion') {\n    throw new Error('Not a novel promotion project')\n  }\n\n  const novelData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      characters: true,\n      locations: true,\n    },\n  })\n  if (!novelData) {\n    throw new Error('Novel promotion data not found')\n  }\n  const analysisModel = await resolveAnalysisModel({\n    userId: job.data.userId,\n    inputModel: payload.model,\n    projectAnalysisModel: novelData.analysisModel,\n  })\n\n  const firstEpisode = await prisma.novelPromotionEpisode.findFirst({\n    where: { novelPromotionProjectId: novelData.id },\n    orderBy: { createdAt: 'asc' },\n    select: {\n      novelText: true,\n    },\n  })\n\n  let contentToAnalyze = readText(novelData.globalAssetText) || readText(firstEpisode?.novelText)\n  if (!contentToAnalyze.trim()) {\n    throw new Error('请先填写全局资产设定或剧本内容')\n  }\n\n  const maxContentLength = 30000\n  if (contentToAnalyze.length > maxContentLength) {\n    contentToAnalyze = contentToAnalyze.substring(0, maxContentLength)\n  }\n\n  const charactersLibName = (novelData.characters || []).map((item) => item.name).join(', ')\n  const locationsLibName = (novelData.locations || []).map((item) => item.name).join(', ')\n  const characterPromptTemplate = buildPrompt({\n    promptId: PROMPT_IDS.NP_AGENT_CHARACTER_PROFILE,\n    locale: job.data.locale,\n    variables: {\n      input: contentToAnalyze,\n      characters_lib_info: charactersLibName || '无',\n    },\n  })\n  const locationPromptTemplate = buildPrompt({\n    promptId: PROMPT_IDS.NP_SELECT_LOCATION,\n    locale: job.data.locale,\n    variables: {\n      input: contentToAnalyze,\n      locations_lib_name: locationsLibName || '无',\n    },\n  })\n\n  await reportTaskProgress(job, 20, {\n    stage: 'analyze_novel_prepare',\n    stageLabel: '准备资产分析参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'analyze_novel_prepare')\n\n  const streamContext = createWorkerLLMStreamContext(job, 'analyze_novel')\n  const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)\n  const [characterCompletion, locationCompletion] = await (async () => {\n    try {\n      return await withInternalLLMStreamCallbacks(\n        streamCallbacks,\n        async () =>\n          await Promise.all([\n            executeAiTextStep({\n              userId: job.data.userId,\n              model: analysisModel,\n              messages: [{ role: 'user', content: characterPromptTemplate }],\n              temperature: 0.7,\n              projectId,\n              action: 'analyze_characters',\n              meta: {\n                stepId: 'analyze_characters',\n                stepTitle: '角色分析',\n                stepIndex: 1,\n                stepTotal: 2,\n              },\n            }),\n            executeAiTextStep({\n              userId: job.data.userId,\n              model: analysisModel,\n              messages: [{ role: 'user', content: locationPromptTemplate }],\n              temperature: 0.7,\n              projectId,\n              action: 'analyze_locations',\n              meta: {\n                stepId: 'analyze_locations',\n                stepTitle: '场景分析',\n                stepIndex: 2,\n                stepTotal: 2,\n              },\n            }),\n          ]),\n      )\n    } finally {\n      await streamCallbacks.flush()\n    }\n  })()\n\n  const characterResponseText = characterCompletion.text\n  const locationResponseText = locationCompletion.text\n\n  await reportTaskProgress(job, 60, {\n    stage: 'analyze_novel_characters_done',\n    stageLabel: '角色分析完成',\n    displayMode: 'detail',\n    stepId: 'analyze_characters',\n    stepTitle: '角色分析',\n    stepIndex: 1,\n    stepTotal: 2,\n    done: true,\n    output: characterResponseText,\n  })\n\n  await reportTaskProgress(job, 70, {\n    stage: 'analyze_novel_locations_done',\n    stageLabel: '场景分析完成',\n    displayMode: 'detail',\n    stepId: 'analyze_locations',\n    stepTitle: '场景分析',\n    stepIndex: 2,\n    stepTotal: 2,\n    done: true,\n    output: locationResponseText,\n  })\n\n  const charactersData = parseJsonResponse(characterResponseText)\n  const locationsData = parseJsonResponse(locationResponseText)\n  const parsedCharacters = Array.isArray(charactersData.characters)\n    ? (charactersData.characters as Array<Record<string, unknown>>)\n    : []\n  const parsedLocations = Array.isArray(locationsData.locations)\n    ? (locationsData.locations as Array<Record<string, unknown>>)\n    : []\n\n  await reportTaskProgress(job, 75, {\n    stage: 'analyze_novel_persist',\n    stageLabel: '保存资产分析结果',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'analyze_novel_persist')\n\n  const createdCharacters: Array<{ id: string }> = []\n  for (const item of parsedCharacters) {\n    const name = readText(item.name).trim()\n    if (!name) continue\n\n    const existsInLibrary = (novelData.characters || []).some(\n      (character) => nameMatchesWithAlias(character.name, name),\n    )\n    if (existsInLibrary) continue\n\n    const profileData = {\n      role_level: item.role_level,\n      archetype: item.archetype,\n      personality_tags: toStringArray(item.personality_tags),\n      era_period: item.era_period,\n      social_class: item.social_class,\n      occupation: item.occupation,\n      costume_tier: item.costume_tier,\n      suggested_colors: toStringArray(item.suggested_colors),\n      primary_identifier: item.primary_identifier,\n      visual_keywords: toStringArray(item.visual_keywords),\n      gender: item.gender,\n      age_range: item.age_range,\n    }\n\n    const created = await prisma.novelPromotionCharacter.create({\n      data: {\n        novelPromotionProjectId: novelData.id,\n        name,\n        aliases: JSON.stringify(toStringArray(item.aliases)),\n        profileData: JSON.stringify(profileData),\n        profileConfirmed: false,\n      },\n      select: { id: true },\n    })\n    createdCharacters.push(created)\n  }\n\n  const createdLocations: Array<{ id: string }> = []\n  for (const item of parsedLocations) {\n    const name = readText(item.name).trim()\n    if (!name) continue\n\n    const descriptionsRaw = Array.isArray(item.descriptions)\n      ? (item.descriptions as unknown[])\n      : (readText(item.description) ? [readText(item.description)] : [])\n    const descriptions = descriptionsRaw\n      .map((value) => readText(value))\n      .filter(Boolean)\n    const firstDescription = descriptions[0] || ''\n    const invalidKeywords = ['幻想', '抽象', '无明确', '空间锚点', '未说明', '不明确']\n    const isInvalid = invalidKeywords.some((keyword) => name.includes(keyword) || firstDescription.includes(keyword))\n    if (isInvalid) continue\n\n    const existsInLibrary = (novelData.locations || []).some(\n      (location) => nameMatchesWithAlias(location.name, name),\n    )\n    if (existsInLibrary) continue\n\n    const created = await prisma.novelPromotionLocation.create({\n      data: {\n        novelPromotionProjectId: novelData.id,\n        name,\n        summary: readText(item.summary) || null,\n      },\n      select: { id: true },\n    })\n\n    const cleanDescriptions = descriptions.map((value) => removeLocationPromptSuffix(value || ''))\n    for (let i = 0; i < cleanDescriptions.length; i += 1) {\n      await prisma.locationImage.create({\n        data: {\n          locationId: created.id,\n          imageIndex: i,\n          description: cleanDescriptions[i],\n        },\n      })\n    }\n\n    createdLocations.push(created)\n  }\n\n  await prisma.novelPromotionProject.update({\n    where: { id: novelData.id },\n    data: {\n      artStylePrompt: getArtStylePrompt(novelData.artStyle, job.data.locale) || '',\n    },\n  })\n\n  await reportTaskProgress(job, 96, {\n    stage: 'analyze_novel_done',\n    stageLabel: '资产分析已完成',\n    displayMode: 'detail',\n  })\n\n  return {\n    success: true,\n    characters: createdCharacters,\n    locations: createdLocations,\n    characterCount: createdCharacters.length,\n    locationCount: createdLocations.length,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/asset-hub-ai-design.ts",
    "content": "import type { Job } from 'bullmq'\nimport { getUserModelConfig } from '@/lib/config-service'\nimport { aiDesign } from '@/lib/asset-utils'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nfunction resolveUserInstruction(payload: Record<string, unknown>) {\n  const value = payload.userInstruction\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nexport async function handleAssetHubAIDesignTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as Record<string, unknown>\n  const userInstruction = resolveUserInstruction(payload)\n  if (!userInstruction) {\n    throw new Error('userInstruction is required')\n  }\n\n  const assetType =\n    job.data.type === TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER\n      || job.data.type === TASK_TYPE.AI_CREATE_CHARACTER\n      ? 'character'\n      : job.data.type === TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION\n        || job.data.type === TASK_TYPE.AI_CREATE_LOCATION\n        ? 'location'\n        : null\n  if (!assetType) {\n    throw new Error(`Unsupported asset hub ai design task type: ${job.data.type}`)\n  }\n\n  const userConfig = await getUserModelConfig(job.data.userId)\n  const analysisModelFromPayload =\n    typeof payload.analysisModel === 'string' && payload.analysisModel.trim()\n      ? payload.analysisModel.trim()\n      : null\n  const analysisModel = analysisModelFromPayload || userConfig.analysisModel || ''\n  if (!analysisModel) {\n    throw new Error('ANALYSIS_MODEL_NOT_CONFIGURED: 请先在设置页面配置分析模型')\n  }\n\n  await reportTaskProgress(job, 25, {\n    stage: 'asset_hub_ai_design_prepare',\n    stageLabel: '准备资产设计参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'asset_hub_ai_design_prepare')\n\n  const result = await aiDesign({\n    userId: job.data.userId,\n    locale: job.data.locale,\n    analysisModel,\n    userInstruction,\n    assetType,\n    projectId: job.data.projectId || 'asset-hub',\n    skipBilling: true,\n  })\n\n  if (!result.success || !result.prompt) {\n    throw new Error(result.error || 'Generation failed')\n  }\n\n  await reportTaskProgress(job, 96, {\n    stage: 'asset_hub_ai_design_done',\n    stageLabel: '资产设计结果已生成',\n    displayMode: 'detail',\n  })\n\n  return {\n    prompt: result.prompt,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/asset-hub-ai-modify.ts",
    "content": "import type { Job } from 'bullmq'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { getUserModelConfig } from '@/lib/config-service'\nimport { removeCharacterPromptSuffix, removeLocationPromptSuffix } from '@/lib/constants'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport type { TaskJobData } from '@/lib/task/types'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\n\nfunction readRequiredString(value: unknown, field: string): string {\n  if (typeof value !== 'string' || !value.trim()) {\n    throw new Error(`${field} is required`)\n  }\n  return value.trim()\n}\n\nimport { safeParseJsonObject } from '@/lib/json-repair'\n\nfunction parseJsonPrompt(responseText: string): string {\n  const parsed = safeParseJsonObject(responseText)\n  const prompt = typeof parsed.prompt === 'string' ? parsed.prompt.trim() : ''\n  if (!prompt) {\n    throw new Error('No prompt field in response')\n  }\n  return prompt\n}\n\nexport async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as Record<string, unknown>\n  const userConfig = await getUserModelConfig(job.data.userId)\n  if (!userConfig.analysisModel) {\n    throw new Error('请先在用户配置中设置分析模型')\n  }\n\n  const isCharacter = job.data.type === TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER\n  const isLocation = job.data.type === TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION\n  if (!isCharacter && !isLocation) {\n    throw new Error(`Unsupported task type: ${job.data.type}`)\n  }\n\n  const targetIdField = isCharacter ? 'characterId' : 'locationId'\n  const targetId = readRequiredString(payload[targetIdField], targetIdField)\n  const modifyInstruction = readRequiredString(payload.modifyInstruction, 'modifyInstruction')\n  const currentDescriptionRaw = readRequiredString(payload.currentDescription, 'currentDescription')\n\n  const finalPrompt = isCharacter\n    ? buildPrompt({\n      promptId: PROMPT_IDS.NP_CHARACTER_MODIFY,\n      locale: job.data.locale,\n      variables: {\n        character_input: removeCharacterPromptSuffix(currentDescriptionRaw),\n        user_input: modifyInstruction,\n      },\n    })\n    : buildPrompt({\n      promptId: PROMPT_IDS.NP_LOCATION_MODIFY,\n      locale: job.data.locale,\n      variables: {\n        location_name: readRequiredString(payload.locationName || '场景', 'locationName'),\n        location_input: removeLocationPromptSuffix(currentDescriptionRaw),\n        user_input: modifyInstruction,\n      },\n    })\n\n  await reportTaskProgress(job, 25, {\n    stage: 'asset_hub_ai_modify_prepare',\n    stageLabel: '准备资产修改参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'asset_hub_ai_modify_prepare')\n\n  const streamContext = createWorkerLLMStreamContext(job, isCharacter ? 'asset_hub_ai_modify_character' : 'asset_hub_ai_modify_location')\n  const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)\n\n  const completion = await withInternalLLMStreamCallbacks(\n    streamCallbacks,\n    async () =>\n      await executeAiTextStep({\n        userId: job.data.userId,\n        model: userConfig.analysisModel!,\n        messages: [{ role: 'user', content: finalPrompt }],\n        temperature: 0.7,\n        projectId: 'asset-hub',\n        action: isCharacter ? 'ai_modify_character' : 'ai_modify_location',\n        meta: {\n          stepId: isCharacter ? 'asset_hub_ai_modify_character' : 'asset_hub_ai_modify_location',\n          stepTitle: isCharacter ? '角色描述修改' : '场景描述修改',\n          stepIndex: 1,\n          stepTotal: 1,\n        },\n      }),\n  )\n  await streamCallbacks.flush()\n  await assertTaskActive(job, 'asset_hub_ai_modify_parse')\n\n  const modifiedDescription = parseJsonPrompt(completion.text)\n\n  await reportTaskProgress(job, 96, {\n    stage: 'asset_hub_ai_modify_done',\n    stageLabel: '资产修改结果已生成',\n    displayMode: 'detail',\n    meta: {\n      targetType: isCharacter ? 'character' : 'location',\n      targetId,\n    },\n  })\n\n  return {\n    success: true,\n    modifiedDescription,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/asset-hub-image-task-handler.ts",
    "content": "import { type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { addCharacterPromptSuffix, addLocationPromptSuffix, getArtStylePrompt } from '@/lib/constants'\nimport { type TaskJobData } from '@/lib/task/types'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\nimport {\n  assertTaskActive,\n  getUserModels,\n} from '../utils'\nimport {\n  AnyObj,\n  generateLabeledImageToCos,\n  parseJsonStringArray,\n} from './image-task-handler-shared'\n\ninterface GlobalCharacterAppearanceRecord {\n  id: string\n  appearanceIndex: number\n  changeReason: string | null\n  description: string | null\n  descriptions: string | null\n}\n\ninterface GlobalCharacterRecord {\n  id: string\n  name: string\n  appearances: GlobalCharacterAppearanceRecord[]\n}\n\ninterface GlobalLocationImageRecord {\n  id: string\n  description: string | null\n}\n\ninterface GlobalLocationRecord {\n  id: string\n  name: string\n  images: GlobalLocationImageRecord[]\n}\n\ninterface AssetHubImageDb {\n  globalCharacter: {\n    findFirst(args: Record<string, unknown>): Promise<GlobalCharacterRecord | null>\n  }\n  globalCharacterAppearance: {\n    update(args: Record<string, unknown>): Promise<unknown>\n  }\n  globalLocation: {\n    findFirst(args: Record<string, unknown>): Promise<GlobalLocationRecord | null>\n  }\n  globalLocationImage: {\n    update(args: Record<string, unknown>): Promise<unknown>\n  }\n}\n\nexport async function handleAssetHubImageTask(job: Job<TaskJobData>) {\n  const db = prisma as unknown as AssetHubImageDb\n  const payload = (job.data.payload || {}) as AnyObj\n  const userId = job.data.userId\n  const userModels = await getUserModels(userId)\n  const artStyle = getArtStylePrompt(\n    typeof payload.artStyle === 'string' ? payload.artStyle : undefined,\n    job.data.locale,\n  )\n\n  if (payload.type === 'character') {\n    const characterId = typeof payload.id === 'string' ? payload.id : null\n    if (!characterId) throw new Error('Global character id missing')\n\n    const character = await db.globalCharacter.findFirst({\n      where: { id: characterId, userId },\n      include: { appearances: { orderBy: { appearanceIndex: 'asc' } } },\n    })\n\n    if (!character) throw new Error('Global character not found')\n\n    const appearanceIndex = Number(payload.appearanceIndex ?? PRIMARY_APPEARANCE_INDEX)\n    const appearance = character.appearances.find((appearanceItem) => appearanceItem.appearanceIndex === appearanceIndex)\n    if (!appearance) throw new Error('Global character appearance not found')\n\n    const modelId = userModels.characterModel\n    if (!modelId) throw new Error('User character model not configured')\n\n    const descriptions = parseJsonStringArray(appearance.descriptions)\n    const base = descriptions.length ? descriptions : [appearance.description || '']\n    const count = normalizeImageGenerationCount('character', payload.count)\n    const imageUrls: string[] = []\n\n    for (let i = 0; i < count; i++) {\n      const raw = base[i] || base[0]\n      const prompt = artStyle ? `${addCharacterPromptSuffix(raw)}，${artStyle}` : addCharacterPromptSuffix(raw)\n      const cosKey = await generateLabeledImageToCos({\n        job,\n        userId,\n        modelId,\n        prompt,\n        label: `${character.name} - ${appearance.changeReason || '形象'}`,\n        targetId: `${appearance.id}-${i}`,\n        keyPrefix: 'global-character',\n        options: {\n          aspectRatio: '3:2',\n        },\n      })\n      imageUrls.push(cosKey)\n    }\n\n    await assertTaskActive(job, 'persist_global_character_image')\n    await db.globalCharacterAppearance.update({\n      where: { id: appearance.id },\n      data: {\n        imageUrls: encodeImageUrls(imageUrls),\n        imageUrl: imageUrls[0] || null,\n        selectedIndex: null,\n      },\n    })\n\n    return { type: payload.type, appearanceId: appearance.id, imageCount: imageUrls.length }\n  }\n\n  if (payload.type === 'location') {\n    const locationId = typeof payload.id === 'string' ? payload.id : null\n    if (!locationId) throw new Error('Global location id missing')\n\n    const location = await db.globalLocation.findFirst({\n      where: { id: locationId, userId },\n      include: { images: { orderBy: { imageIndex: 'asc' } } },\n    })\n\n    if (!location || !location.images?.length) throw new Error('Global location not found')\n\n    const modelId = userModels.locationModel\n    if (!modelId) throw new Error('User location model not configured')\n\n    const count = normalizeImageGenerationCount('location', payload.count)\n    const targetImages = Object.prototype.hasOwnProperty.call(payload, 'count')\n      ? location.images.slice(0, count)\n      : location.images\n\n    for (const image of targetImages) {\n      if (!image.description) continue\n      const prompt = artStyle ? `${addLocationPromptSuffix(image.description)}，${artStyle}` : addLocationPromptSuffix(image.description)\n\n      const cosKey = await generateLabeledImageToCos({\n        job,\n        userId,\n        modelId,\n        prompt,\n        label: location.name,\n        targetId: image.id,\n        keyPrefix: 'global-location',\n        options: {\n          aspectRatio: '1:1',\n        },\n      })\n\n      await assertTaskActive(job, 'persist_global_location_image')\n      await db.globalLocationImage.update({\n        where: { id: image.id },\n        data: { imageUrl: cosKey },\n      })\n    }\n\n    return { type: payload.type, locationId: location.id, imageCount: targetImages.length }\n  }\n\n  throw new Error(`Unsupported asset-hub image type: ${String(payload.type)}`)\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/asset-hub-modify-task-handler.ts",
    "content": "import { type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { type TaskJobData } from '@/lib/task/types'\nimport {\n  assertTaskActive,\n  getUserModels,\n  resolveImageSourceFromGeneration,\n  stripLabelBar,\n  toSignedUrlIfCos,\n  uploadImageSourceToCos,\n  withLabelBar,\n} from '../utils'\nimport {\n  normalizeReferenceImagesForGeneration,\n} from '@/lib/media/outbound-image'\nimport {\n  AnyObj,\n  parseImageUrls,\n} from './image-task-handler-shared'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport {\n  buildCharacterDescriptionFields,\n  generateModifiedAssetDescription,\n  readIndexedDescription,\n} from './modify-description-sync'\n\nconst logger = createScopedLogger({ module: 'worker.asset-hub-modify' })\n\ninterface GlobalCharacterAppearanceRecord {\n  id: string\n  appearanceIndex: number\n  changeReason: string | null\n  description: string | null\n  descriptions: string | null\n  imageUrl: string | null\n  imageUrls: string | null\n  selectedIndex: number | null\n  previousDescription: string | null\n  previousDescriptions: string | null\n}\n\ninterface GlobalCharacterRecord {\n  id: string\n  name: string\n  appearances: GlobalCharacterAppearanceRecord[]\n}\n\ninterface GlobalLocationImageRecord {\n  id: string\n  imageIndex: number\n  description: string | null\n  imageUrl: string | null\n  previousDescription: string | null\n}\n\ninterface GlobalLocationRecord {\n  id: string\n  name: string\n  images: GlobalLocationImageRecord[]\n}\n\ninterface AssetHubModifyDb {\n  globalCharacter: {\n    findFirst(args: Record<string, unknown>): Promise<GlobalCharacterRecord | null>\n  }\n  globalCharacterAppearance: {\n    update(args: Record<string, unknown>): Promise<unknown>\n  }\n  globalLocation: {\n    findFirst(args: Record<string, unknown>): Promise<GlobalLocationRecord | null>\n  }\n  globalLocationImage: {\n    update(args: Record<string, unknown>): Promise<unknown>\n  }\n}\n\nfunction readModifyInstruction(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nexport async function handleAssetHubModifyTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const userId = job.data.userId\n  const db = prisma as unknown as AssetHubModifyDb\n  const userModels = await getUserModels(userId)\n  const editModel = userModels.editModel\n  if (!editModel) throw new Error('User edit model not configured')\n\n  const generationOptions = payload.generationOptions as Record<string, unknown> | undefined\n  const resolution = typeof generationOptions?.resolution === 'string'\n    ? generationOptions.resolution\n    : undefined\n  const modifyInstruction = readModifyInstruction(payload.modifyPrompt)\n\n  if (payload.type === 'character') {\n    const character = await db.globalCharacter.findFirst({\n      where: { id: payload.id, userId },\n      include: { appearances: true },\n    })\n    if (!character) throw new Error('Global character not found')\n\n    const appearanceIndex = Number(payload.appearanceIndex ?? PRIMARY_APPEARANCE_INDEX)\n    const appearance = character.appearances.find((appearanceItem) => appearanceItem.appearanceIndex === appearanceIndex)\n    if (!appearance) throw new Error('Global character appearance not found')\n\n    const imageUrls = parseImageUrls(appearance.imageUrls, 'globalCharacterAppearance.imageUrls')\n    const targetImageIndex = Number(payload.imageIndex ?? appearance.selectedIndex ?? 0)\n    const currentKey = imageUrls[targetImageIndex] || appearance.imageUrl\n    const currentUrl = toSignedUrlIfCos(currentKey, 3600)\n    if (!currentUrl) throw new Error('No global character image to modify')\n\n    const extraReferenceInputs: string[] = []\n    if (Array.isArray(payload.extraImageUrls)) {\n      for (const url of payload.extraImageUrls) {\n        if (typeof url === 'string' && url.trim().length > 0) {\n          extraReferenceInputs.push(url.trim())\n        }\n      }\n    }\n    const requiredReference = await stripLabelBar(currentUrl)\n    const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)\n    const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))\n    const currentDescription = readIndexedDescription({\n      descriptions: appearance.descriptions,\n      fallbackDescription: appearance.description,\n      index: targetImageIndex,\n    })\n\n    const prompt = `请根据以下指令修改图片，保持人物核心特征一致：\\n${modifyInstruction}`\n    const source = await resolveImageSourceFromGeneration(job, {\n      userId,\n      modelId: editModel,\n      prompt,\n      options: {\n        referenceImages,\n        aspectRatio: '3:2',\n        ...(resolution ? { resolution } : {}),\n      },\n    })\n\n    const label = `${character.name} - ${appearance.changeReason || '形象'}`\n    const labeled = await withLabelBar(source, label)\n    const cosKey = await uploadImageSourceToCos(labeled, 'global-character-modify', appearance.id)\n\n    while (imageUrls.length <= targetImageIndex) imageUrls.push('')\n    imageUrls[targetImageIndex] = cosKey\n\n    const selectedIndex = appearance.selectedIndex\n    const shouldUpdateMain = selectedIndex === targetImageIndex || selectedIndex === null || imageUrls.length === 1\n\n    let descriptionFields: { description: string; descriptions: string } | null = null\n    if (currentDescription && modifyInstruction && userModels.analysisModel) {\n      try {\n        const nextDescription = await generateModifiedAssetDescription({\n          userId,\n          model: userModels.analysisModel,\n          locale: job.data.locale,\n          type: 'character',\n          currentDescription,\n          modifyInstruction,\n          referenceImages: normalizedExtras,\n        })\n        descriptionFields = buildCharacterDescriptionFields({\n          descriptions: appearance.descriptions,\n          fallbackDescription: appearance.description,\n          index: targetImageIndex,\n          nextDescription,\n        })\n      } catch (err) {\n        logger.warn({ message: '资产库角色描述同步失败', details: { error: String(err) } })\n      }\n    }\n\n    await assertTaskActive(job, 'persist_global_character_modify')\n    await db.globalCharacterAppearance.update({\n      where: { id: appearance.id },\n      data: {\n        previousImageUrl: appearance.imageUrl || null,\n        previousImageUrls: appearance.imageUrls,\n        previousDescription: appearance.description || null,\n        previousDescriptions: appearance.descriptions ?? null,\n        imageUrls: encodeImageUrls(imageUrls),\n        imageUrl: shouldUpdateMain ? cosKey : appearance.imageUrl,\n        ...(descriptionFields || {}),\n      },\n    })\n\n    return { type: payload.type, appearanceId: appearance.id, imageUrl: cosKey }\n  }\n\n  if (payload.type === 'location') {\n    const location = await db.globalLocation.findFirst({\n      where: { id: payload.id, userId },\n      include: { images: true },\n    })\n    if (!location) throw new Error('Global location not found')\n\n    const targetImageIndex = Number(payload.imageIndex ?? 0)\n    const locationImage = location.images.find((imageItem) => imageItem.imageIndex === targetImageIndex)\n    if (!locationImage?.imageUrl) throw new Error('Global location image not found')\n\n    const currentUrl = toSignedUrlIfCos(locationImage.imageUrl, 3600)\n    if (!currentUrl) throw new Error('No global location image to modify')\n\n    const extraReferenceInputs: string[] = []\n    if (Array.isArray(payload.extraImageUrls)) {\n      for (const url of payload.extraImageUrls) {\n        if (typeof url === 'string' && url.trim().length > 0) {\n          extraReferenceInputs.push(url.trim())\n        }\n      }\n    }\n    const requiredReference = await stripLabelBar(currentUrl)\n    const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)\n    const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))\n\n    const prompt = `请根据以下指令修改场景图片，保持整体风格一致：\\n${modifyInstruction}`\n    const source = await resolveImageSourceFromGeneration(job, {\n      userId,\n      modelId: editModel,\n      prompt,\n      options: {\n        referenceImages,\n        aspectRatio: '1:1',\n        ...(resolution ? { resolution } : {}),\n      },\n    })\n\n    const labeled = await withLabelBar(source, location.name)\n    const cosKey = await uploadImageSourceToCos(labeled, 'global-location-modify', locationImage.id)\n\n    let extractedDescription: string | undefined\n    if (locationImage.description && modifyInstruction && userModels.analysisModel) {\n      try {\n        extractedDescription = await generateModifiedAssetDescription({\n          userId,\n          model: userModels.analysisModel,\n          locale: job.data.locale,\n          type: 'location',\n          currentDescription: locationImage.description,\n          modifyInstruction,\n          referenceImages: normalizedExtras,\n          locationName: location.name,\n        })\n      } catch (err) {\n        logger.warn({ message: '资产库场景描述同步失败', details: { error: String(err) } })\n      }\n    }\n\n    await assertTaskActive(job, 'persist_global_location_modify')\n    await db.globalLocationImage.update({\n      where: { id: locationImage.id },\n      data: {\n        previousImageUrl: locationImage.imageUrl,\n        previousDescription: locationImage.description || null,\n        imageUrl: cosKey,\n        ...(extractedDescription ? { description: extractedDescription } : {}),\n      },\n    })\n\n    return { type: payload.type, locationImageId: locationImage.id, imageUrl: cosKey }\n  }\n\n  throw new Error(`Unsupported asset-hub modify type: ${String(payload.type)}`)\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/character-image-task-handler.ts",
    "content": "import { type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { addCharacterPromptSuffix, getArtStylePrompt, isArtStyleValue, PRIMARY_APPEARANCE_INDEX, type ArtStyleValue } from '@/lib/constants'\nimport { type TaskJobData } from '@/lib/task/types'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\nimport { reportTaskProgress } from '../shared'\nimport {\n  assertTaskActive,\n  getProjectModels,\n  toSignedUrlIfCos,\n} from '../utils'\nimport { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'\nimport {\n  AnyObj,\n  generateLabeledImageToCos,\n  parseImageUrls,\n  parseJsonStringArray,\n  pickFirstString,\n} from './image-task-handler-shared'\n\nfunction resolvePayloadArtStyle(payload: AnyObj): ArtStyleValue | undefined {\n  if (!Object.prototype.hasOwnProperty.call(payload, 'artStyle')) return undefined\n  const parsedArtStyle = typeof payload.artStyle === 'string' ? payload.artStyle.trim() : ''\n  if (!isArtStyleValue(parsedArtStyle)) {\n    throw new Error('Invalid artStyle in IMAGE_CHARACTER payload')\n  }\n  return parsedArtStyle\n}\n\ninterface CharacterAppearanceRecord {\n  id: string\n  characterId: string\n  appearanceIndex: number\n  descriptions: string | null\n  description: string | null\n  imageUrls: string | null\n  selectedIndex: number | null\n  imageUrl: string | null\n  changeReason: string | null\n}\n\ninterface CharacterAppearanceWithCharacter extends CharacterAppearanceRecord {\n  character: {\n    name: string\n  }\n}\n\ninterface CharacterRecord {\n  id: string\n  name: string\n  appearances: CharacterAppearanceRecord[]\n}\n\ninterface PrimaryAppearanceRecord {\n  imageUrl: string | null\n  imageUrls: string | null\n}\n\ninterface CharacterImageDb {\n  characterAppearance: {\n    findUnique(args: Record<string, unknown>): Promise<CharacterAppearanceWithCharacter | null>\n    findFirst(args: Record<string, unknown>): Promise<PrimaryAppearanceRecord | null>\n    update(args: Record<string, unknown>): Promise<unknown>\n  }\n  novelPromotionCharacter: {\n    findUnique(args: Record<string, unknown>): Promise<CharacterRecord | null>\n  }\n}\n\nexport async function handleCharacterImageTask(job: Job<TaskJobData>) {\n  const db = prisma as unknown as CharacterImageDb\n  const payload = (job.data.payload || {}) as AnyObj\n  const projectId = job.data.projectId\n  const userId = job.data.userId\n  const models = await getProjectModels(projectId, userId)\n  const modelId = models.characterModel\n  if (!modelId) throw new Error('Character model not configured')\n\n  const appearanceId = pickFirstString(job.data.targetId, payload.appearanceId)\n  let appearance: CharacterAppearanceRecord | null = null\n  let characterName = '角色'\n\n  if (appearanceId) {\n    const appearanceWithCharacter = await db.characterAppearance.findUnique({\n      where: { id: appearanceId },\n      include: { character: true },\n    })\n    if (appearanceWithCharacter) {\n      appearance = appearanceWithCharacter\n      characterName = appearanceWithCharacter.character.name\n    }\n  }\n\n  const characterId = typeof payload.id === 'string' ? payload.id : null\n  if (!appearance && characterId) {\n    const character = await db.novelPromotionCharacter.findUnique({\n      where: { id: characterId },\n      include: { appearances: { orderBy: { appearanceIndex: 'asc' } } },\n    })\n    appearance = character?.appearances?.[0] || null\n    if (character && appearance) {\n      characterName = character.name\n    }\n  }\n\n  if (!appearance) throw new Error('Character appearance not found')\n\n  const payloadArtStyle = resolvePayloadArtStyle(payload)\n  const artStyle = getArtStylePrompt(payloadArtStyle ?? models.artStyle, job.data.locale)\n  const descriptions = parseJsonStringArray(appearance.descriptions)\n  const baseDescriptions = descriptions.length > 0 ? descriptions : [appearance.description || '']\n\n  // 子形象（不是主形象）生成时，引用主形象图片保持一致性\n  const primaryReferenceInputs: string[] = []\n  if (appearance.appearanceIndex > PRIMARY_APPEARANCE_INDEX) {\n    const primaryAppearance = await db.characterAppearance.findFirst({\n      where: {\n        characterId: appearance.characterId,\n        appearanceIndex: PRIMARY_APPEARANCE_INDEX,\n      },\n      select: { imageUrl: true, imageUrls: true },\n    })\n    if (primaryAppearance) {\n      const primaryMainUrl = primaryAppearance.imageUrl\n        ? toSignedUrlIfCos(primaryAppearance.imageUrl, 3600)\n        : null\n      if (primaryMainUrl) {\n        primaryReferenceInputs.push(primaryMainUrl)\n      }\n    }\n  }\n  const primaryReferenceImages = await normalizeReferenceImagesForGeneration(primaryReferenceInputs)\n\n  const singleIndex = payload.imageIndex ?? payload.descriptionIndex\n  const count = normalizeImageGenerationCount('character', payload.count)\n  const indexes = singleIndex !== undefined\n    ? [Number(singleIndex)]\n    : Array.from({ length: count }, (_value, index) => index)\n\n  const imageUrls = parseImageUrls(appearance.imageUrls, 'characterAppearance.imageUrls')\n  const nextImageUrls = [...imageUrls]\n  const label = `${characterName} - ${appearance.changeReason || '形象'}`\n\n  for (let i = 0; i < indexes.length; i++) {\n    const index = indexes[i]\n    const raw = baseDescriptions[index] || baseDescriptions[0]\n    const prompt = artStyle ? `${addCharacterPromptSuffix(raw)}，${artStyle}` : addCharacterPromptSuffix(raw)\n\n    await reportTaskProgress(job, 15 + Math.floor((i / Math.max(indexes.length, 1)) * 55), {\n      stage: 'generate_character_image',\n      index,\n    })\n\n    const cosKey = await generateLabeledImageToCos({\n      job,\n      userId,\n      modelId,\n      prompt,\n      label,\n      targetId: `${appearance.id}-${index}`,\n      keyPrefix: 'character',\n      options: {\n        referenceImages: primaryReferenceImages.length > 0 ? primaryReferenceImages : undefined,\n        aspectRatio: '3:2',\n      },\n    })\n\n    while (nextImageUrls.length <= index) {\n      nextImageUrls.push('')\n    }\n    nextImageUrls[index] = cosKey\n  }\n\n  const selectedIndex = appearance.selectedIndex\n  const fallbackMain = nextImageUrls.find((url) => typeof url === 'string' && url) || appearance.imageUrl\n  const mainImage = selectedIndex !== null && selectedIndex !== undefined && nextImageUrls[selectedIndex]\n    ? nextImageUrls[selectedIndex]\n    : fallbackMain\n\n  await assertTaskActive(job, 'persist_character_image')\n  await db.characterAppearance.update({\n    where: { id: appearance.id },\n    data: {\n      imageUrls: encodeImageUrls(nextImageUrls),\n      imageUrl: mainImage || null,\n    },\n  })\n\n  return {\n    appearanceId: appearance.id,\n    imageCount: nextImageUrls.filter(Boolean).length,\n    imageUrl: mainImage || null,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/character-profile-helpers.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { safeParseJsonObject } from '@/lib/json-repair'\n\nexport type AnyObj = Record<string, unknown>\n\nexport function readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nexport function readRequiredString(value: unknown, field: string): string {\n  const text = readText(value).trim()\n  if (!text) {\n    throw new Error(`${field} is required`)\n  }\n  return text\n}\n\nexport function parseVisualResponse(responseText: string): AnyObj {\n  return safeParseJsonObject(responseText) as AnyObj\n}\n\nexport async function resolveProjectModel(projectId: string) {\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: {\n      id: true,\n      novelPromotionData: {\n        select: {\n          id: true,\n          analysisModel: true,\n        },\n      },\n    },\n  })\n  if (!project) throw new Error('Project not found')\n  if (!project.novelPromotionData) throw new Error('Novel promotion data not found')\n  if (!project.novelPromotionData.analysisModel) throw new Error('请先在项目设置中配置分析模型')\n  return project\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/character-profile.ts",
    "content": "import type { Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { validateProfileData, stringifyProfileData } from '@/types/character-profile'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\nimport {\n  type AnyObj,\n  parseVisualResponse,\n  readRequiredString,\n  readText,\n  resolveProjectModel,\n} from './character-profile-helpers'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\n\ntype ConfirmProfileOptions = {\n  suppressProgress?: boolean\n}\n\nasync function handleConfirmProfile(\n  job: Job<TaskJobData>,\n  payload: AnyObj,\n  options: ConfirmProfileOptions = {},\n) {\n  const suppressProgress = options.suppressProgress === true\n  const characterId = readRequiredString(payload.characterId, 'characterId')\n  const project = await resolveProjectModel(job.data.projectId)\n\n  const character = await prisma.novelPromotionCharacter.findFirst({\n    where: {\n      id: characterId,\n      novelPromotionProjectId: project.novelPromotionData!.id,\n    },\n  })\n  if (!character) {\n    throw new Error('Character not found')\n  }\n\n  let finalProfileData = character.profileData\n  if (payload.profileData) {\n    if (!validateProfileData(payload.profileData)) {\n      throw new Error('档案数据格式错误')\n    }\n    finalProfileData = stringifyProfileData(payload.profileData)\n    await assertTaskActive(job, 'character_profile_confirm_update_profile')\n    await prisma.novelPromotionCharacter.update({\n      where: { id: characterId },\n      data: { profileData: finalProfileData },\n    })\n  }\n\n  if (!finalProfileData) {\n    throw new Error('角色缺少档案数据')\n  }\n\n  const parsedProfile = JSON.parse(finalProfileData) as AnyObj\n  const promptTemplate = buildPrompt({\n    promptId: PROMPT_IDS.NP_AGENT_CHARACTER_VISUAL,\n    locale: job.data.locale,\n    variables: {\n      character_profiles: JSON.stringify(\n        [\n          {\n            name: character.name,\n            ...parsedProfile,\n          },\n        ],\n        null,\n        2,\n      ),\n    },\n  })\n\n  if (!suppressProgress) {\n    await reportTaskProgress(job, 20, {\n      stage: 'character_profile_confirm_prepare',\n      stageLabel: '准备角色档案确认参数',\n      displayMode: 'detail',\n    })\n  }\n  await assertTaskActive(job, 'character_profile_confirm_prepare')\n\n  const streamContext = createWorkerLLMStreamContext(job, 'character_profile_confirm')\n  const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)\n  const completion = await withInternalLLMStreamCallbacks(\n    streamCallbacks,\n    async () =>\n      await executeAiTextStep({\n        userId: job.data.userId,\n        model: project.novelPromotionData!.analysisModel!,\n        messages: [{ role: 'user', content: promptTemplate }],\n        temperature: 0.7,\n        projectId: job.data.projectId,\n        action: 'generate_character_visual',\n        meta: {\n          stepId: 'character_profile_confirm',\n          stepTitle: '角色档案确认',\n          stepIndex: 1,\n          stepTotal: 1,\n        },\n      }),\n  )\n  await streamCallbacks.flush()\n  await assertTaskActive(job, 'character_profile_confirm_parse')\n\n  const responseText = completion.text\n  const visualData = parseVisualResponse(responseText)\n  const visualCharacters = Array.isArray(visualData.characters)\n    ? (visualData.characters as Array<AnyObj>)\n    : []\n  const firstCharacter = visualCharacters[0]\n  const appearances = Array.isArray(firstCharacter?.appearances)\n    ? (firstCharacter!.appearances as Array<AnyObj>)\n    : []\n  if (appearances.length === 0) {\n    throw new Error('AI返回格式错误: 缺少 appearances')\n  }\n\n  if (!suppressProgress) {\n    await reportTaskProgress(job, 78, {\n      stage: 'character_profile_confirm_persist',\n      stageLabel: '保存角色档案确认结果',\n      displayMode: 'detail',\n    })\n  }\n  await assertTaskActive(job, 'character_profile_confirm_persist')\n\n  for (let appIndex = 0; appIndex < appearances.length; appIndex++) {\n    const app = appearances[appIndex]\n    await assertTaskActive(job, 'character_profile_confirm_create_appearance')\n    const descriptions = Array.isArray(app.descriptions) ? app.descriptions : []\n    const normalizedDescriptions = descriptions.map((item) => readText(item)).filter(Boolean)\n    await prisma.characterAppearance.create({\n      data: {\n        characterId: character.id,\n        appearanceIndex: appIndex,\n        changeReason: readText(app.change_reason) || '初始形象',\n        description: normalizedDescriptions[0] || '',\n        descriptions: JSON.stringify(normalizedDescriptions),\n        imageUrls: encodeImageUrls([]),\n        previousImageUrls: encodeImageUrls([]),\n      },\n    })\n  }\n\n  await prisma.novelPromotionCharacter.update({\n    where: { id: characterId },\n    data: { profileConfirmed: true },\n  })\n\n  if (!suppressProgress) {\n    await reportTaskProgress(job, 96, {\n      stage: 'character_profile_confirm_done',\n      stageLabel: '角色档案确认完成',\n      displayMode: 'detail',\n      meta: { characterId },\n    })\n  }\n\n  return {\n    success: true,\n    character: {\n      ...character,\n      profileConfirmed: true,\n      appearances,\n    },\n  }\n}\n\nasync function handleBatchConfirmProfile(job: Job<TaskJobData>) {\n  const project = await resolveProjectModel(job.data.projectId)\n\n  const unconfirmedCharacters = await prisma.novelPromotionCharacter.findMany({\n    where: {\n      novelPromotionProjectId: project.novelPromotionData!.id,\n      profileConfirmed: false,\n      profileData: { not: null },\n    },\n  })\n\n  if (unconfirmedCharacters.length === 0) {\n    return {\n      success: true,\n      count: 0,\n      message: '没有待确认的角色',\n    }\n  }\n\n  await reportTaskProgress(job, 18, {\n    stage: 'character_profile_batch_prepare',\n    stageLabel: '准备批量角色档案确认参数',\n    displayMode: 'detail',\n    message: `共 ${unconfirmedCharacters.length} 个角色`,\n  })\n  await assertTaskActive(job, 'character_profile_batch_prepare')\n\n  let successCount = 0\n  const totalCount = unconfirmedCharacters.length\n\n  for (let index = 0; index < unconfirmedCharacters.length; index++) {\n    const character = unconfirmedCharacters[index]\n    await assertTaskActive(job, 'character_profile_batch_loop_character')\n    const progress = 18 + Math.floor(((index + 1) / totalCount) * 78)\n    await reportTaskProgress(job, progress, {\n      stage: 'character_profile_batch_loop_character',\n      stageLabel: '批量角色档案确认中',\n      displayMode: 'detail',\n      message: `${index + 1}/${totalCount} ${character.name}`,\n      meta: { characterId: character.id, index: index + 1, total: totalCount },\n    })\n    await handleConfirmProfile(job, { characterId: character.id }, { suppressProgress: true })\n    successCount += 1\n  }\n\n  await reportTaskProgress(job, 96, {\n    stage: 'character_profile_batch_done',\n    stageLabel: '批量角色档案确认完成',\n    displayMode: 'detail',\n    meta: { count: successCount },\n  })\n\n  return {\n    success: true,\n    count: successCount,\n  }\n}\n\nexport async function handleCharacterProfileTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  switch (job.data.type) {\n    case TASK_TYPE.CHARACTER_PROFILE_CONFIRM:\n      return await handleConfirmProfile(job, payload)\n    case TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM:\n      return await handleBatchConfirmProfile(job)\n    default:\n      throw new Error(`Unsupported character profile task type: ${job.data.type}`)\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/clips-build.ts",
    "content": "import type { Job } from 'bullmq'\nimport { safeParseJsonArray } from '@/lib/json-repair'\nimport { prisma } from '@/lib/prisma'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { buildCharactersIntroduction } from '@/lib/constants'\nimport { createClipContentMatcher } from '@/lib/novel-promotion/story-to-script/clip-matching'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport { resolveAnalysisModel } from './resolve-analysis-model'\n\nfunction parseClipArrayResponse(responseText: string): Array<Record<string, unknown>> {\n  return safeParseJsonArray(responseText, 'clips')\n}\n\nfunction readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nconst MAX_SPLIT_BOUNDARY_ATTEMPTS = 2\nconst CLIP_BOUNDARY_SUFFIX = `\n\n[Boundary Constraints]\n1. The \"start\" and \"end\" anchors must come from the original text and be locatable.\n2. Allow punctuation/whitespace differences, but do not rewrite key entities or events.\n3. If anchors cannot be located reliably, return [] directly.`\n\nexport async function handleClipsBuildTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as Record<string, unknown>\n  const projectId = job.data.projectId\n  const episodeId = readText(payload.episodeId || job.data.episodeId).trim()\n  if (!episodeId) {\n    throw new Error('episodeId is required')\n  }\n\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: { id: true, mode: true },\n  })\n  if (!project) {\n    throw new Error('Project not found')\n  }\n  if (project.mode !== 'novel-promotion') {\n    throw new Error('Not a novel promotion project')\n  }\n\n  const novelData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      characters: true,\n      locations: true,\n    },\n  })\n  if (!novelData) {\n    throw new Error('Novel promotion data not found')\n  }\n  const analysisModel = await resolveAnalysisModel({\n    userId: job.data.userId,\n    inputModel: payload.model,\n    projectAnalysisModel: novelData.analysisModel,\n  })\n\n  const episode = await prisma.novelPromotionEpisode.findUnique({\n    where: { id: episodeId },\n    select: {\n      id: true,\n      name: true,\n      novelText: true,\n      novelPromotionProjectId: true,\n    },\n  })\n  if (!episode) {\n    throw new Error('Episode not found')\n  }\n  if (episode.novelPromotionProjectId !== novelData.id) {\n    throw new Error('Episode does not belong to this project')\n  }\n\n  const contentToProcess = readText(episode.novelText)\n  if (!contentToProcess.trim()) {\n    throw new Error('No novel text to process')\n  }\n\n  const locationsLibName = novelData.locations.length > 0\n    ? novelData.locations.map((item) => item.name).join('、')\n    : '无'\n  const charactersLibName = novelData.characters.length > 0\n    ? novelData.characters.map((item) => item.name).join('、')\n    : '无'\n  const charactersIntroduction = buildCharactersIntroduction(novelData.characters)\n  const promptTemplateBase = buildPrompt({\n    promptId: PROMPT_IDS.NP_AGENT_CLIP,\n    locale: job.data.locale,\n    variables: {\n      input: contentToProcess,\n      locations_lib_name: locationsLibName,\n      characters_lib_name: charactersLibName,\n      characters_introduction: charactersIntroduction,\n    },\n  })\n  const promptTemplate = `${promptTemplateBase}${CLIP_BOUNDARY_SUFFIX}`\n\n  await reportTaskProgress(job, 20, {\n    stage: 'clips_build_prepare',\n    stageLabel: '准备片段切分参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'clips_build_prepare')\n\n  const streamContext = createWorkerLLMStreamContext(job, 'clips_build')\n  const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)\n  const resolvedClips: Array<{\n    startText: string\n    endText: string\n    summary: string\n    location: string | null\n    characters: unknown\n    content: string\n  }> = []\n  let lastBoundaryError: Error | null = null\n\n  try {\n    for (let attempt = 1; attempt <= MAX_SPLIT_BOUNDARY_ATTEMPTS; attempt += 1) {\n      const completion = await withInternalLLMStreamCallbacks(\n        streamCallbacks,\n        async () =>\n          await executeAiTextStep({\n            userId: job.data.userId,\n            model: analysisModel,\n            messages: [{ role: 'user', content: promptTemplate }],\n            projectId,\n            action: 'split_clips',\n            meta: {\n              stepId: 'split_clips',\n              stepAttempt: attempt,\n              stepTitle: '片段切分',\n              stepIndex: 1,\n              stepTotal: 1,\n            },\n          }),\n      )\n\n      const responseText = completion.text\n      if (!responseText) {\n        lastBoundaryError = new Error('No response from AI')\n        continue\n      }\n\n      const parsed = parseClipArrayResponse(responseText)\n      if (parsed.length === 0) {\n        lastBoundaryError = new Error('Invalid clips data structure')\n        continue\n      }\n\n      const matcher = createClipContentMatcher(contentToProcess)\n      const currentResolved: typeof resolvedClips = []\n      let searchFrom = 0\n      let failedAt: { index: number; startText: string; endText: string } | null = null\n      for (let i = 0; i < parsed.length; i += 1) {\n        const clipData = parsed[i]\n        const startText = readText(clipData.start)\n        const endText = readText(clipData.end)\n        const match = matcher.matchBoundary(startText, endText, searchFrom)\n        if (!match) {\n          failedAt = { index: i + 1, startText, endText }\n          break\n        }\n        currentResolved.push({\n          startText,\n          endText,\n          summary: readText(clipData.summary),\n          location: readText(clipData.location) || null,\n          characters: clipData.characters,\n          content: contentToProcess.slice(match.startIndex, match.endIndex),\n        })\n        searchFrom = match.endIndex\n      }\n\n      if (!failedAt) {\n        resolvedClips.push(...currentResolved)\n        break\n      }\n\n      lastBoundaryError = new Error(\n        `split_clips boundary matching failed at clip_${failedAt.index}: start=\"${failedAt.startText}\" end=\"${failedAt.endText}\"`,\n      )\n    }\n  } finally {\n    await streamCallbacks.flush()\n  }\n\n  if (resolvedClips.length === 0) {\n    throw lastBoundaryError || new Error('split_clips boundary matching failed')\n  }\n\n  await reportTaskProgress(job, 75, {\n    stage: 'clips_build_persist',\n    stageLabel: '保存片段切分结果',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'clips_build_persist')\n\n  const existingClips = await prisma.novelPromotionClip.findMany({\n    where: { episodeId },\n    orderBy: { createdAt: 'asc' },\n    select: { id: true },\n  })\n  const createdClips: Array<{ id: string }> = []\n  for (let i = 0; i < resolvedClips.length; i += 1) {\n    const clipData = resolvedClips[i]\n    const existing = existingClips[i]\n    if (existing) {\n      const updated = await prisma.novelPromotionClip.update({\n        where: { id: existing.id },\n        data: {\n          startText: clipData.startText,\n          endText: clipData.endText,\n          summary: clipData.summary,\n          location: clipData.location,\n          characters: clipData.characters ? JSON.stringify(clipData.characters) : null,\n          content: clipData.content,\n        },\n        select: { id: true },\n      })\n      createdClips.push(updated)\n      continue\n    }\n\n    const created = await prisma.novelPromotionClip.create({\n      data: {\n        episodeId,\n        startText: clipData.startText,\n        endText: clipData.endText,\n        summary: clipData.summary,\n        location: clipData.location,\n        characters: clipData.characters ? JSON.stringify(clipData.characters) : null,\n        content: clipData.content,\n      },\n      select: { id: true },\n    })\n    createdClips.push(created)\n  }\n\n  const staleIds = existingClips.slice(resolvedClips.length).map((item) => item.id)\n  if (staleIds.length > 0) {\n    await prisma.novelPromotionClip.deleteMany({\n      where: {\n        id: {\n          in: staleIds,\n        },\n      },\n    })\n  }\n\n  await reportTaskProgress(job, 96, {\n    stage: 'clips_build_done',\n    stageLabel: '片段切分已完成',\n    displayMode: 'detail',\n  })\n\n  return {\n    episodeId,\n    count: createdClips.length,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/episode-split.ts",
    "content": "import type { Job } from 'bullmq'\nimport { safeParseJsonObject } from '@/lib/json-repair'\nimport { prisma } from '@/lib/prisma'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { countWords } from '@/lib/word-count'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { getUserModelConfig } from '@/lib/config-service'\nimport { createTextMarkerMatcher } from '@/lib/novel-promotion/story-to-script/clip-matching'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\n\ntype EpisodeSplit = {\n  number?: number\n  title?: string\n  summary?: string\n  startMarker?: string\n  endMarker?: string\n  startIndex?: number\n  endIndex?: number\n}\n\ntype SplitResponse = {\n  episodes?: EpisodeSplit[]\n}\n\nconst MAX_EPISODE_SPLIT_ATTEMPTS = 2\nconst EPISODE_SPLIT_BOUNDARY_SUFFIX = `\n\n[Boundary Constraints]\n1. Each episode MUST include both startMarker and endMarker from the original text.\n2. Markers must be locatable in the original text; allow punctuation/whitespace differences only.\n3. If boundaries cannot be located reliably, return an empty episodes array.`\n\nfunction parseSplitResponse(aiResponse: string): SplitResponse {\n  const parsed = safeParseJsonObject(aiResponse) as SplitResponse\n  if (!parsed || !Array.isArray(parsed.episodes) || parsed.episodes.length === 0) {\n    throw new Error('Failed to parse AI response: invalid episodes payload')\n  }\n  return parsed\n}\n\nfunction readBoundaryMarker(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  const marker = value.trim()\n  return marker.length > 0 ? marker : null\n}\n\nfunction toValidBoundaryIndex(value: unknown, textLength: number): number | null {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return null\n  const idx = Math.floor(value)\n  if (idx < 0 || idx > textLength) return null\n  return idx\n}\n\nexport async function handleEpisodeSplitTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as Record<string, unknown>\n  const projectId = job.data.projectId\n  const content = typeof payload.content === 'string' ? payload.content : ''\n  if (!content || content.length < 100) {\n    throw new Error('文本太短，至少需要 100 字')\n  }\n\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: {\n      id: true,\n      mode: true,\n    },\n  })\n  if (!project) {\n    throw new Error('Project not found')\n  }\n  if (project.mode !== 'novel-promotion') {\n    throw new Error('Not a novel promotion project')\n  }\n\n  const novelProject = await prisma.novelPromotionProject.findFirst({\n    where: { projectId },\n    select: { id: true },\n  })\n  if (!novelProject) {\n    throw new Error('Novel promotion data not found')\n  }\n\n  const userConfig = await getUserModelConfig(job.data.userId)\n  const analysisModel = userConfig.analysisModel\n  if (!analysisModel) {\n    throw new Error('请先在设置页面配置分析模型')\n  }\n\n  const promptBase = buildPrompt({\n    promptId: PROMPT_IDS.NP_EPISODE_SPLIT,\n    locale: job.data.locale,\n    variables: {\n      CONTENT: content,\n    },\n  })\n  const prompt = `${promptBase}${EPISODE_SPLIT_BOUNDARY_SUFFIX}`\n\n  await reportTaskProgress(job, 20, {\n    stage: 'episode_split_prepare',\n    stageLabel: '准备分集参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'episode_split_prepare')\n\n  const streamContext = createWorkerLLMStreamContext(job, 'episode_split')\n  const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)\n  type EpisodeOutput = {\n    number: number\n    title: string\n    summary: string\n    content: string\n    wordCount: number\n  }\n  let episodes: EpisodeOutput[] | null = null\n  let lastError: Error | null = null\n\n  try {\n    for (let attempt = 1; attempt <= MAX_EPISODE_SPLIT_ATTEMPTS; attempt += 1) {\n      try {\n        await assertTaskActive(job, `episode_split_attempt:${attempt}`)\n        const completion = await withInternalLLMStreamCallbacks(\n          streamCallbacks,\n          async () =>\n            await executeAiTextStep({\n              userId: job.data.userId,\n              model: analysisModel,\n              messages: [{ role: 'user', content: prompt }],\n              temperature: 0.3,\n              reasoning: true,\n              reasoningEffort: 'high',\n              projectId,\n              action: 'episode_split',\n              meta: {\n                stepId: 'episode_split',\n                stepAttempt: attempt,\n                stepTitle: '智能分集',\n                stepIndex: 1,\n                stepTotal: 1,\n              },\n            }),\n        )\n\n        const aiResponse = completion.text\n        if (!aiResponse) {\n          throw new Error('AI 返回为空')\n        }\n\n        await reportTaskProgress(job, 60, {\n          stage: 'episode_split_parse',\n          stageLabel: attempt === 1 ? '解析分集结果' : `解析分集结果（重试 ${attempt - 1}）`,\n          displayMode: 'detail',\n        })\n        await assertTaskActive(job, 'episode_split_parse')\n\n        const splitResult = parseSplitResponse(aiResponse)\n        const splitEpisodes = splitResult.episodes || []\n        if (splitEpisodes.length === 0) {\n          throw new Error('分集结果为空')\n        }\n\n        await reportTaskProgress(job, 80, {\n          stage: 'episode_split_match',\n          stageLabel: '匹配剧集内容范围',\n          displayMode: 'detail',\n        })\n        const markerMatcher = createTextMarkerMatcher(content)\n        const resolved: EpisodeOutput[] = []\n        let searchFrom = 0\n\n        for (let idx = 0; idx < splitEpisodes.length; idx += 1) {\n          await assertTaskActive(job, `episode_split_match:${idx + 1}`)\n          const ep = splitEpisodes[idx]\n          const episodeNumber =\n            typeof ep.number === 'number' && Number.isFinite(ep.number) && ep.number > 0\n              ? Math.floor(ep.number)\n              : null\n          if (episodeNumber === null) {\n            throw new Error(`episode_${idx + 1} 缺少有效 number`)\n          }\n\n          const title = typeof ep.title === 'string' ? ep.title.trim() : ''\n          if (!title) {\n            throw new Error(`episode_${idx + 1} 缺少 title`)\n          }\n\n          const startMarker = readBoundaryMarker(ep.startMarker)\n          const endMarker = readBoundaryMarker(ep.endMarker)\n          if (!startMarker || !endMarker) {\n            throw new Error(`episode_${idx + 1} 必须同时提供 startMarker/endMarker`)\n          }\n\n          const startMatch = markerMatcher.matchMarker(startMarker, searchFrom)\n          if (!startMatch) {\n            throw new Error(`episode_${idx + 1} startMarker 无法定位`)\n          }\n          const endMatch = markerMatcher.matchMarker(endMarker, startMatch.endIndex)\n          if (!endMatch) {\n            throw new Error(`episode_${idx + 1} endMarker 无法定位`)\n          }\n\n          const rawStartIndex = toValidBoundaryIndex(ep.startIndex, content.length)\n          if (rawStartIndex !== null && Math.abs(rawStartIndex - startMatch.startIndex) > 200) {\n            throw new Error(`episode_${idx + 1} startIndex 与 marker 偏差过大`)\n          }\n          const rawEndIndex = toValidBoundaryIndex(ep.endIndex, content.length)\n          if (rawEndIndex !== null && Math.abs(rawEndIndex - endMatch.endIndex) > 200) {\n            throw new Error(`episode_${idx + 1} endIndex 与 marker 偏差过大`)\n          }\n\n          const startPos = startMatch.startIndex\n          const endPos = endMatch.endIndex\n          if (startPos < searchFrom || endPos <= startPos || endPos > content.length) {\n            throw new Error(`episode_${idx + 1} 边界区间无效`)\n          }\n\n          const episodeContent = content.slice(startPos, endPos).trim()\n          if (!episodeContent) {\n            throw new Error(`episode_${idx + 1} 匹配内容为空`)\n          }\n\n          resolved.push({\n            number: episodeNumber,\n            title,\n            summary: typeof ep.summary === 'string' ? ep.summary : '',\n            content: episodeContent,\n            wordCount: countWords(episodeContent),\n          })\n          searchFrom = endPos\n        }\n\n        episodes = resolved\n        break\n      } catch (error) {\n        lastError = error instanceof Error ? error : new Error(String(error))\n      }\n    }\n  } finally {\n    await streamCallbacks.flush()\n  }\n\n  if (!episodes) {\n    throw lastError || new Error('分集边界匹配失败')\n  }\n\n  await reportTaskProgress(job, 96, {\n    stage: 'episode_split_done',\n    stageLabel: '智能分集完成',\n    displayMode: 'detail',\n  })\n\n  return {\n    success: true,\n    episodes,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/image-task-handler-shared.ts",
    "content": "import { type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { type TaskJobData } from '@/lib/task/types'\nimport { decodeImageUrlsFromDb } from '@/lib/contracts/image-urls-contract'\nimport {\n  resolveImageSourceFromGeneration,\n  toSignedUrlIfCos,\n  uploadImageSourceToCos,\n  withLabelBar,\n} from '../utils'\n\nexport type AnyObj = Record<string, unknown>\n\ninterface CharacterAppearanceLike {\n  appearanceIndex?: number\n  changeReason: string | null\n  description?: string | null\n  descriptions?: string | null\n  imageUrls: string | null\n  imageUrl: string | null\n  selectedIndex: number | null\n}\n\ninterface CharacterLike {\n  name: string\n  appearances?: CharacterAppearanceLike[]\n}\n\ninterface LocationImageLike {\n  description?: string | null\n  imageIndex?: number\n  isSelected: boolean\n  imageUrl: string | null\n}\n\ninterface LocationLike {\n  name: string\n  images?: LocationImageLike[]\n}\n\ninterface NovelProjectData {\n  videoRatio?: string | null\n  characters?: CharacterLike[]\n  locations?: LocationLike[]\n}\n\ninterface PanelLike {\n  sketchImageUrl?: string | null\n  characters?: string | null\n  location?: string | null\n}\n\nexport interface PanelCharacterReference {\n  name: string\n  appearance?: string\n}\n\ninterface NovelDataDb {\n  novelPromotionProject: {\n    findUnique(args: Record<string, unknown>): Promise<NovelProjectData | null>\n  }\n}\n\nexport function parseJsonStringArray(value: unknown): string[] {\n  if (!value) return []\n  if (Array.isArray(value)) {\n    return value.filter((item): item is string => typeof item === 'string')\n  }\n  if (typeof value !== 'string') return []\n  try {\n    const parsed = JSON.parse(value)\n    if (!Array.isArray(parsed)) return []\n    return parsed.filter((item): item is string => typeof item === 'string')\n  } catch {\n    return []\n  }\n}\n\nexport function parseImageUrls(value: string | null | undefined, fieldName: string): string[] {\n  return decodeImageUrlsFromDb(value, fieldName)\n}\n\nexport function clampCount(value: unknown, min: number, max: number, fallback: number) {\n  const n = Number(value)\n  if (!Number.isFinite(n)) return fallback\n  return Math.max(min, Math.min(max, Math.floor(n)))\n}\n\nexport function pickFirstString(...values: unknown[]) {\n  for (const value of values) {\n    if (typeof value === 'string' && value.trim()) return value\n  }\n  return null\n}\n\nexport async function generateLabeledImageToCos(params: {\n  job: Job<TaskJobData>\n  userId: string\n  modelId: string\n  prompt: string\n  label: string\n  targetId: string\n  keyPrefix: string\n  options?: {\n    referenceImages?: string[]\n    aspectRatio?: string\n    size?: string\n  }\n}) {\n  const source = await resolveImageSourceFromGeneration(params.job, {\n    userId: params.userId,\n    modelId: params.modelId,\n    prompt: params.prompt,\n    options: params.options,\n  })\n\n  const labeled = await withLabelBar(source, params.label)\n  const cosKey = await uploadImageSourceToCos(labeled, params.keyPrefix, params.targetId)\n  return cosKey\n}\n\nexport async function resolveNovelData(projectId: string) {\n  const db = prisma as unknown as NovelDataDb\n  const data = await db.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      characters: { include: { appearances: { orderBy: { appearanceIndex: 'asc' } } } },\n      locations: { include: { images: { orderBy: { imageIndex: 'asc' } } } },\n    },\n  })\n\n  if (!data) {\n    throw new Error(`NovelPromotionProject not found: ${projectId}`)\n  }\n\n  return data\n}\n\nexport function parsePanelCharacterReferences(value: string | null | undefined): PanelCharacterReference[] {\n  if (!value) return []\n  try {\n    const parsed = JSON.parse(value)\n    if (!Array.isArray(parsed)) return []\n    return parsed\n      .map((item: unknown) => {\n        if (typeof item === 'string') return { name: item }\n        if (!item || typeof item !== 'object') return null\n        const candidate = item as { name?: unknown; appearance?: unknown }\n        if (typeof candidate.name === 'string') {\n          return {\n            name: candidate.name,\n            appearance: typeof candidate.appearance === 'string' ? candidate.appearance : undefined,\n          }\n        }\n        return null\n      })\n      .filter(Boolean) as PanelCharacterReference[]\n  } catch {\n    return []\n  }\n}\n\n/**\n * 按角色名查找角色（支持别名匹配）\n * 优先级：1. 精确全名匹配  2. 按 '/' 拆分后别名精确匹配\n * 例：引用名 \"顾娘子\" 可匹配角色 \"顾娘子/顾盼之\"\n */\nexport function findCharacterByName<T extends { name: string }>(characters: T[], referenceName: string): T | undefined {\n  const refLower = referenceName.toLowerCase().trim()\n  if (!refLower) return undefined\n\n  // 优先级 1：精确全名匹配\n  const exact = characters.find((c) => c.name.toLowerCase().trim() === refLower)\n  if (exact) return exact\n\n  // 优先级 2：别名匹配 — 按 '/' 拆分后任一别名精确匹配\n  const refAliases = refLower.split('/').map((s) => s.trim()).filter(Boolean)\n  for (const character of characters) {\n    const charAliases = character.name.toLowerCase().split('/').map((s) => s.trim()).filter(Boolean)\n    const hasOverlap = refAliases.some((refAlias) => charAliases.includes(refAlias))\n    if (hasOverlap) return character\n  }\n\n  return undefined\n}\n\nexport async function collectPanelReferenceImages(projectData: NovelProjectData, panel: PanelLike) {\n  const refs: string[] = []\n\n  const sketch = toSignedUrlIfCos(panel.sketchImageUrl, 3600)\n  if (sketch) refs.push(sketch)\n\n  const panelCharacters = parsePanelCharacterReferences(panel.characters)\n  for (const item of panelCharacters) {\n    const character = findCharacterByName(projectData.characters || [], item.name)\n    if (!character) continue\n\n    const appearances = character.appearances || []\n    let appearance = appearances[0]\n    if (item.appearance) {\n      const matched = appearances.find((a) => (a.changeReason || '').toLowerCase() === item.appearance!.toLowerCase())\n      if (matched) appearance = matched\n    }\n\n    if (!appearance) continue\n\n    const imageUrls = parseImageUrls(appearance.imageUrls, 'characterAppearance.imageUrls')\n    const selectedIndex = appearance.selectedIndex\n    const selectedUrl = selectedIndex !== null && selectedIndex !== undefined ? imageUrls[selectedIndex] : null\n    const key = selectedUrl || imageUrls[0] || appearance.imageUrl\n    const signed = toSignedUrlIfCos(key, 3600)\n    if (signed) refs.push(signed)\n  }\n\n  if (panel.location) {\n    const location = (projectData.locations || []).find((loc) => loc.name.toLowerCase() === panel.location!.toLowerCase())\n    if (location) {\n      const images = location.images || []\n      const selected = images.find((img) => img.isSelected) || images[0]\n      const signed = toSignedUrlIfCos(selected?.imageUrl, 3600)\n      if (signed) refs.push(signed)\n    }\n  }\n\n  return refs\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/image-task-handlers-core.ts",
    "content": "import { type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { type TaskJobData } from '@/lib/task/types'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport {\n  assertTaskActive,\n  getProjectModels,\n  getUserModels,\n  resolveImageSourceFromGeneration,\n  stripLabelBar,\n  toSignedUrlIfCos,\n  uploadImageSourceToCos,\n  withLabelBar,\n} from '../utils'\nimport {\n  normalizeReferenceImagesForGeneration,\n  normalizeToBase64ForGeneration,\n} from '@/lib/media/outbound-image'\nimport {\n  AnyObj,\n  parseImageUrls,\n  pickFirstString,\n  resolveNovelData,\n} from './image-task-handler-shared'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport {\n  buildCharacterDescriptionFields,\n  generateModifiedAssetDescription,\n  readIndexedDescription,\n} from './modify-description-sync'\n\nconst logger = createScopedLogger({ module: 'worker.modify-asset-image' })\n\ninterface LocationImageRecord {\n  id: string\n  locationId: string\n  description: string | null\n  imageUrl: string | null\n  previousDescription: string | null\n  location: {\n    name: string\n  } | null\n}\n\nexport async function handleModifyAssetImageTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const type = payload.type\n  const modifyPrompt = payload.modifyPrompt\n\n  if (!type || !modifyPrompt) {\n    throw new Error('modify task missing type/modifyPrompt')\n  }\n\n  const projectModels = await getProjectModels(job.data.projectId, job.data.userId)\n  const editModel = projectModels.editModel\n  if (!editModel) throw new Error('Edit model not configured')\n\n  // 从 payload.generationOptions 读取 resolution（由 route 层 buildImageBillingPayload 注入）\n  // 与老版本 getModelResolution 等价，但数据来源改为 capabilityDefaults/capabilityOverrides 体系\n  const generationOptions = payload.generationOptions as Record<string, unknown> | undefined\n  const resolution = typeof generationOptions?.resolution === 'string'\n    ? generationOptions.resolution\n    : undefined\n  const modifyInstruction = typeof modifyPrompt === 'string' ? modifyPrompt.trim() : ''\n\n  if (type === 'character') {\n    const appearanceId = pickFirstString(payload.appearanceId, payload.targetId, job.data.targetId)\n    if (!appearanceId) throw new Error('character appearance id missing')\n\n    const appearance = await prisma.characterAppearance.findUnique({\n      where: { id: appearanceId },\n      include: { character: true },\n    })\n    if (!appearance) throw new Error('Character appearance not found')\n\n    const imageIndex = Number(payload.imageIndex ?? appearance.selectedIndex ?? 0)\n    const imageUrls = parseImageUrls(appearance.imageUrls, 'characterAppearance.imageUrls')\n    const currentKey = imageUrls[imageIndex] || appearance.imageUrl\n    const currentUrl = toSignedUrlIfCos(currentKey, 3600)\n    if (!currentUrl) throw new Error('No image to modify')\n\n    const requiredReference = await stripLabelBar(currentUrl)\n    const extraReferenceInputs: string[] = []\n    if (Array.isArray(payload.extraImageUrls)) {\n      for (const url of payload.extraImageUrls) {\n        if (typeof url === 'string' && url.trim().length > 0) {\n          extraReferenceInputs.push(url.trim())\n        }\n      }\n    }\n    const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)\n    const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))\n    const currentDescription = readIndexedDescription({\n      descriptions: appearance.descriptions,\n      fallbackDescription: appearance.description,\n      index: imageIndex,\n    })\n\n    const prompt = `请根据以下指令修改图片，保持人物核心特征一致：\\n${modifyInstruction}`\n    const source = await resolveImageSourceFromGeneration(job, {\n      userId: job.data.userId,\n      modelId: editModel,\n      prompt,\n      options: {\n        referenceImages,\n        aspectRatio: '3:2',\n        ...(resolution ? { resolution } : {}),\n      },\n    })\n\n    const label = `${appearance.character?.name || '角色'} - ${appearance.changeReason || '形象'}`\n    const labeled = await withLabelBar(source, label)\n    const cosKey = await uploadImageSourceToCos(labeled, 'character-modify', appearance.id)\n\n    while (imageUrls.length <= imageIndex) imageUrls.push('')\n    imageUrls[imageIndex] = cosKey\n\n    const selectedIndex = appearance.selectedIndex\n    const shouldUpdateMain = selectedIndex === imageIndex || (selectedIndex === null && imageIndex === 0) || imageUrls.length === 1\n\n    let descriptionFields: { description: string; descriptions: string } | null = null\n    if (currentDescription && modifyInstruction) {\n      try {\n        const userModels = await getUserModels(job.data.userId)\n        const analysisModel = userModels.analysisModel\n        if (analysisModel) {\n          const nextDescription = await generateModifiedAssetDescription({\n            userId: job.data.userId,\n            model: analysisModel,\n            locale: job.data.locale,\n            type: 'character',\n            currentDescription,\n            modifyInstruction,\n            referenceImages: normalizedExtras,\n            projectId: job.data.projectId,\n          })\n          descriptionFields = buildCharacterDescriptionFields({\n            descriptions: appearance.descriptions,\n            fallbackDescription: appearance.description,\n            index: imageIndex,\n            nextDescription,\n          })\n        }\n      } catch (err) {\n        logger.warn({ message: '项目角色描述同步失败，不影响改图结果', details: { error: String(err) } })\n      }\n    }\n\n    await assertTaskActive(job, 'persist_character_modify')\n    await prisma.characterAppearance.update({\n      where: { id: appearance.id },\n      data: {\n        previousImageUrl: appearance.imageUrl || null,\n        previousImageUrls: appearance.imageUrls,\n        previousDescription: appearance.description || null,\n        previousDescriptions: appearance.descriptions || null,\n        imageUrls: encodeImageUrls(imageUrls),\n        imageUrl: shouldUpdateMain ? cosKey : appearance.imageUrl,\n        ...(descriptionFields || {}),\n      },\n    })\n\n    return { type, appearanceId: appearance.id, imageIndex, imageUrl: cosKey }\n  }\n\n  if (type === 'location') {\n    const locationImageId = pickFirstString(payload.locationImageId, payload.targetId, job.data.targetId)\n    let locationImage: LocationImageRecord | null = locationImageId\n      ? await prisma.locationImage.findUnique({\n        where: { id: locationImageId },\n        include: { location: true },\n      }) as unknown as LocationImageRecord | null\n      : null\n\n    const payloadLocationId = typeof payload.locationId === 'string' ? payload.locationId : null\n    if (!locationImage && payloadLocationId) {\n      locationImage = await prisma.locationImage.findFirst({\n        where: { locationId: payloadLocationId, imageIndex: Number(payload.imageIndex ?? 0) },\n        include: { location: true },\n      }) as unknown as LocationImageRecord | null\n    }\n\n    if (!locationImage || !locationImage.imageUrl) {\n      throw new Error('Location image not found')\n    }\n\n    const currentUrl = toSignedUrlIfCos(locationImage.imageUrl, 3600)\n    if (!currentUrl) throw new Error('No location image url')\n\n    const requiredReference = await stripLabelBar(currentUrl)\n    const extraReferenceInputs: string[] = []\n    if (Array.isArray(payload.extraImageUrls)) {\n      for (const url of payload.extraImageUrls) {\n        if (typeof url === 'string' && url.trim().length > 0) {\n          extraReferenceInputs.push(url.trim())\n        }\n      }\n    }\n    const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)\n    const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))\n\n    const prompt = `请根据以下指令修改场景图片，保持整体风格一致：\\n${modifyInstruction}`\n    const source = await resolveImageSourceFromGeneration(job, {\n      userId: job.data.userId,\n      modelId: editModel,\n      prompt,\n      options: {\n        referenceImages,\n        aspectRatio: '1:1',\n        ...(resolution ? { resolution } : {}),\n      },\n    })\n\n    const label = locationImage.location?.name || '场景'\n    const labeled = await withLabelBar(source, label)\n    const cosKey = await uploadImageSourceToCos(labeled, 'location-modify', locationImage.id)\n\n    let extractedDescription: string | undefined\n    if (locationImage.description && modifyInstruction) {\n      try {\n        const userModels = await getUserModels(job.data.userId)\n        const analysisModel = userModels.analysisModel\n        if (analysisModel) {\n          extractedDescription = await generateModifiedAssetDescription({\n            userId: job.data.userId,\n            model: analysisModel,\n            locale: job.data.locale,\n            type: 'location',\n            currentDescription: locationImage.description,\n            modifyInstruction,\n            referenceImages: normalizedExtras,\n            locationName: locationImage.location?.name || '场景',\n            projectId: job.data.projectId,\n          })\n        }\n      } catch (err) {\n        logger.warn({ message: '项目场景描述同步失败，不影响改图结果', details: { error: String(err) } })\n      }\n    }\n\n    await assertTaskActive(job, 'persist_location_modify')\n    await prisma.locationImage.update({\n      where: { id: locationImage.id },\n      data: {\n        previousImageUrl: locationImage.imageUrl,\n        previousDescription: locationImage.description || null,\n        imageUrl: cosKey,\n        ...(extractedDescription ? { description: extractedDescription } : {}),\n      },\n    })\n\n    return { type, locationImageId: locationImage.id, imageUrl: cosKey }\n  }\n\n  if (type === 'storyboard') {\n    const panelId = pickFirstString(payload.panelId, payload.targetId, job.data.targetId)\n    let panel = panelId\n      ? await prisma.novelPromotionPanel.findUnique({\n        where: { id: panelId },\n        select: {\n          id: true,\n          storyboardId: true,\n          panelIndex: true,\n          imageUrl: true,\n          previousImageUrl: true,\n        },\n      })\n      : null\n\n    const storyboardId = pickFirstString(payload.storyboardId)\n    if (!panel && storyboardId && payload.panelIndex !== undefined) {\n      panel = await prisma.novelPromotionPanel.findFirst({\n        where: {\n          storyboardId,\n          panelIndex: Number(payload.panelIndex),\n        },\n        select: {\n          id: true,\n          storyboardId: true,\n          panelIndex: true,\n          imageUrl: true,\n          previousImageUrl: true,\n        },\n      })\n    }\n\n    if (!panel || !panel.imageUrl) {\n      throw new Error('Storyboard panel image not found')\n    }\n\n    const currentUrl = toSignedUrlIfCos(panel.imageUrl, 3600)\n    if (!currentUrl) throw new Error('No storyboard panel image url')\n\n    const projectData = await resolveNovelData(job.data.projectId)\n    if (!projectData.videoRatio) throw new Error('Project videoRatio not configured')\n    const aspectRatio = projectData.videoRatio\n    const requiredReference = await normalizeToBase64ForGeneration(currentUrl)\n    const extraReferenceInputs: string[] = []\n\n    const selectedAssets = Array.isArray(payload.selectedAssets)\n      ? payload.selectedAssets\n      : []\n    for (const asset of selectedAssets) {\n      if (!asset || typeof asset !== 'object') continue\n      const assetImage = (asset as AnyObj).imageUrl\n      if (typeof assetImage === 'string' && assetImage.trim()) {\n        extraReferenceInputs.push(assetImage.trim())\n      }\n    }\n\n    if (Array.isArray(payload.extraImageUrls)) {\n      for (const url of payload.extraImageUrls) {\n        if (typeof url === 'string' && url.trim().length > 0) {\n          extraReferenceInputs.push(url.trim())\n        }\n      }\n    }\n\n    const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)\n    const uniqueReferences = Array.from(new Set([requiredReference, ...normalizedExtras]))\n    const prompt = `请根据以下指令修改分镜图片，保持镜头语言和主体一致：\\n${modifyPrompt}`\n    const source = await resolveImageSourceFromGeneration(job, {\n      userId: job.data.userId,\n      modelId: editModel,\n      prompt,\n      options: {\n        referenceImages: uniqueReferences,\n        aspectRatio,\n        ...(resolution ? { resolution } : {}),\n      },\n    })\n\n    const cosKey = await uploadImageSourceToCos(source, 'panel-modify', panel.id)\n\n    await assertTaskActive(job, 'persist_storyboard_modify')\n    await prisma.novelPromotionPanel.update({\n      where: { id: panel.id },\n      data: {\n        previousImageUrl: panel.imageUrl || panel.previousImageUrl || null,\n        imageUrl: cosKey,\n        candidateImages: null,\n      },\n    })\n\n    return {\n      type,\n      panelId: panel.id,\n      imageUrl: cosKey,\n    }\n  }\n\n  throw new Error(`Unsupported modify type: ${String(type)}`)\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/image-task-handlers.ts",
    "content": "export { handleCharacterImageTask } from './character-image-task-handler'\nexport { handleLocationImageTask } from './location-image-task-handler'\nexport { handlePanelImageTask } from './panel-image-task-handler'\nexport { handleModifyAssetImageTask } from './modify-asset-image-task-handler'\nexport { handleAssetHubImageTask } from './asset-hub-image-task-handler'\nexport { handleAssetHubModifyTask } from './asset-hub-modify-task-handler'\nexport { handlePanelVariantTask } from './panel-variant-task-handler'\n"
  },
  {
    "path": "src/lib/workers/handlers/llm-proxy.ts",
    "content": "import type { Job } from 'bullmq'\nimport { INTERNAL_TASK_API_BASE_URL, INTERNAL_TASK_TOKEN } from '@/lib/llm-observe/config'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { type TaskJobData, type TaskType } from '@/lib/task/types'\n\nconst LLM_PROXY_ROUTES: Record<string, (job: Job<TaskJobData>) => string> = {}\n\nfunction getRouteByTaskType(type: TaskType, job: Job<TaskJobData>) {\n  const resolver = LLM_PROXY_ROUTES[type]\n  if (!resolver) {\n    throw new Error(`Unsupported llm proxy task type: ${type}`)\n  }\n  return resolver(job)\n}\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction toAbsoluteUrl(pathname: string) {\n  return new URL(pathname, INTERNAL_TASK_API_BASE_URL).toString()\n}\n\nfunction toErrorMessage(status: number, body: unknown) {\n  const payload = toObject(body)\n  const nestedError = toObject(payload.error)\n  const message =\n    (typeof payload.message === 'string' && payload.message) ||\n    (typeof payload.error === 'string' && payload.error) ||\n    (typeof nestedError.message === 'string' && nestedError.message) ||\n    (typeof payload.detail === 'string' && payload.detail) ||\n    null\n  if (message) return message\n  return `Internal route failed with status ${status}`\n}\n\nexport async function handleLLMProxyTask(job: Job<TaskJobData>) {\n  const route = getRouteByTaskType(job.data.type, job)\n  const payload = {\n    ...toObject(job.data.payload),\n    sync: 1,\n  }\n\n  await reportTaskProgress(job, 15, {\n    stage: 'llm_proxy_submit',\n    displayMode: 'detail',\n    meta: {\n      route,\n    },\n  })\n\n  const headers: Record<string, string> = {\n    'content-type': 'application/json',\n    'x-internal-user-id': job.data.userId,\n    'x-internal-task-stream': '1',\n    'x-internal-task-id': job.data.taskId,\n    'x-internal-project-id': job.data.projectId,\n    'x-internal-task-type': job.data.type,\n    'x-internal-target-type': job.data.targetType,\n    'x-internal-target-id': job.data.targetId,\n  }\n  if (job.data.episodeId) {\n    headers['x-internal-episode-id'] = job.data.episodeId\n  }\n\n  if (INTERNAL_TASK_TOKEN) {\n    headers['x-internal-task-token'] = INTERNAL_TASK_TOKEN\n  }\n\n  const url = toAbsoluteUrl(route)\n  await reportTaskProgress(job, 45, {\n    stage: 'llm_proxy_execute',\n    displayMode: 'detail',\n    meta: {\n      route,\n    },\n  })\n\n  const response = await fetch(url, {\n    method: 'POST',\n    headers,\n    cache: 'no-store',\n    body: JSON.stringify(payload),\n  })\n\n  const rawText = await response.text()\n  let parsed: unknown = null\n  if (rawText) {\n    try {\n      parsed = JSON.parse(rawText)\n    } catch {\n      parsed = rawText\n    }\n  }\n\n  if (!response.ok) {\n    throw new Error(toErrorMessage(response.status, parsed))\n  }\n\n  await assertTaskActive(job, 'llm_proxy_persist')\n  await reportTaskProgress(job, 92, {\n    stage: 'llm_proxy_persist',\n    stageLabel: '保存模型结果',\n    displayMode: 'detail',\n    message: '模型输出完成，正在保存结果...',\n    meta: {\n      route,\n    },\n  })\n\n  return toObject(parsed)\n}\n\nexport function isLLMProxyTaskType(type: TaskType) {\n  return Object.prototype.hasOwnProperty.call(LLM_PROXY_ROUTES, type)\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/llm-stream.ts",
    "content": "import type { Job } from 'bullmq'\nimport { type InternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport type { LLMStreamKind } from '@/lib/llm-observe/types'\nimport { TaskTerminatedError } from '@/lib/task/errors'\nimport { isTaskActive } from '@/lib/task/service'\nimport { reportTaskProgress, reportTaskStreamChunk } from '@/lib/workers/shared'\nimport type { TaskJobData } from '@/lib/task/types'\nimport { assertTaskActive } from '@/lib/workers/utils'\n\nexport type WorkerLLMStreamContext = {\n  streamRunId: string\n  nextSeqByStepLane: Record<string, number>\n}\n\nexport type WorkerInternalLLMStreamCallbacks = InternalLLMStreamCallbacks & {\n  flush: () => Promise<void>\n}\n\nexport type WorkerLLMActiveController = {\n  assertActive?: (stage: string) => Promise<void>\n  isActive?: () => Promise<boolean>\n}\n\nexport function createWorkerLLMStreamContext(job: Job<TaskJobData>, label = 'worker'): WorkerLLMStreamContext {\n  return {\n    streamRunId: `run:${job.data.taskId}:${label}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`,\n    nextSeqByStepLane: {},\n  }\n}\n\nfunction nextWorkerStreamSeq(streamContext: WorkerLLMStreamContext, stepId: string | null, lane: string) {\n  const key = `${stepId || '__default'}|${lane || 'main'}`\n  const current = streamContext.nextSeqByStepLane[key] || 1\n  streamContext.nextSeqByStepLane[key] = current + 1\n  return current\n}\n\nexport function createWorkerLLMStreamCallbacks(\n  job: Job<TaskJobData>,\n  streamContext: WorkerLLMStreamContext,\n  activeController?: WorkerLLMActiveController,\n): WorkerInternalLLMStreamCallbacks {\n  const maxChunkChars = 128\n  const activeProbeIntervalMs = 600\n  let publishQueue: Promise<void> = Promise.resolve()\n  let terminatedError: TaskTerminatedError | null = null\n  let checkingActive = false\n  let lastActiveProbeAt = 0\n\n  const markTerminated = (stage: string) => {\n    if (terminatedError) return\n    terminatedError = new TaskTerminatedError(\n      job.data.taskId,\n      `Task terminated during ${stage}`,\n    )\n  }\n\n  const ensureActiveOrThrow = (stage: string) => {\n    void stage\n    if (terminatedError) throw terminatedError\n  }\n\n  const assertActive = async (stage: string) => {\n    if (activeController?.assertActive) {\n      await activeController.assertActive(stage)\n      return\n    }\n    await assertTaskActive(job, stage)\n  }\n\n  const probeActive = async () => {\n    if (activeController?.isActive) {\n      return await activeController.isActive()\n    }\n    return await isTaskActive(job.data.taskId)\n  }\n\n  const scheduleActiveProbe = () => {\n    if (terminatedError || checkingActive) return\n    const now = Date.now()\n    if (now - lastActiveProbeAt < activeProbeIntervalMs) return\n    checkingActive = true\n    lastActiveProbeAt = now\n    void probeActive()\n      .then((active) => {\n        if (!active) {\n          markTerminated('worker_llm_stream_probe')\n        }\n      })\n      .finally(() => {\n        checkingActive = false\n      })\n  }\n\n  const enqueue = (stage: string, work: () => Promise<void>) => {\n    ensureActiveOrThrow(stage)\n    scheduleActiveProbe()\n    publishQueue = publishQueue\n      .catch(() => undefined)\n      .then(async () => {\n        ensureActiveOrThrow(stage)\n        await assertActive(stage)\n        await work()\n      })\n      .catch((error) => {\n        if (error instanceof TaskTerminatedError) {\n          markTerminated(stage)\n          return\n        }\n        throw error\n      })\n  }\n\n  return {\n    onStage: ({ stage, provider, step }) => {\n      ensureActiveOrThrow(`worker_llm_stage:${stage}`)\n      scheduleActiveProbe()\n      const stageLabel =\n        stage === 'submit'\n          ? 'progress.runtime.stage.llmSubmit'\n          : stage === 'streaming'\n            ? 'progress.runtime.stage.llmStreaming'\n            : stage === 'fallback'\n              ? 'progress.runtime.stage.llmFallbackNonStream'\n              : 'progress.runtime.stage.llmCompleted'\n      const stageKey = `worker_llm_${stage}`\n      const stepId = typeof step?.id === 'string' && step.id.trim() ? step.id.trim() : null\n      const stepAttempt =\n        typeof step?.attempt === 'number' && Number.isFinite(step.attempt)\n          ? Math.max(1, Math.floor(step.attempt))\n          : null\n      const stepTitle = typeof step?.title === 'string' && step.title.trim() ? step.title.trim() : null\n      const stepIndex =\n        typeof step?.index === 'number' && Number.isFinite(step.index) ? Math.max(1, Math.floor(step.index)) : null\n      const stepTotal =\n        typeof step?.total === 'number' && Number.isFinite(step.total)\n          ? Math.max(stepIndex || 1, Math.floor(step.total))\n          : null\n      enqueue(`worker_llm_stage:${stage}`, async () => {\n        await reportTaskProgress(job, 65, {\n          stage: stageKey,\n          stageLabel,\n          displayMode: 'detail',\n          message: stageLabel,\n          streamRunId: streamContext.streamRunId,\n          ...(stepId ? { stepId } : {}),\n          ...(stepAttempt ? { stepAttempt } : {}),\n          ...(stepTitle ? { stepTitle } : {}),\n          ...(stepIndex ? { stepIndex } : {}),\n          ...(stepTotal ? { stepTotal } : {}),\n          meta: {\n            provider: provider || null,\n          },\n        })\n      })\n    },\n    onChunk: ({ kind, delta, lane, step }) => {\n      ensureActiveOrThrow('worker_llm_stream')\n      scheduleActiveProbe()\n      if (!delta) return\n      const stepId = typeof step?.id === 'string' && step.id.trim() ? step.id.trim() : null\n      const stepAttempt =\n        typeof step?.attempt === 'number' && Number.isFinite(step.attempt)\n          ? Math.max(1, Math.floor(step.attempt))\n          : null\n      const stepTitle = typeof step?.title === 'string' && step.title.trim() ? step.title.trim() : null\n      const stepIndex =\n        typeof step?.index === 'number' && Number.isFinite(step.index) ? Math.max(1, Math.floor(step.index)) : null\n      const stepTotal =\n        typeof step?.total === 'number' && Number.isFinite(step.total)\n          ? Math.max(stepIndex || 1, Math.floor(step.total))\n          : null\n      const laneKey = lane || (kind === 'reasoning' ? 'reasoning' : 'main')\n      for (let i = 0; i < delta.length; i += maxChunkChars) {\n        const piece = delta.slice(i, i + maxChunkChars)\n        if (!piece) continue\n        enqueue('worker_llm_stream', async () => {\n          await reportTaskStreamChunk(\n            job,\n            {\n              kind: kind as LLMStreamKind,\n              delta: piece,\n              seq: nextWorkerStreamSeq(streamContext, stepId, laneKey),\n              lane: laneKey,\n            },\n            {\n              stage: 'worker_llm_stream',\n              stageLabel: 'progress.runtime.stage.llmStreaming',\n              displayMode: 'detail',\n              done: false,\n              message: kind === 'reasoning' ? 'progress.runtime.llm.reasoning' : 'progress.runtime.llm.output',\n              streamRunId: streamContext.streamRunId,\n              ...(stepId ? { stepId } : {}),\n              ...(stepAttempt ? { stepAttempt } : {}),\n              ...(stepTitle ? { stepTitle } : {}),\n              ...(stepIndex ? { stepIndex } : {}),\n              ...(stepTotal ? { stepTotal } : {}),\n            },\n          )\n        })\n      }\n    },\n    onComplete: (text, step) => {\n      ensureActiveOrThrow('worker_llm_complete')\n      const stepId = typeof step?.id === 'string' && step.id.trim() ? step.id.trim() : null\n      const stepAttempt =\n        typeof step?.attempt === 'number' && Number.isFinite(step.attempt)\n          ? Math.max(1, Math.floor(step.attempt))\n          : null\n      const stepTitle = typeof step?.title === 'string' && step.title.trim() ? step.title.trim() : null\n      const stepIndex =\n        typeof step?.index === 'number' && Number.isFinite(step.index) ? Math.max(1, Math.floor(step.index)) : null\n      const stepTotal =\n        typeof step?.total === 'number' && Number.isFinite(step.total)\n          ? Math.max(stepIndex || 1, Math.floor(step.total))\n          : null\n      enqueue('worker_llm_complete', async () => {\n        await reportTaskProgress(job, 90, {\n          stage: 'worker_llm_complete',\n          stageLabel: 'progress.runtime.stage.llmCompleted',\n          displayMode: 'detail',\n          message: 'progress.runtime.llm.completed',\n          done: true,\n          ...(typeof text === 'string' ? { output: text } : {}),\n          streamRunId: streamContext.streamRunId,\n          ...(stepId ? { stepId } : {}),\n          ...(stepAttempt ? { stepAttempt } : {}),\n          ...(stepTitle ? { stepTitle } : {}),\n          ...(stepIndex ? { stepIndex } : {}),\n          ...(stepTotal ? { stepTotal } : {}),\n        })\n      })\n    },\n    onError: (error, step) => {\n      if (error instanceof TaskTerminatedError) {\n        markTerminated('worker_llm_error')\n        throw error\n      }\n      ensureActiveOrThrow('worker_llm_error')\n      const stepId = typeof step?.id === 'string' && step.id.trim() ? step.id.trim() : null\n      const stepAttempt =\n        typeof step?.attempt === 'number' && Number.isFinite(step.attempt)\n          ? Math.max(1, Math.floor(step.attempt))\n          : null\n      const stepTitle = typeof step?.title === 'string' && step.title.trim() ? step.title.trim() : null\n      const stepIndex =\n        typeof step?.index === 'number' && Number.isFinite(step.index) ? Math.max(1, Math.floor(step.index)) : null\n      const stepTotal =\n        typeof step?.total === 'number' && Number.isFinite(step.total)\n          ? Math.max(stepIndex || 1, Math.floor(step.total))\n          : null\n      enqueue('worker_llm_error', async () => {\n        await reportTaskProgress(job, 90, {\n          stage: 'worker_llm_error',\n          stageLabel: 'progress.runtime.stage.llmFailed',\n          displayMode: 'detail',\n          message: error instanceof Error ? error.message : String(error),\n          streamRunId: streamContext.streamRunId,\n          ...(stepId ? { stepId } : {}),\n          ...(stepAttempt ? { stepAttempt } : {}),\n          ...(stepTitle ? { stepTitle } : {}),\n          ...(stepIndex ? { stepIndex } : {}),\n          ...(stepTotal ? { stepTotal } : {}),\n        })\n      })\n    },\n    async flush() {\n      await publishQueue.catch(() => undefined)\n      if (terminatedError) {\n        throw terminatedError\n      }\n    },\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/location-image-task-handler.ts",
    "content": "import { type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { addLocationPromptSuffix, getArtStylePrompt, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\nimport { type TaskJobData } from '@/lib/task/types'\nimport { reportTaskProgress } from '../shared'\nimport {\n  assertTaskActive,\n  getProjectModels,\n} from '../utils'\nimport {\n  AnyObj,\n  generateLabeledImageToCos,\n  pickFirstString,\n} from './image-task-handler-shared'\n\nfunction resolvePayloadArtStyle(payload: AnyObj): ArtStyleValue | undefined {\n  if (!Object.prototype.hasOwnProperty.call(payload, 'artStyle')) return undefined\n  const parsedArtStyle = typeof payload.artStyle === 'string' ? payload.artStyle.trim() : ''\n  if (!isArtStyleValue(parsedArtStyle)) {\n    throw new Error('Invalid artStyle in IMAGE_LOCATION payload')\n  }\n  return parsedArtStyle\n}\n\ninterface LocationImageRecord {\n  id: string\n  locationId: string\n  description: string | null\n  imageIndex: number\n  location?: { name: string } | null\n}\n\ninterface LocationWithImages {\n  id: string\n  name: string\n  images?: LocationImageRecord[]\n}\n\ninterface LocationImageTaskDb {\n  locationImage: {\n    findUnique(args: Record<string, unknown>): Promise<LocationImageRecord | null>\n    update(args: Record<string, unknown>): Promise<unknown>\n  }\n  novelPromotionLocation: {\n    findUnique(args: Record<string, unknown>): Promise<LocationWithImages | null>\n    findMany(args: Record<string, unknown>): Promise<LocationWithImages[]>\n  }\n}\n\nfunction resolveRequestedLocationCount(payload: AnyObj): number | null {\n  if (!Object.prototype.hasOwnProperty.call(payload, 'count')) return null\n  return normalizeImageGenerationCount('location', payload.count)\n}\n\nexport async function handleLocationImageTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const projectId = job.data.projectId\n  const userId = job.data.userId\n  const db = prisma as unknown as LocationImageTaskDb\n  const models = await getProjectModels(projectId, userId)\n  const modelId = models.locationModel\n  if (!modelId) throw new Error('Location model not configured')\n  const requestedCount = resolveRequestedLocationCount(payload)\n\n  const payloadArtStyle = resolvePayloadArtStyle(payload)\n  const artStyle = getArtStylePrompt(payloadArtStyle ?? models.artStyle, job.data.locale)\n\n  // targetId may be locationId (group) or locationImageId (single)\n  const maybeLocationImage = await db.locationImage.findUnique({\n    where: { id: job.data.targetId },\n    include: { location: true },\n  })\n\n  let locationImages: LocationImageRecord[] = []\n  // 用于存储 locationId -> name 的映射，避免 images 子集缺少 location 关联\n  const locationNameMap: Record<string, string> = {}\n\n  if (maybeLocationImage) {\n    // 来源 location 名字已 include，先记录\n    if (maybeLocationImage.location?.name) {\n      locationNameMap[maybeLocationImage.locationId] = maybeLocationImage.location.name\n    }\n    if (payload.imageIndex !== undefined) {\n      locationImages = [maybeLocationImage]\n    } else {\n      const location = await db.novelPromotionLocation.findUnique({\n        where: { id: maybeLocationImage.locationId },\n        include: { images: { orderBy: { imageIndex: 'asc' } } },\n      })\n      if (location?.name) {\n        locationNameMap[maybeLocationImage.locationId] = location.name\n      }\n      const orderedImages = location?.images || [maybeLocationImage]\n      locationImages = requestedCount === null ? orderedImages : orderedImages.slice(0, requestedCount)\n    }\n  } else {\n    const locationId = pickFirstString(payload.id, payload.locationId, job.data.targetId)\n    if (!locationId) throw new Error('Location id missing')\n\n    const location = await db.novelPromotionLocation.findUnique({\n      where: { id: locationId },\n      include: { images: { orderBy: { imageIndex: 'asc' } } },\n    })\n\n    if (!location || !location.images?.length) {\n      throw new Error('Location images not found')\n    }\n\n    // 记录 location 名字\n    locationNameMap[locationId] = location.name\n\n    if (payload.imageIndex !== undefined) {\n      const image = location.images.find((it) => it.imageIndex === Number(payload.imageIndex))\n      if (!image) throw new Error(`Location image not found for imageIndex=${payload.imageIndex}`)\n      locationImages = [image]\n    } else {\n      locationImages = requestedCount === null ? location.images : location.images.slice(0, requestedCount)\n    }\n  }\n\n  // 补充查询缺失的 location 名字（兜底）\n  const missingLocationIds = Array.from(new Set(locationImages.map((it) => it.locationId)))\n    .filter((id) => !locationNameMap[id])\n  if (missingLocationIds.length > 0) {\n    const extras = await db.novelPromotionLocation.findMany({\n      where: { id: { in: missingLocationIds } } as Record<string, unknown>,\n    })\n    for (const loc of extras) {\n      locationNameMap[loc.id] = loc.name\n    }\n  }\n\n  const locationIds = Array.from(new Set(locationImages.map((it) => it.locationId)))\n\n  for (let i = 0; i < locationImages.length; i++) {\n    const item = locationImages[i]\n    // 优先用映射表中的名字，回退到 item.location?.name，最后才用默认值\n    const name = locationNameMap[item.locationId] || item.location?.name || '场景'\n    const promptBody = item.description || ''\n    if (!promptBody) continue\n\n    const prompt = artStyle ? `${addLocationPromptSuffix(promptBody)}，${artStyle}` : addLocationPromptSuffix(promptBody)\n    await reportTaskProgress(job, 20 + Math.floor((i / Math.max(locationImages.length, 1)) * 55), {\n      stage: 'generate_location_image',\n      imageId: item.id,\n    })\n\n    const cosKey = await generateLabeledImageToCos({\n      job,\n      userId,\n      modelId,\n      prompt,\n      label: name,\n      targetId: item.id,\n      keyPrefix: 'location',\n      options: {\n        aspectRatio: '1:1',\n      },\n    })\n\n    await assertTaskActive(job, 'persist_location_image')\n    await db.locationImage.update({\n      where: { id: item.id },\n      data: { imageUrl: cosKey },\n    })\n  }\n\n  return {\n    updated: locationImages.length,\n    locationIds,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/modify-asset-image-task-handler.ts",
    "content": "export { handleModifyAssetImageTask } from './image-task-handlers-core'\n"
  },
  {
    "path": "src/lib/workers/handlers/modify-description-sync.ts",
    "content": "import { executeAiTextStep, executeAiVisionStep } from '@/lib/ai-runtime'\nimport { removeCharacterPromptSuffix, removeLocationPromptSuffix } from '@/lib/constants'\nimport { safeParseJsonObject } from '@/lib/json-repair'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport type { PromptLocale } from '@/lib/prompt-i18n/types'\nimport {\n  buildCharacterDescriptionFields,\n  readIndexedDescription,\n} from '@/lib/assets/description-fields'\n\nexport type SyncedAssetType = 'character' | 'location'\n\nfunction trimText(value: string | null | undefined): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nfunction buildImageContext(type: SyncedAssetType, hasReferenceImages: boolean): string {\n  if (!hasReferenceImages) return ''\n  if (type === 'character') {\n    return '【参考图片】\\n请仔细分析参考图片中的服装款式、颜色、材质、配饰等关键视觉特征，并将这些特征融入更新后的描述中。'\n  }\n  return '【参考图片】\\n请仔细分析参考图片中的建筑风格、装饰元素、光线氛围、色调等关键视觉特征，并将这些特征融入更新后的描述中。'\n}\n\nfunction parseModifiedDescription(responseText: string): string {\n  const parsed = safeParseJsonObject(responseText)\n  const prompt = trimText(typeof parsed.prompt === 'string' ? parsed.prompt : '')\n  if (!prompt) {\n    throw new Error('No prompt field in response')\n  }\n  return prompt\n}\n\nexport { buildCharacterDescriptionFields, readIndexedDescription }\n\nexport async function generateModifiedAssetDescription(params: {\n  userId: string\n  model: string\n  locale: PromptLocale\n  type: SyncedAssetType\n  currentDescription: string\n  modifyInstruction: string\n  referenceImages?: string[]\n  locationName?: string\n  projectId?: string\n}): Promise<string> {\n  const hasReferenceImages = Array.isArray(params.referenceImages) && params.referenceImages.length > 0\n  const finalPrompt = params.type === 'character'\n    ? buildPrompt({\n      promptId: PROMPT_IDS.NP_CHARACTER_DESCRIPTION_UPDATE,\n      locale: params.locale,\n      variables: {\n        original_description: removeCharacterPromptSuffix(params.currentDescription),\n        modify_instruction: params.modifyInstruction,\n        image_context: buildImageContext('character', hasReferenceImages),\n      },\n    })\n    : buildPrompt({\n      promptId: PROMPT_IDS.NP_LOCATION_DESCRIPTION_UPDATE,\n      locale: params.locale,\n      variables: {\n        location_name: trimText(params.locationName) || '场景',\n        original_description: removeLocationPromptSuffix(params.currentDescription),\n        modify_instruction: params.modifyInstruction,\n        image_context: buildImageContext('location', hasReferenceImages),\n      },\n    })\n\n  if (hasReferenceImages) {\n    const completion = await executeAiVisionStep({\n      userId: params.userId,\n      model: params.model,\n      prompt: finalPrompt,\n      imageUrls: params.referenceImages ?? [],\n      temperature: 0.7,\n      ...(params.projectId ? { projectId: params.projectId } : {}),\n    })\n    return parseModifiedDescription(completion.text)\n  }\n\n  const completion = await executeAiTextStep({\n    userId: params.userId,\n    model: params.model,\n    messages: [{ role: 'user', content: finalPrompt }],\n    temperature: 0.7,\n    ...(params.projectId ? { projectId: params.projectId } : {}),\n    action: params.type === 'character'\n      ? 'sync_character_description_after_image_modify'\n      : 'sync_location_description_after_image_modify',\n    meta: {\n      stepId: params.type === 'character'\n        ? 'sync_character_description_after_image_modify'\n        : 'sync_location_description_after_image_modify',\n      stepTitle: params.type === 'character' ? '同步角色描述' : '同步场景描述',\n      stepIndex: 1,\n      stepTotal: 1,\n    },\n  })\n  return parseModifiedDescription(completion.text)\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/panel-image-task-handler.ts",
    "content": "import { type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { getArtStylePrompt } from '@/lib/constants'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport { type TaskJobData } from '@/lib/task/types'\nimport { reportTaskProgress } from '../shared'\nimport {\n  assertTaskActive,\n  getProjectModels,\n  resolveImageSourceFromGeneration,\n  uploadImageSourceToCos,\n} from '../utils'\nimport { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'\nimport {\n  AnyObj,\n  clampCount,\n  collectPanelReferenceImages,\n  findCharacterByName,\n  parsePanelCharacterReferences,\n  pickFirstString,\n  resolveNovelData,\n} from './image-task-handler-shared'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\n\nfunction parseJsonUnknown(raw: string | null | undefined): unknown | null {\n  if (!raw) return null\n  try {\n    return JSON.parse(raw)\n  } catch {\n    return null\n  }\n}\n\nfunction parseDescriptionList(raw: string | null | undefined): string[] {\n  if (!raw) return []\n  try {\n    const parsed = JSON.parse(raw)\n    if (!Array.isArray(parsed)) return []\n    return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)\n  } catch {\n    return []\n  }\n}\n\nfunction pickAppearanceDescription(appearance: {\n  descriptions?: string | null\n  description?: string | null\n  selectedIndex?: number | null\n}): string {\n  const descriptions = parseDescriptionList(appearance.descriptions || null)\n  if (descriptions.length > 0) {\n    const selectedIndex = typeof appearance.selectedIndex === 'number' ? appearance.selectedIndex : 0\n    const selected = descriptions[selectedIndex] || descriptions[0]\n    if (selected && selected.trim()) return selected.trim()\n  }\n  if (typeof appearance.description === 'string' && appearance.description.trim()) {\n    return appearance.description.trim()\n  }\n  return '无描述'\n}\n\nfunction buildPanelPromptContext(params: {\n  panel: {\n    id: string\n    shotType: string | null\n    cameraMove: string | null\n    description: string | null\n    videoPrompt: string | null\n    location: string | null\n    characters: string | null\n    srtSegment: string | null\n    photographyRules: string | null\n    actingNotes: string | null\n  }\n  projectData: Awaited<ReturnType<typeof resolveNovelData>>\n}) {\n  const panelCharacters = parsePanelCharacterReferences(params.panel.characters)\n  const characterContexts = panelCharacters.map((reference) => {\n    const character = findCharacterByName(params.projectData.characters || [], reference.name)\n    if (!character) {\n      return {\n        name: reference.name,\n        appearance: reference.appearance || null,\n        description: '无角色外貌数据',\n      }\n    }\n\n    const appearances = character.appearances || []\n    const matchedAppearance =\n      (reference.appearance\n        ? appearances.find((appearance) => (appearance.changeReason || '').toLowerCase() === reference.appearance!.toLowerCase())\n        : null) || appearances[0] || null\n\n    return {\n      name: character.name,\n      appearance: matchedAppearance?.changeReason || null,\n      description: matchedAppearance ? pickAppearanceDescription(matchedAppearance) : '无角色外貌数据',\n    }\n  })\n\n  const locationContext = (() => {\n    if (!params.panel.location) return null\n    const matchedLocation = (params.projectData.locations || []).find(\n      (item) => item.name.toLowerCase() === params.panel.location!.toLowerCase(),\n    )\n    if (!matchedLocation) return null\n    const selectedImage = (matchedLocation.images || []).find((item) => item.isSelected) || matchedLocation.images?.[0]\n    return {\n      name: matchedLocation.name,\n      description: selectedImage?.description || null,\n    }\n  })()\n\n  return {\n    panel: {\n      panel_id: params.panel.id,\n      shot_type: params.panel.shotType || '',\n      camera_move: params.panel.cameraMove || '',\n      description: params.panel.description || '',\n      video_prompt: params.panel.videoPrompt || '',\n      location: params.panel.location || '',\n      characters: panelCharacters,\n      source_text: params.panel.srtSegment || '',\n      photography_rules: parseJsonUnknown(params.panel.photographyRules),\n      acting_notes: parseJsonUnknown(params.panel.actingNotes),\n    },\n    context: {\n      character_appearances: characterContexts,\n      location_reference: locationContext,\n    },\n  }\n}\n\nfunction buildPanelPrompt(params: {\n  locale: TaskJobData['locale']\n  aspectRatio: string\n  styleText: string\n  sourceText: string\n  contextJson: string\n}) {\n  return buildPrompt({\n    promptId: PROMPT_IDS.NP_SINGLE_PANEL_IMAGE,\n    locale: params.locale,\n    variables: {\n      aspect_ratio: params.aspectRatio,\n      storyboard_text_json_input: params.contextJson,\n      source_text: params.sourceText || '无',\n      style: params.styleText,\n    },\n  })\n}\n\nexport async function handlePanelImageTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const panelId = pickFirstString(payload.panelId, job.data.targetId)\n  if (!panelId) throw new Error('panelId missing')\n\n  const panel = await prisma.novelPromotionPanel.findUnique({\n    where: { id: panelId },\n  })\n\n  if (!panel) throw new Error('Panel not found')\n\n  const projectData = await resolveNovelData(job.data.projectId)\n  const modelConfig = await getProjectModels(job.data.projectId, job.data.userId)\n  const modelKey = modelConfig.storyboardModel\n  if (!modelKey) throw new Error('Storyboard model not configured')\n\n  const candidateCount = clampCount(payload.candidateCount ?? payload.count, 1, 4, 1)\n  const refs = await collectPanelReferenceImages(projectData, panel)\n  const normalizedRefs = await normalizeReferenceImagesForGeneration(refs)\n\n  const logger = createScopedLogger({\n    module: 'worker.panel-image',\n    action: 'panel_image_generate',\n    requestId: job.data.trace?.requestId || undefined,\n    taskId: job.data.taskId,\n    projectId: job.data.projectId,\n    userId: job.data.userId,\n  })\n  logger.info({\n    message: 'panel image generation started',\n    details: {\n      panelId,\n      modelKey,\n      candidateCount,\n      referenceImagesRawCount: refs.length,\n      referenceImagesNormalizedCount: normalizedRefs.length,\n      rawUrls: refs.map((u) => u.substring(0, 100)),\n      normalizedUrls: normalizedRefs.map((u) => u.substring(0, 100)),\n      panelCharacters: panel.characters,\n      panelLocation: panel.location,\n      artStyle: modelConfig.artStyle,\n    },\n  })\n\n  const artStyle = getArtStylePrompt(modelConfig.artStyle, job.data.locale)\n  if (!projectData.videoRatio) throw new Error('Project videoRatio not configured')\n  const aspectRatio = projectData.videoRatio\n  const promptContext = buildPanelPromptContext({\n    panel: {\n      id: panel.id,\n      shotType: panel.shotType,\n      cameraMove: panel.cameraMove,\n      description: panel.description,\n      videoPrompt: panel.videoPrompt,\n      location: panel.location,\n      characters: panel.characters,\n      srtSegment: panel.srtSegment,\n      photographyRules: panel.photographyRules,\n      actingNotes: panel.actingNotes,\n    },\n    projectData,\n  })\n  const contextJson = JSON.stringify(promptContext, null, 2)\n  const prompt = buildPanelPrompt({\n    locale: job.data.locale,\n    aspectRatio,\n    styleText: artStyle || '与参考图风格一致',\n    sourceText: panel.srtSegment || panel.description || '',\n    contextJson,\n  })\n  logger.info({\n    message: 'panel image prompt resolved',\n    details: {\n      promptLength: prompt.length,\n    },\n  })\n\n  const candidates: string[] = []\n\n  for (let i = 0; i < candidateCount; i++) {\n    await reportTaskProgress(job, 18 + Math.floor((i / Math.max(candidateCount, 1)) * 58), {\n      stage: 'generate_panel_candidate',\n      candidateIndex: i,\n    })\n\n    const source = await resolveImageSourceFromGeneration(job, {\n      userId: job.data.userId,\n      modelId: modelKey,\n      prompt,\n      options: {\n        referenceImages: normalizedRefs,\n        aspectRatio,\n      },\n      // 单个任务内会串行生成多候选，若允许按 task.externalId 续接会复用上一候选外部任务结果。\n      allowTaskExternalIdResume: candidateCount === 1,\n      pollProgress: { start: 30, end: 90 },\n    })\n\n    const cosKey = await uploadImageSourceToCos(source, 'panel-candidate', `${panel.id}-${i}`)\n    candidates.push(cosKey)\n  }\n\n  const isFirstGeneration = !panel.imageUrl\n\n  await assertTaskActive(job, 'persist_panel_image')\n  if (isFirstGeneration) {\n    await prisma.novelPromotionPanel.update({\n      where: { id: panel.id },\n      data: {\n        imageUrl: candidates[0] || null,\n        candidateImages: candidateCount > 1 ? JSON.stringify(candidates) : null,\n      },\n    })\n  } else {\n    await prisma.novelPromotionPanel.update({\n      where: { id: panel.id },\n      data: {\n        previousImageUrl: panel.imageUrl,\n        candidateImages: JSON.stringify(candidates),\n      },\n    })\n  }\n\n  return {\n    panelId: panel.id,\n    candidateCount: candidates.length,\n    imageUrl: isFirstGeneration ? candidates[0] || null : null,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/panel-variant-task-handler.ts",
    "content": "import { type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { getArtStylePrompt } from '@/lib/constants'\nimport { logInfo as _ulogInfo } from '@/lib/logging/core'\nimport { type TaskJobData } from '@/lib/task/types'\nimport {\n  assertTaskActive,\n  getProjectModels,\n  resolveImageSourceFromGeneration,\n  toSignedUrlIfCos,\n  uploadImageSourceToCos,\n} from '../utils'\nimport { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'\nimport {\n  AnyObj,\n  findCharacterByName,\n  parseImageUrls,\n  parsePanelCharacterReferences,\n  pickFirstString,\n  resolveNovelData,\n} from './image-task-handler-shared'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\n\n// ── 构建变体提示词 ──────────────────────────────────────\ninterface VariantPromptParams {\n  locale: TaskJobData['locale']\n  originalDescription: string\n  originalShotType: string\n  originalCameraMove: string\n  location: string\n  charactersInfo: string\n  variantTitle: string\n  variantDescription: string\n  targetShotType: string\n  targetCameraMove: string\n  videoPrompt: string\n  characterAssets: string\n  locationAsset: string\n  aspectRatio: string\n  style: string\n}\n\nfunction buildVariantPrompt(params: VariantPromptParams): string {\n  return buildPrompt({\n    promptId: PROMPT_IDS.NP_AGENT_SHOT_VARIANT_GENERATE,\n    locale: params.locale,\n    variables: {\n      original_description: params.originalDescription,\n      original_shot_type: params.originalShotType,\n      original_camera_move: params.originalCameraMove,\n      location: params.location,\n      characters_info: params.charactersInfo,\n      variant_title: params.variantTitle,\n      variant_description: params.variantDescription,\n      target_shot_type: params.targetShotType,\n      target_camera_move: params.targetCameraMove,\n      video_prompt: params.videoPrompt,\n      character_assets: params.characterAssets,\n      location_asset: params.locationAsset,\n      aspect_ratio: params.aspectRatio,\n      style: params.style,\n    },\n  })\n}\n\n// ── 构建角色和场景描述信息 ─────────────────────────────\nfunction buildCharactersInfo(\n  panel: { characters: string | null },\n  projectData: { characters?: Array<{ name: string; introduction?: string | null; appearances?: Array<{ changeReason?: string | null }> }> },\n): string {\n  const panelCharacters = parsePanelCharacterReferences(panel.characters)\n  if (panelCharacters.length === 0) return '无角色'\n\n  return panelCharacters.map(item => {\n    const character = findCharacterByName(projectData.characters || [], item.name)\n    const intro = character?.introduction || ''\n    const appearance = item.appearance || '默认形象'\n    return `- ${item.name}（${appearance}）${intro ? `：${intro}` : ''}`\n  }).join('\\n')\n}\n\nfunction buildCharacterAssetsDescription(\n  panel: { characters: string | null },\n  projectData: { characters?: Array<{ name: string; appearances?: Array<{ changeReason?: string | null; imageUrl?: string | null }> }> },\n): string {\n  const panelCharacters = parsePanelCharacterReferences(panel.characters)\n  if (panelCharacters.length === 0) return '无角色参考图'\n\n  return panelCharacters.map(item => {\n    const character = findCharacterByName(projectData.characters || [], item.name)\n    if (!character) return `- ${item.name}：无参考图`\n    const hasAppearance = (character.appearances || []).length > 0\n    return `- ${item.name}：${hasAppearance ? '已提供参考图' : '无参考图'}`\n  }).join('\\n')\n}\n\nfunction buildLocationAssetDescription(params: {\n  includeLocationAsset: boolean\n  locationName: string\n  locale: TaskJobData['locale']\n}): string {\n  if (params.locationName) {\n    if (params.includeLocationAsset) return `场景：${params.locationName}`\n    return params.locale === 'en' ? 'Location reference disabled' : '未使用场景参考图'\n  }\n  return params.locale === 'en' ? 'No location reference' : '无场景参考'\n}\n\nfunction buildVariantReferenceImages(params: {\n  includeCharacterAssets: boolean\n  includeLocationAsset: boolean\n  newPanel: {\n    characters: string | null\n    location: string | null\n  }\n  sourcePanelImageUrl: string | null\n  projectData: Awaited<ReturnType<typeof resolveNovelData>>\n}): string[] {\n  const refs: string[] = []\n  if (params.sourcePanelImageUrl) refs.push(params.sourcePanelImageUrl)\n\n  if (params.includeCharacterAssets) {\n    const panelCharacters = parsePanelCharacterReferences(params.newPanel.characters)\n    for (const item of panelCharacters) {\n      const character = findCharacterByName(params.projectData.characters || [], item.name)\n      if (!character) continue\n\n      const appearances = character.appearances || []\n      let appearance = appearances[0]\n      if (item.appearance) {\n        const matched = appearances.find((candidate) => (candidate.changeReason || '').toLowerCase() === item.appearance!.toLowerCase())\n        if (matched) appearance = matched\n      }\n\n      if (!appearance) continue\n      const imageUrls = parseImageUrls((appearance as { imageUrls?: string | null }).imageUrls || null, 'characterAppearance.imageUrls')\n      const selectedIndex = typeof (appearance as { selectedIndex?: number | null }).selectedIndex === 'number'\n        ? (appearance as { selectedIndex?: number | null }).selectedIndex\n        : null\n      const selectedUrl = (selectedIndex !== null && selectedIndex !== undefined ? imageUrls[selectedIndex] : null)\n        || imageUrls[0]\n        || appearance.imageUrl\n        || null\n      const signed = toSignedUrlIfCos(selectedUrl, 3600)\n      if (signed) refs.push(signed)\n    }\n  }\n\n  if (params.includeLocationAsset && params.newPanel.location) {\n    const location = (params.projectData.locations || []).find(\n      (item) => item.name.toLowerCase() === params.newPanel.location!.toLowerCase(),\n    )\n    if (location) {\n      const selected = (location.images || []).find((image) => image.isSelected) || location.images?.[0]\n      const signed = toSignedUrlIfCos(selected?.imageUrl, 3600)\n      if (signed) refs.push(signed)\n    }\n  }\n\n  return refs\n}\n\ninterface PanelVariantPayload {\n  shot_type?: string\n  camera_move?: string\n  description?: string\n  video_prompt?: string\n  title?: string\n  location?: string\n  characters?: unknown\n}\n\nexport async function handlePanelVariantTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const newPanelId = pickFirstString(payload.newPanelId)\n  const sourcePanelId = pickFirstString(payload.sourcePanelId)\n  const includeCharacterAssets = payload.includeCharacterAssets !== false\n  const includeLocationAsset = payload.includeLocationAsset !== false\n  const variant: PanelVariantPayload = payload.variant && typeof payload.variant === 'object'\n    ? (payload.variant as PanelVariantPayload)\n    : {}\n\n  if (!newPanelId || !sourcePanelId) {\n    throw new Error('panel_variant missing newPanelId/sourcePanelId')\n  }\n\n  // Panel 已在 API route 中创建，这里只需获取它\n  const newPanel = await prisma.novelPromotionPanel.findUnique({ where: { id: newPanelId } })\n  if (!newPanel) throw new Error('New panel not found (should have been created by API route)')\n\n  const sourcePanel = await prisma.novelPromotionPanel.findUnique({ where: { id: sourcePanelId } })\n  if (!sourcePanel) throw new Error('Source panel not found')\n\n  const projectData = await resolveNovelData(job.data.projectId)\n  if (!projectData.videoRatio) throw new Error('Project videoRatio not configured')\n  const aspectRatio = projectData.videoRatio\n\n  const modelConfig = await getProjectModels(job.data.projectId, job.data.userId)\n  const storyboardModel = modelConfig.storyboardModel\n  if (!storyboardModel) throw new Error('Storyboard model not configured')\n\n  // 收集参考图（与 panel-image-task-handler 共用同一链路）\n  const sourcePanelImageUrl = toSignedUrlIfCos(sourcePanel.imageUrl, 3600)\n  const refs = buildVariantReferenceImages({\n    includeCharacterAssets,\n    includeLocationAsset,\n    newPanel,\n    sourcePanelImageUrl,\n    projectData,\n  })\n  const normalizedRefs = await normalizeReferenceImagesForGeneration(refs)\n\n  // 使用 agent_shot_variant_generate.txt 提示词模板\n  const artStyle = getArtStylePrompt(modelConfig.artStyle, job.data.locale)\n  const charactersInfo = buildCharactersInfo(newPanel, projectData)\n  const characterAssetsDesc = includeCharacterAssets\n    ? buildCharacterAssetsDescription(newPanel, projectData)\n    : (job.data.locale === 'en' ? 'Character reference images disabled' : '未使用角色参考图')\n  const locationName = newPanel.location || sourcePanel.location || ''\n\n  const prompt = buildVariantPrompt({\n    locale: job.data.locale,\n    originalDescription: sourcePanel.description || '',\n    originalShotType: sourcePanel.shotType || '',\n    originalCameraMove: sourcePanel.cameraMove || '',\n    location: locationName,\n    charactersInfo,\n    variantTitle: pickFirstString(variant.title) || '镜头变体',\n    variantDescription: variant.description || '',\n    targetShotType: variant.shot_type || sourcePanel.shotType || '',\n    targetCameraMove: variant.camera_move || sourcePanel.cameraMove || '',\n    videoPrompt: pickFirstString(variant.video_prompt, variant.description) || '',\n    characterAssets: characterAssetsDesc,\n    locationAsset: buildLocationAssetDescription({\n      includeLocationAsset,\n      locationName,\n      locale: job.data.locale,\n    }),\n    aspectRatio,\n    style: artStyle || '与参考图风格一致',\n  })\n\n  _ulogInfo('[panel-variant] resolved variant prompt', prompt)\n\n  await assertTaskActive(job, 'generate_panel_variant_image')\n  const source = await resolveImageSourceFromGeneration(job, {\n    userId: job.data.userId,\n    modelId: storyboardModel,\n    prompt,\n    options: {\n      referenceImages: normalizedRefs,\n      aspectRatio,\n    },\n  })\n\n  const cosKey = await uploadImageSourceToCos(source, 'panel-variant', newPanel.id)\n\n  await assertTaskActive(job, 'persist_panel_variant')\n  await prisma.novelPromotionPanel.update({\n    where: { id: newPanel.id },\n    data: { imageUrl: cosKey },\n  })\n\n  return {\n    panelId: newPanel.id,\n    storyboardId: newPanel.storyboardId,\n    imageUrl: cosKey,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/reference-to-character-helpers.ts",
    "content": "const MAX_REFERENCE_IMAGES = 5\n\nexport function readString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : ''\n}\n\nexport function readBoolean(value: unknown): boolean {\n  if (value === true) return true\n  if (typeof value === 'number') return value === 1\n  if (typeof value !== 'string') return false\n  const normalized = value.trim().toLowerCase()\n  return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'\n}\n\nexport function parseReferenceImages(payload: Record<string, unknown>): string[] {\n  const multi = Array.isArray(payload.referenceImageUrls)\n    ? payload.referenceImageUrls.map((item) => readString(item)).filter(Boolean)\n    : []\n  if (multi.length > 0) {\n    return multi.slice(0, MAX_REFERENCE_IMAGES)\n  }\n  const single = readString(payload.referenceImageUrl)\n  if (single) return [single]\n  return []\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/reference-to-character.ts",
    "content": "import sharp from 'sharp'\nimport type { Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { generateImage } from '@/lib/generator-api'\nimport { queryFalStatus } from '@/lib/async-submit'\nimport { fetchWithTimeoutAndRetry } from '@/lib/ark-api'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { executeAiVisionStep } from '@/lib/ai-runtime'\nimport { getUserModelConfig } from '@/lib/config-service'\nimport {\n  CHARACTER_IMAGE_BANANA_RATIO,\n  addCharacterPromptSuffix,\n  getArtStylePrompt,\n} from '@/lib/constants'\nimport { encodeImageUrls } from '@/lib/contracts/image-urls-contract'\nimport { generateUniqueKey, getSignedUrl, uploadObject } from '@/lib/storage'\nimport { initializeFonts, createLabelSVG } from '@/lib/fonts'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport { normalizeImageGenerationCount } from '@/lib/image-generation/count'\nimport {\n  parseReferenceImages,\n  readBoolean,\n  readString,\n} from './reference-to-character-helpers'\nconst POLL_MAX_ATTEMPTS = 60\nconst POLL_INTERVAL_MS = 2000\nasync function generateLabeledImage(params: {\n  job: Job<TaskJobData>\n  imageIndex: number\n  userId: string\n  imageModel: string\n  prompt: string\n  referenceImages?: string[]\n  falApiKey?: string | null\n  keyPrefix: string\n  labelText: string\n}): Promise<string | null> {\n  const {\n    job,\n    imageIndex,\n    userId,\n    imageModel,\n    prompt,\n    referenceImages,\n    falApiKey,\n    keyPrefix,\n    labelText,\n  } = params\n\n  try {\n    await assertTaskActive(job, `reference_to_character_generate_${imageIndex + 1}`)\n    const result = await generateImage(\n      userId,\n      imageModel,\n      prompt,\n      {\n        referenceImages,\n        aspectRatio: CHARACTER_IMAGE_BANANA_RATIO,\n      },\n    )\n\n    let finalImageUrl = result.imageUrl\n    const requestId = typeof result.requestId === 'string' ? result.requestId : ''\n    const endpoint = typeof result.endpoint === 'string' ? result.endpoint : ''\n    if (result.async && requestId && endpoint) {\n      if (!falApiKey) {\n        throw new Error('reference_to_character async result requires falApiKey')\n      }\n      for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt += 1) {\n        await assertTaskActive(job, `reference_to_character_poll_${imageIndex + 1}_${attempt + 1}`)\n        await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))\n        const status = await queryFalStatus(endpoint, requestId, falApiKey)\n        if (status.completed && status.resultUrl) {\n          finalImageUrl = status.resultUrl\n          break\n        }\n        if (status.failed) {\n          return null\n        }\n      }\n    }\n\n    if (!result.success || !finalImageUrl) {\n      return null\n    }\n\n    const imgRes = await fetchWithTimeoutAndRetry(finalImageUrl, {\n      logPrefix: `[reference-to-character:${imageIndex + 1}]`,\n    })\n    const buffer = Buffer.from(await imgRes.arrayBuffer())\n    const meta = await sharp(buffer).metadata()\n    const width = meta.width || 2160\n    const height = meta.height || 2160\n    const fontSize = Math.floor(height * 0.04)\n    const pad = Math.floor(fontSize * 0.5)\n    const barHeight = fontSize + pad * 2\n\n    const svg = await createLabelSVG(width, barHeight, fontSize, pad, labelText)\n    const processed = await sharp(buffer)\n      .extend({\n        top: barHeight,\n        bottom: 0,\n        left: 0,\n        right: 0,\n        background: { r: 0, g: 0, b: 0, alpha: 1 },\n      })\n      .composite([{ input: svg, top: 0, left: 0 }])\n      .jpeg({ quality: 90, mozjpeg: true })\n      .toBuffer()\n\n    const key = generateUniqueKey(`${keyPrefix}-${Date.now()}-${imageIndex}`, 'jpg')\n    return await uploadObject(processed, key)\n  } catch {\n    return null\n  }\n}\n\nexport async function handleReferenceToCharacterTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as Record<string, unknown>\n  const allReferenceImages = parseReferenceImages(payload)\n  if (allReferenceImages.length === 0) {\n    throw new Error('Missing referenceImageUrl or referenceImageUrls')\n  }\n\n  const isAssetHub = job.data.type === TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER\n  const isProject = job.data.type === TASK_TYPE.REFERENCE_TO_CHARACTER\n  if (!isAssetHub && !isProject) {\n    throw new Error(`Unsupported task type: ${job.data.type}`)\n  }\n\n  const isBackgroundJob = readBoolean(payload.isBackgroundJob)\n  const appearanceId = readString(payload.appearanceId)\n  const characterId = readString(payload.characterId)\n  const extractOnly = readBoolean(payload.extractOnly)\n  const customDescription = readString(payload.customDescription)\n  const characterName = readString(payload.characterName) || '新角色 - 初始形象'\n  const artStyle = readString(payload.artStyle)\n\n  if (isBackgroundJob && (!characterId || !appearanceId)) {\n    throw new Error('Missing characterId or appearanceId for background job')\n  }\n\n  await reportTaskProgress(job, 15, {\n    stage: 'reference_to_character_prepare',\n    stageLabel: '准备参考图转换参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'reference_to_character_prepare')\n\n  await initializeFonts()\n\n  const userConfig = await getUserModelConfig(job.data.userId)\n  const imageModel = readString(userConfig.characterModel)\n  const analysisModel = readString(userConfig.analysisModel)\n  if (!imageModel && !extractOnly) {\n    throw new Error('请先在设置页面配置角色图片模型')\n  }\n  if (!analysisModel && extractOnly) {\n    throw new Error('请先在设置页面配置分析模型')\n  }\n\n  if (extractOnly) {\n    await reportTaskProgress(job, 45, {\n      stage: 'reference_to_character_extract',\n      stageLabel: '提取参考图描述',\n      displayMode: 'detail',\n    })\n    const completion = await executeAiVisionStep({\n      userId: job.data.userId,\n      model: analysisModel,\n      prompt: buildPrompt({\n        promptId: PROMPT_IDS.CHARACTER_IMAGE_TO_DESCRIPTION,\n        locale: job.data.locale,\n      }),\n      imageUrls: allReferenceImages,\n      temperature: 0.3,\n      ...(isProject ? { projectId: job.data.projectId } : {}),\n    })\n    await assertTaskActive(job, 'reference_to_character_extract_done')\n    await reportTaskProgress(job, 96, {\n      stage: 'reference_to_character_extract_done',\n      stageLabel: '参考图描述提取完成',\n      displayMode: 'detail',\n    })\n    return {\n      success: true,\n      description: completion.text,\n    }\n  }\n\n  const artStylePrompt = getArtStylePrompt(artStyle, job.data.locale)\n\n  const basePrompt = customDescription || buildPrompt({\n    promptId: PROMPT_IDS.CHARACTER_REFERENCE_TO_SHEET,\n    locale: job.data.locale,\n  })\n  let prompt = addCharacterPromptSuffix(basePrompt)\n  if (artStylePrompt) {\n    prompt = `${prompt}，${artStylePrompt}`\n  }\n\n  const useReferenceImages = !customDescription\n  const { apiKey: falApiKey } = await getProviderConfig(job.data.userId, 'fal')\n  const keyPrefix = isAssetHub ? 'ref-char' : `proj-ref-char-${job.data.projectId}`\n  const count = normalizeImageGenerationCount('reference-to-character', payload.count)\n\n  await reportTaskProgress(job, 35, {\n    stage: 'reference_to_character_generate',\n    stageLabel: '生成角色三视图',\n    displayMode: 'detail',\n  })\n\n  const imageResults = await Promise.all(Array.from({ length: count }, (_value, index) => index).map(async (index) =>\n    await generateLabeledImage({\n      job,\n      imageIndex: index,\n      userId: job.data.userId,\n      imageModel,\n      prompt,\n      referenceImages: useReferenceImages ? allReferenceImages : undefined,\n      falApiKey,\n      keyPrefix,\n      labelText: characterName,\n    }),\n  ))\n\n  let description: string | null = null\n  if (analysisModel) {\n    const analysisPrompt = buildPrompt({\n      promptId: PROMPT_IDS.CHARACTER_IMAGE_TO_DESCRIPTION,\n      locale: job.data.locale,\n    })\n    const completion = await executeAiVisionStep({\n      userId: job.data.userId,\n      model: analysisModel,\n      prompt: analysisPrompt,\n      imageUrls: allReferenceImages,\n      temperature: 0.3,\n      ...(isProject ? { projectId: job.data.projectId } : {}),\n    })\n    description = completion.text\n  }\n\n  const successfulCosKeys = imageResults.filter((item): item is string => Boolean(item))\n  if (successfulCosKeys.length === 0) {\n    throw new Error('图片生成失败')\n  }\n\n  await assertTaskActive(job, 'reference_to_character_persist')\n  if (isBackgroundJob && appearanceId) {\n    if (isAssetHub) {\n      await prisma.globalCharacterAppearance.update({\n        where: { id: appearanceId },\n        data: {\n          imageUrl: successfulCosKeys[0],\n          imageUrls: encodeImageUrls(successfulCosKeys),\n          description: description || undefined,\n        },\n      })\n    } else {\n      await prisma.characterAppearance.update({\n        where: { id: appearanceId },\n        data: {\n          imageUrl: successfulCosKeys[0],\n          imageUrls: encodeImageUrls(successfulCosKeys),\n          description: description || undefined,\n        },\n      })\n    }\n    await reportTaskProgress(job, 96, {\n      stage: 'reference_to_character_done',\n      stageLabel: '参考图转换完成',\n      displayMode: 'detail',\n    })\n    return { success: true }\n  }\n\n  const mainCosKey = successfulCosKeys[0]\n  const mainSignedUrl = getSignedUrl(mainCosKey, 7 * 24 * 3600)\n\n  await reportTaskProgress(job, 96, {\n    stage: 'reference_to_character_done',\n    stageLabel: '参考图转换完成',\n    displayMode: 'detail',\n  })\n\n  return {\n    success: true,\n    imageUrl: mainSignedUrl,\n    cosKey: mainCosKey,\n    cosKeys: successfulCosKeys,\n    description,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/resolve-analysis-model.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { composeModelKey, parseModelKeyStrict } from '@/lib/model-config-contract'\n\ntype ResolveAnalysisModelInput = {\n  userId: string\n  inputModel?: unknown\n  projectAnalysisModel?: unknown\n}\n\nfunction normalizeModelKey(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  if (!trimmed) return null\n  const parsed = parseModelKeyStrict(trimmed)\n  if (!parsed) return null\n  return composeModelKey(parsed.provider, parsed.modelId)\n}\n\nexport async function resolveAnalysisModel(input: ResolveAnalysisModelInput): Promise<string> {\n  const modelFromInput = normalizeModelKey(input.inputModel)\n  if (modelFromInput) return modelFromInput\n\n  const modelFromProject = normalizeModelKey(input.projectAnalysisModel)\n  if (modelFromProject) return modelFromProject\n\n  const userPreference = await prisma.userPreference.findUnique({\n    where: { userId: input.userId },\n    select: { analysisModel: true },\n  })\n  const modelFromUserPreference = normalizeModelKey(userPreference?.analysisModel)\n  if (modelFromUserPreference) return modelFromUserPreference\n\n  throw new Error('ANALYSIS_MODEL_NOT_CONFIGURED: 请先在设置页面配置分析模型')\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/screenplay-convert-helpers.ts",
    "content": "import { safeParseJsonObject } from '@/lib/json-repair'\n\nexport type AnyObj = Record<string, unknown>\n\nexport function readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nexport function parseScreenplayPayload(responseText: string): AnyObj {\n  const parsed = safeParseJsonObject(responseText)\n  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n    throw new Error('AI returned invalid screenplay JSON object')\n  }\n  return parsed as AnyObj\n}\n\n"
  },
  {
    "path": "src/lib/workers/handlers/screenplay-convert.ts",
    "content": "import type { Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { buildCharactersIntroduction } from '@/lib/constants'\nimport { TaskTerminatedError } from '@/lib/task/errors'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { logAIAnalysis } from '@/lib/logging/semantic'\nimport { onProjectNameAvailable } from '@/lib/logging/file-writer'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\nimport {\n  type AnyObj,\n  parseScreenplayPayload,\n  readText,\n} from './screenplay-convert-helpers'\nimport { getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport { resolveAnalysisModel } from './resolve-analysis-model'\n\nconst MAX_SCREENPLAY_ATTEMPTS = 2\n\nexport async function handleScreenplayConvertTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const projectId = job.data.projectId\n  const episodeId = readText(payload.episodeId || job.data.episodeId).trim()\n  if (!episodeId) {\n    throw new Error('episodeId is required')\n  }\n\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: {\n      id: true,\n      name: true,\n      mode: true,\n    },\n  })\n  if (!project) {\n    throw new Error('Project not found')\n  }\n  if (project.mode !== 'novel-promotion') {\n    throw new Error('Not a novel promotion project')\n  }\n\n  const novelData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      characters: true,\n      locations: true,\n    },\n  })\n  if (!novelData) {\n    throw new Error('Novel promotion data not found')\n  }\n  const analysisModel = await resolveAnalysisModel({\n    userId: job.data.userId,\n    inputModel: payload.model,\n    projectAnalysisModel: novelData.analysisModel,\n  })\n\n  const episode = await prisma.novelPromotionEpisode.findUnique({\n    where: { id: episodeId },\n    include: {\n      clips: {\n        orderBy: { createdAt: 'asc' },\n      },\n    },\n  })\n  if (!episode) {\n    throw new Error('Episode not found')\n  }\n  if (episode.novelPromotionProjectId !== novelData.id) {\n    throw new Error('Episode does not belong to this project')\n  }\n  if (episode.clips.length === 0) {\n    throw new Error('No clips found, please split clips first')\n  }\n\n  const screenplayPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_SCREENPLAY_CONVERSION, job.data.locale)\n  const charactersLibName = novelData.characters.map((item) => item.name).join('、') || '无'\n  const locationsLibName = novelData.locations.map((item) => item.name).join('、') || '无'\n  const charactersIntroduction = buildCharactersIntroduction(novelData.characters)\n\n  await reportTaskProgress(job, 10, {\n    stage: 'screenplay_convert_prepare',\n    stageLabel: '准备剧本转换参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'screenplay_convert_prepare')\n\n  const streamContext = createWorkerLLMStreamContext(job, 'screenplay_convert')\n  const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)\n  const total = episode.clips.length\n  const results: Array<{\n    clipId: string\n    success: boolean\n    sceneCount?: number\n    error?: string\n  }> = []\n\n  for (let i = 0; i < total; i += 1) {\n    const clip = episode.clips[i]\n    const stepIndex = i + 1\n    const stepId = `screenplay_clip_${clip.id}`\n    const stepTitle = `片段剧本转换 ${stepIndex}/${total}`\n    const progress = 15 + Math.min(70, Math.floor((stepIndex / Math.max(1, total)) * 70))\n\n    await assertTaskActive(job, `screenplay_convert_step:${clip.id}`)\n    await reportTaskProgress(job, progress, {\n      stage: 'screenplay_convert_step',\n      stageLabel: '执行剧本转换',\n      displayMode: 'detail',\n      message: stepTitle,\n      stepId,\n      stepTitle,\n      stepIndex,\n      stepTotal: total,\n    })\n\n    try {\n      const clipContent = readText(clip.content).trim()\n      if (!clipContent) {\n        throw new Error(`clip ${clip.id} content is empty`)\n      }\n\n      const prompt = screenplayPromptTemplate\n        .replace('{clip_content}', clipContent)\n        .replace('{locations_lib_name}', locationsLibName)\n        .replace('{characters_lib_name}', charactersLibName)\n        .replace('{characters_introduction}', charactersIntroduction)\n        .replace('{clip_id}', clip.id)\n\n      // 记录 prompt 输入\n      onProjectNameAvailable(projectId, project.name)\n      logAIAnalysis(job.data.userId, 'worker', projectId, project.name, {\n        action: `SCREENPLAY_CONVERT_PROMPT`,\n        input: { stepId, stepTitle, prompt },\n        model: analysisModel,\n      })\n\n      let screenplayStored = false\n      let stepLastError: Error | null = null\n      for (let attempt = 1; attempt <= MAX_SCREENPLAY_ATTEMPTS; attempt += 1) {\n        try {\n          const completion = await (async () => {\n            try {\n              return await withInternalLLMStreamCallbacks(\n                streamCallbacks,\n                async () =>\n                  await executeAiTextStep({\n                    userId: job.data.userId,\n                    model: analysisModel,\n                    messages: [{ role: 'user', content: prompt }],\n                    reasoning: true,\n                    projectId,\n                    action: 'screenplay_conversion',\n                    meta: {\n                      stepId,\n                      stepAttempt: attempt,\n                      stepTitle,\n                      stepIndex,\n                      stepTotal: total,\n                    },\n                  }),\n              )\n            } finally {\n              await streamCallbacks.flush()\n            }\n          })()\n\n          const responseText = completion.text\n          if (!responseText || !responseText.trim()) {\n            throw new Error('AI returned empty response')\n          }\n\n          // 记录 AI 输出\n          logAIAnalysis(job.data.userId, 'worker', projectId, project.name, {\n            action: `SCREENPLAY_CONVERT_OUTPUT`,\n            output: {\n              stepId,\n              stepTitle,\n              attempt,\n              rawText: responseText,\n              textLength: responseText.length,\n            },\n            model: analysisModel,\n          })\n\n          const screenplay = parseScreenplayPayload(responseText)\n          screenplay.clip_id = clip.id\n          screenplay.original_text = clipContent\n\n          await prisma.novelPromotionClip.update({\n            where: { id: clip.id },\n            data: {\n              screenplay: JSON.stringify(screenplay),\n            },\n          })\n\n          const scenes = Array.isArray(screenplay.scenes) ? screenplay.scenes : []\n          results.push({\n            clipId: clip.id,\n            success: true,\n            sceneCount: scenes.length,\n          })\n          screenplayStored = true\n          break\n        } catch (error) {\n          if (error instanceof TaskTerminatedError) {\n            throw error\n          }\n          stepLastError = error instanceof Error ? error : new Error(String(error))\n        }\n      }\n\n      if (!screenplayStored) {\n        throw stepLastError || new Error(`clip ${clip.id} screenplay conversion failed`)\n      }\n    } catch (error) {\n      if (error instanceof TaskTerminatedError) {\n        throw error\n      }\n      results.push({\n        clipId: clip.id,\n        success: false,\n        error: error instanceof Error ? error.message : String(error),\n      })\n    }\n  }\n\n  const successCount = results.filter((item) => item.success).length\n  const failCount = results.length - successCount\n  const totalScenes = results.reduce((sum, item) => sum + (item.sceneCount || 0), 0)\n\n  if (failCount > 0) {\n    const failedItems = results\n      .filter((item) => !item.success)\n      .map((item) => `${item.clipId}:${item.error || 'unknown error'}`)\n    const preview = failedItems.slice(0, 3).join(' | ')\n    throw new Error(\n      `SCREENPLAY_CONVERT_PARTIAL_FAILED: ${failCount}/${total} clips failed. ${preview}`,\n    )\n  }\n\n  await reportTaskProgress(job, 96, {\n    stage: 'screenplay_convert_done',\n    stageLabel: '剧本转换结果已保存',\n    displayMode: 'detail',\n    message: `完成 ${successCount}/${total} 个片段`,\n  })\n\n  return {\n    episodeId,\n    total,\n    successCount,\n    failCount,\n    totalScenes,\n    results,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/script-to-storyboard-atomic-retry.ts",
    "content": "import { safeParseJsonArray } from '@/lib/json-repair'\nimport { buildCharactersIntroduction } from '@/lib/constants'\nimport { normalizeAnyError } from '@/lib/errors/normalize'\nimport type {\n  ScriptToStoryboardPromptTemplates,\n  ScriptToStoryboardStepMeta,\n  ScriptToStoryboardStepOutput,\n} from '@/lib/novel-promotion/script-to-storyboard/orchestrator'\nimport { listArtifacts } from '@/lib/run-runtime/service'\nimport {\n  type ActingDirection,\n  type CharacterAsset,\n  type ClipCharacterRef,\n  formatClipId,\n  getFilteredAppearanceList,\n  getFilteredFullDescription,\n  getFilteredLocationsDescription,\n  type LocationAsset,\n  type PhotographyRule,\n  type StoryboardPanel,\n} from '@/lib/storyboard-phases'\nimport type { ClipPanelsResult, JsonRecord } from './script-to-storyboard-helpers'\n\ntype StoryboardClipInput = {\n  id: string\n  content: string | null\n  characters: string | null\n  location: string | null\n  screenplay: string | null\n}\n\nexport type StoryboardRetryPhase = 'phase1' | 'phase2_cinematography' | 'phase2_acting' | 'phase3_detail'\n\nexport type StoryboardRetryTarget = {\n  stepKey: string\n  clipId: string\n  phase: StoryboardRetryPhase\n}\n\nexport type ScriptToStoryboardAtomicRetryResult = {\n  clipPanels: ClipPanelsResult[]\n  phase1PanelsByClipId: Record<string, StoryboardPanel[]>\n  phase2CinematographyByClipId: Record<string, PhotographyRule[]>\n  phase2ActingByClipId: Record<string, ActingDirection[]>\n  phase3PanelsByClipId: Record<string, StoryboardPanel[]>\n  totalPanelCount: number\n  totalStepCount: number\n}\n\ntype StepRunner = (\n  meta: ScriptToStoryboardStepMeta,\n  prompt: string,\n  action: string,\n  maxOutputTokens: number,\n) => Promise<ScriptToStoryboardStepOutput>\n\nconst MAX_STEP_ATTEMPTS = 3\nconst MAX_RETRY_DELAY_MS = 10_000\n\nfunction asObject(value: unknown): Record<string, unknown> | null {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n  return value as Record<string, unknown>\n}\n\nfunction asObjectArray(value: unknown): JsonRecord[] {\n  if (!Array.isArray(value)) return []\n  return value.filter((item): item is JsonRecord => typeof item === 'object' && item !== null)\n}\n\nfunction parseClipCharacters(raw: string | null): ClipCharacterRef[] {\n  if (!raw) return []\n  try {\n    const parsed = JSON.parse(raw)\n    if (!Array.isArray(parsed)) {\n      throw new Error('characters field must be JSON array')\n    }\n    return parsed as ClipCharacterRef[]\n  } catch (error) {\n    throw new Error(`Invalid clip characters JSON: ${error instanceof Error ? error.message : String(error)}`)\n  }\n}\n\nfunction parseScreenplay(raw: string | null): unknown {\n  if (!raw) return null\n  try {\n    return JSON.parse(raw)\n  } catch (error) {\n    throw new Error(`Invalid clip screenplay JSON: ${error instanceof Error ? error.message : String(error)}`)\n  }\n}\n\nfunction parseJsonArray<T extends JsonRecord>(responseText: string, label: string): T[] {\n  const rows = safeParseJsonArray(responseText)\n  if (rows.length === 0) {\n    throw new Error(`${label}: empty result`)\n  }\n  return rows as T[]\n}\n\nfunction shouldRetryStepError(error: unknown, message: string, retryable: boolean) {\n  if (retryable) return true\n  const lowerMessage = message.toLowerCase()\n  return lowerMessage.includes('json') || lowerMessage.includes('parse')\n}\n\nfunction computeRetryDelayMs(attempt: number) {\n  const base = Math.min(1_000 * Math.pow(2, Math.max(0, attempt - 1)), MAX_RETRY_DELAY_MS)\n  const jitter = Math.floor(Math.random() * 300)\n  return base + jitter\n}\n\nfunction wait(ms: number) {\n  return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nfunction extractArtifactRows<T extends JsonRecord>(payload: unknown, key: string): T[] {\n  const record = asObject(payload)\n  if (!record) return []\n  return asObjectArray(record[key]) as T[]\n}\n\nasync function readArtifactRows<T extends JsonRecord>(params: {\n  runId: string\n  clipId: string\n  artifactType: string\n  key: string\n}) {\n  const rows = await listArtifacts({\n    runId: params.runId,\n    artifactType: params.artifactType,\n    refId: params.clipId,\n    limit: 1,\n  })\n  const artifact = rows[0]\n  if (!artifact) return []\n  return extractArtifactRows<T>(artifact.payload, params.key)\n}\n\nfunction getStepNumbers(params: {\n  phase: StoryboardRetryPhase\n  clipIndex: number\n  totalClipCount: number\n}) {\n  const zeroBasedClipIndex = params.clipIndex\n  const totalStepCount = params.totalClipCount * 4 + 2\n  if (params.phase === 'phase1') {\n    return { stepIndex: zeroBasedClipIndex + 1, stepTotal: totalStepCount }\n  }\n  if (params.phase === 'phase2_cinematography') {\n    return {\n      stepIndex: params.totalClipCount + zeroBasedClipIndex * 3 + 1,\n      stepTotal: totalStepCount,\n    }\n  }\n  if (params.phase === 'phase2_acting') {\n    return {\n      stepIndex: params.totalClipCount + zeroBasedClipIndex * 3 + 2,\n      stepTotal: totalStepCount,\n    }\n  }\n  return {\n    stepIndex: params.totalClipCount + zeroBasedClipIndex * 3 + 3,\n    stepTotal: totalStepCount,\n  }\n}\n\nfunction buildStepMeta(params: {\n  target: StoryboardRetryTarget\n  clipIndex: number\n  totalClipCount: number\n}): ScriptToStoryboardStepMeta {\n  const stepNumbers = getStepNumbers({\n    phase: params.target.phase,\n    clipIndex: params.clipIndex,\n    totalClipCount: params.totalClipCount,\n  })\n  const stepKey = params.target.stepKey\n  const groupId = `clip_${params.target.clipId}`\n\n  if (params.target.phase === 'phase1') {\n    return {\n      stepId: stepKey,\n      stepTitle: 'progress.streamStep.storyboardPlan',\n      stepIndex: stepNumbers.stepIndex,\n      stepTotal: stepNumbers.stepTotal,\n      groupId,\n      parallelKey: 'phase1',\n      retryable: true,\n    }\n  }\n  if (params.target.phase === 'phase2_cinematography') {\n    return {\n      stepId: stepKey,\n      stepTitle: 'progress.streamStep.cinematographyRules',\n      stepIndex: stepNumbers.stepIndex,\n      stepTotal: stepNumbers.stepTotal,\n      dependsOn: [`clip_${params.target.clipId}_phase1`],\n      groupId,\n      parallelKey: 'phase2',\n      retryable: true,\n    }\n  }\n  if (params.target.phase === 'phase2_acting') {\n    return {\n      stepId: stepKey,\n      stepTitle: 'progress.streamStep.actingDirection',\n      stepIndex: stepNumbers.stepIndex,\n      stepTotal: stepNumbers.stepTotal,\n      dependsOn: [`clip_${params.target.clipId}_phase1`],\n      groupId,\n      parallelKey: 'phase2',\n      retryable: true,\n    }\n  }\n  return {\n    stepId: stepKey,\n    stepTitle: 'progress.streamStep.storyboardDetailRefine',\n    stepIndex: stepNumbers.stepIndex,\n    stepTotal: stepNumbers.stepTotal,\n    dependsOn: [\n      `clip_${params.target.clipId}_phase2_cinematography`,\n      `clip_${params.target.clipId}_phase2_acting`,\n    ],\n    groupId,\n    parallelKey: 'phase3',\n    retryable: true,\n  }\n}\n\nasync function runStepWithRetry<T>(params: {\n  runStep: StepRunner\n  baseMeta: ScriptToStoryboardStepMeta\n  prompt: string\n  action: string\n  maxOutputTokens: number\n  parse: (text: string) => T\n  retryStepAttempt: number\n}) {\n  let lastError: Error | null = null\n  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt += 1) {\n    const stepAttempt = params.retryStepAttempt + attempt - 1\n    const meta: ScriptToStoryboardStepMeta = {\n      ...params.baseMeta,\n      stepAttempt,\n    }\n    try {\n      const output = await params.runStep(meta, params.prompt, params.action, params.maxOutputTokens)\n      const parsed = params.parse(output.text)\n      return parsed\n    } catch (error) {\n      lastError = error instanceof Error ? error : new Error(String(error))\n      const normalized = normalizeAnyError(error, { context: 'worker' })\n      const shouldRetry = attempt < MAX_STEP_ATTEMPTS\n        && shouldRetryStepError(error, normalized.message, normalized.retryable)\n      if (!shouldRetry) break\n      const retryDelayMs = computeRetryDelayMs(attempt)\n      await wait(retryDelayMs)\n    }\n  }\n  throw lastError || new Error('step execution failed')\n}\n\nfunction mergePanelsWithRules(params: {\n  finalPanels: StoryboardPanel[]\n  photographyRules: PhotographyRule[]\n  actingDirections: ActingDirection[]\n}) {\n  const { finalPanels, photographyRules, actingDirections } = params\n  return finalPanels.map((panel, index) => {\n    const rule = photographyRules.find((item) => item.panel_number === panel.panel_number)\n    if (!rule) {\n      throw new Error(`Missing photography rule for panel_number=${String(panel.panel_number)} at index=${index}`)\n    }\n    const acting = actingDirections.find((item) => item.panel_number === panel.panel_number)\n    if (!acting) {\n      throw new Error(`Missing acting direction for panel_number=${String(panel.panel_number)} at index=${index}`)\n    }\n    return {\n      ...panel,\n      photographyPlan: {\n        composition: rule.composition,\n        lighting: rule.lighting,\n        colorPalette: rule.color_palette,\n        atmosphere: rule.atmosphere,\n        technicalNotes: rule.technical_notes,\n      },\n      actingNotes: acting.characters,\n    }\n  })\n}\n\nfunction requireRows<T extends JsonRecord>(rows: T[], label: string) {\n  if (rows.length === 0) {\n    throw new Error(`missing dependency artifact: ${label}`)\n  }\n  return rows\n}\n\nexport function parseStoryboardRetryTarget(stepKey: string): StoryboardRetryTarget | null {\n  const trimmed = stepKey.trim()\n  if (!trimmed) return null\n  const match = /^clip_(.+)_(phase1|phase2_cinematography|phase2_acting|phase3_detail)$/.exec(trimmed)\n  if (!match) return null\n  const clipId = (match[1] || '').trim()\n  const phase = match[2] as StoryboardRetryPhase\n  if (!clipId) return null\n  return {\n    stepKey: trimmed,\n    clipId,\n    phase,\n  }\n}\n\nexport async function runScriptToStoryboardAtomicRetry(params: {\n  runId: string\n  retryTarget: StoryboardRetryTarget\n  retryStepAttempt: number\n  clip: StoryboardClipInput\n  clipIndex: number\n  totalClipCount: number\n  novelPromotionData: {\n    characters: CharacterAsset[]\n    locations: LocationAsset[]\n  }\n  promptTemplates: ScriptToStoryboardPromptTemplates\n  runStep: StepRunner\n}): Promise<ScriptToStoryboardAtomicRetryResult> {\n  const clipCharacters = parseClipCharacters(params.clip.characters)\n  const clipLocation = params.clip.location || null\n  const filteredFullDescription = getFilteredFullDescription(params.novelPromotionData.characters || [], clipCharacters)\n  const filteredLocationsDescription = getFilteredLocationsDescription(\n    params.novelPromotionData.locations || [],\n    clipLocation,\n  )\n  const baseMeta = buildStepMeta({\n    target: params.retryTarget,\n    clipIndex: params.clipIndex,\n    totalClipCount: params.totalClipCount,\n  })\n\n  const phase1PanelsByClipId: Record<string, StoryboardPanel[]> = {}\n  const phase2CinematographyByClipId: Record<string, PhotographyRule[]> = {}\n  const phase2ActingByClipId: Record<string, ActingDirection[]> = {}\n  const phase3PanelsByClipId: Record<string, StoryboardPanel[]> = {}\n  const clipPanels: ClipPanelsResult[] = []\n\n  let phase1Panels = await readArtifactRows<StoryboardPanel>({\n    runId: params.runId,\n    clipId: params.retryTarget.clipId,\n    artifactType: 'storyboard.clip.phase1',\n    key: 'panels',\n  })\n  let phase2Cinematography = await readArtifactRows<PhotographyRule>({\n    runId: params.runId,\n    clipId: params.retryTarget.clipId,\n    artifactType: 'storyboard.clip.phase2.cine',\n    key: 'rules',\n  })\n  let phase2Acting = await readArtifactRows<ActingDirection>({\n    runId: params.runId,\n    clipId: params.retryTarget.clipId,\n    artifactType: 'storyboard.clip.phase2.acting',\n    key: 'directions',\n  })\n  let phase3Panels = await readArtifactRows<StoryboardPanel>({\n    runId: params.runId,\n    clipId: params.retryTarget.clipId,\n    artifactType: 'storyboard.clip.phase3',\n    key: 'panels',\n  })\n\n  if (params.retryTarget.phase === 'phase1') {\n    const clipContent = typeof params.clip.content === 'string' ? params.clip.content.trim() : ''\n    if (!clipContent) {\n      throw new Error(`Clip ${formatClipId(params.clip)} content is empty`)\n    }\n    const filteredAppearanceList = getFilteredAppearanceList(params.novelPromotionData.characters || [], clipCharacters)\n    const charactersLibName = (params.novelPromotionData.characters || []).map((item) => item.name).join(', ') || '无'\n    const locationsLibName = (params.novelPromotionData.locations || []).map((item) => item.name).join(', ') || '无'\n    const charactersIntroduction = buildCharactersIntroduction(params.novelPromotionData.characters || [])\n    const clipJson = JSON.stringify(\n      {\n        id: params.clip.id,\n        content: clipContent,\n        characters: clipCharacters,\n        location: clipLocation,\n      },\n      null,\n      2,\n    )\n    let phase1Prompt = params.promptTemplates.phase1PlanTemplate\n      .replace('{characters_lib_name}', charactersLibName)\n      .replace('{locations_lib_name}', locationsLibName)\n      .replace('{characters_introduction}', charactersIntroduction)\n      .replace('{characters_appearance_list}', filteredAppearanceList)\n      .replace('{characters_full_description}', filteredFullDescription)\n      .replace('{clip_json}', clipJson)\n    const screenplay = parseScreenplay(params.clip.screenplay)\n    if (screenplay) {\n      phase1Prompt = phase1Prompt.replace('{clip_content}', `【剧本格式】\\n${JSON.stringify(screenplay, null, 2)}`)\n    } else {\n      phase1Prompt = phase1Prompt.replace('{clip_content}', clipContent)\n    }\n    phase1Panels = await runStepWithRetry({\n      runStep: params.runStep,\n      baseMeta,\n      prompt: phase1Prompt,\n      action: 'storyboard_phase1_plan',\n      maxOutputTokens: 2600,\n      parse: (text) => {\n        const panels = parseJsonArray<StoryboardPanel>(text, `phase1:${formatClipId(params.clip)}`)\n        if (panels.length === 0) {\n          throw new Error(`Phase 1 returned empty panels for clip ${formatClipId(params.clip)}`)\n        }\n        return panels\n      },\n      retryStepAttempt: params.retryStepAttempt,\n    })\n    phase1PanelsByClipId[params.clip.id] = phase1Panels\n  } else if (params.retryTarget.phase === 'phase2_cinematography') {\n    const planPanels = requireRows(phase1Panels, 'storyboard.clip.phase1')\n    const phase2Prompt = params.promptTemplates.phase2CinematographyTemplate\n      .replace('{panels_json}', JSON.stringify(planPanels, null, 2))\n      .replace(/\\{panel_count\\}/g, String(planPanels.length))\n      .replace('{locations_description}', filteredLocationsDescription)\n      .replace('{characters_info}', filteredFullDescription)\n    phase2Cinematography = await runStepWithRetry({\n      runStep: params.runStep,\n      baseMeta,\n      prompt: phase2Prompt,\n      action: 'storyboard_phase2_cinematography',\n      maxOutputTokens: 2400,\n      parse: (text) => parseJsonArray<PhotographyRule>(text, `phase2:${formatClipId(params.clip)}`),\n      retryStepAttempt: params.retryStepAttempt,\n    })\n    phase2CinematographyByClipId[params.clip.id] = phase2Cinematography\n  } else if (params.retryTarget.phase === 'phase2_acting') {\n    const planPanels = requireRows(phase1Panels, 'storyboard.clip.phase1')\n    const phase2ActingPrompt = params.promptTemplates.phase2ActingTemplate\n      .replace('{panels_json}', JSON.stringify(planPanels, null, 2))\n      .replace(/\\{panel_count\\}/g, String(planPanels.length))\n      .replace('{characters_info}', filteredFullDescription)\n    phase2Acting = await runStepWithRetry({\n      runStep: params.runStep,\n      baseMeta,\n      prompt: phase2ActingPrompt,\n      action: 'storyboard_phase2_acting',\n      maxOutputTokens: 2400,\n      parse: (text) => parseJsonArray<ActingDirection>(text, `phase2-acting:${formatClipId(params.clip)}`),\n      retryStepAttempt: params.retryStepAttempt,\n    })\n    phase2ActingByClipId[params.clip.id] = phase2Acting\n  } else {\n    const planPanels = requireRows(phase1Panels, 'storyboard.clip.phase1')\n    const phase3Prompt = params.promptTemplates.phase3DetailTemplate\n      .replace('{panels_json}', JSON.stringify(planPanels, null, 2))\n      .replace('{characters_age_gender}', filteredFullDescription)\n      .replace('{locations_description}', filteredLocationsDescription)\n    phase3Panels = await runStepWithRetry({\n      runStep: params.runStep,\n      baseMeta,\n      prompt: phase3Prompt,\n      action: 'storyboard_phase3_detail',\n      maxOutputTokens: 2600,\n      parse: (text) => {\n        const parsed = parseJsonArray<StoryboardPanel>(text, `phase3:${formatClipId(params.clip)}`)\n        const filtered = parsed.filter(\n          (panel) => panel.description && panel.description !== '无' && panel.location !== '无',\n        )\n        if (filtered.length === 0) {\n          throw new Error(`Phase 3 returned empty valid panels for clip ${formatClipId(params.clip)}`)\n        }\n        return filtered\n      },\n      retryStepAttempt: params.retryStepAttempt,\n    })\n    phase3PanelsByClipId[params.clip.id] = phase3Panels\n  }\n\n  if (params.retryTarget.phase !== 'phase1') {\n    const finalPanels = mergePanelsWithRules({\n      finalPanels: requireRows(phase3Panels, 'storyboard.clip.phase3'),\n      photographyRules: requireRows(phase2Cinematography, 'storyboard.clip.phase2.cine'),\n      actingDirections: requireRows(phase2Acting, 'storyboard.clip.phase2.acting'),\n    })\n    clipPanels.push({\n      clipId: params.clip.id,\n      clipIndex: params.clipIndex + 1,\n      finalPanels,\n    })\n  }\n\n  const totalPanelCount = clipPanels.reduce((sum, item) => sum + item.finalPanels.length, 0)\n  return {\n    clipPanels,\n    phase1PanelsByClipId,\n    phase2CinematographyByClipId,\n    phase2ActingByClipId,\n    phase3PanelsByClipId,\n    totalPanelCount,\n    totalStepCount: params.totalClipCount * 4 + 2,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/script-to-storyboard-helpers.ts",
    "content": "import { safeParseJson, safeParseJsonArray } from '@/lib/json-repair'\nimport { prisma } from '@/lib/prisma'\nimport type { StoryboardPanel } from '@/lib/storyboard-phases'\n\nexport type JsonRecord = Record<string, unknown>\n\nexport type ClipPanelsResult = {\n  clipId: string\n  clipIndex: number\n  finalPanels: StoryboardPanel[]\n}\n\nexport type PersistedStoryboard = {\n  storyboardId: string\n  clipId: string\n  panels: Array<{\n    id: string\n    panelIndex: number\n    description: string | null\n    srtSegment: string | null\n    characters: string | null\n  }>\n}\n\nexport function parseEffort(value: unknown): 'minimal' | 'low' | 'medium' | 'high' | null {\n  if (value === 'minimal' || value === 'low' || value === 'medium' || value === 'high') return value\n  return null\n}\n\nexport function parseTemperature(value: unknown): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return 0.7\n  return Math.max(0, Math.min(2, value))\n}\n\nexport function toPositiveInt(value: unknown): number | null {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return null\n  const n = Math.floor(value)\n  return n >= 0 ? n : null\n}\n\nfunction parsePanelCharacters(raw: string | null): string[] {\n  if (!raw) return []\n  try {\n    const parsed = JSON.parse(raw)\n    if (!Array.isArray(parsed)) return []\n    return parsed.map((item) => (typeof item === 'string' ? item : item?.name)).filter(Boolean)\n  } catch {\n    return []\n  }\n}\n\nexport function parseVoiceLinesJson(responseText: string): JsonRecord[] {\n  const rows = safeParseJsonArray(responseText)\n  if (rows.length === 0) {\n    const raw = safeParseJson(responseText)\n    if (Array.isArray(raw) && raw.length === 0) {\n      return []\n    }\n    throw new Error('voice_analyze: invalid payload')\n  }\n  return rows as JsonRecord[]\n}\n\nexport function asJsonRecord(value: unknown): JsonRecord | null {\n  return typeof value === 'object' && value !== null ? (value as JsonRecord) : null\n}\n\nexport function buildStoryboardJson(storyboards: PersistedStoryboard[]) {\n  const rows: Array<{\n    storyboardId: string\n    panelIndex: number\n    text_segment: string\n    description: string\n    characters: string[]\n  }> = []\n\n  for (const storyboard of storyboards) {\n    for (const panel of storyboard.panels) {\n      rows.push({\n        storyboardId: storyboard.storyboardId,\n        panelIndex: panel.panelIndex,\n        text_segment: panel.srtSegment || '',\n        description: panel.description || '',\n        characters: parsePanelCharacters(panel.characters),\n      })\n    }\n  }\n\n  if (rows.length === 0) return '无分镜数据'\n  return JSON.stringify(rows, null, 2)\n}\n\nexport async function persistStoryboardsAndPanels(params: {\n  episodeId: string\n  clipPanels: ClipPanelsResult[]\n}) {\n  const { episodeId, clipPanels } = params\n  return await prisma.$transaction(async (tx) => {\n    const persisted: PersistedStoryboard[] = []\n    for (const clipEntry of clipPanels) {\n      const storyboard = await tx.novelPromotionStoryboard.upsert({\n        where: { clipId: clipEntry.clipId },\n        create: {\n          clipId: clipEntry.clipId,\n          episodeId,\n          panelCount: clipEntry.finalPanels.length,\n        },\n        update: {\n          panelCount: clipEntry.finalPanels.length,\n          episodeId,\n          lastError: null,\n        },\n        select: { id: true, clipId: true },\n      })\n\n      await tx.novelPromotionPanel.deleteMany({\n        where: { storyboardId: storyboard.id },\n      })\n\n      const persistedPanels: PersistedStoryboard['panels'] = []\n      for (let i = 0; i < clipEntry.finalPanels.length; i += 1) {\n        const panel = clipEntry.finalPanels[i]\n        const created = await tx.novelPromotionPanel.create({\n          data: {\n            storyboardId: storyboard.id,\n            panelIndex: i,\n            panelNumber: panel.panel_number || i + 1,\n            shotType: panel.shot_type || '中景',\n            cameraMove: panel.camera_move || '固定',\n            description: panel.description || null,\n            videoPrompt: panel.video_prompt || null,\n            location: panel.location || null,\n            characters: panel.characters ? JSON.stringify(panel.characters) : null,\n            srtSegment: panel.source_text || null,\n            photographyRules: panel.photographyPlan ? JSON.stringify(panel.photographyPlan) : null,\n            actingNotes: panel.actingNotes ? JSON.stringify(panel.actingNotes) : null,\n            duration: panel.duration || null,\n          },\n          select: {\n            id: true,\n            panelIndex: true,\n            description: true,\n            srtSegment: true,\n            characters: true,\n          },\n        })\n        persistedPanels.push(created)\n      }\n\n      persisted.push({\n        storyboardId: storyboard.id,\n        clipId: storyboard.clipId,\n        panels: persistedPanels,\n      })\n    }\n    return persisted\n  }, { timeout: 30000 })\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/script-to-storyboard.ts",
    "content": "import type { Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport {\n  getUserWorkflowConcurrencyConfig,\n  resolveProjectModelCapabilityGenerationOptions,\n} from '@/lib/config-service'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { logAIAnalysis } from '@/lib/logging/semantic'\nimport { onProjectNameAvailable } from '@/lib/logging/file-writer'\nimport { buildCharactersIntroduction } from '@/lib/constants'\nimport { TaskTerminatedError } from '@/lib/task/errors'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport {\n  JsonParseError,\n  runScriptToStoryboardOrchestrator,\n  type ScriptToStoryboardStepMeta,\n  type ScriptToStoryboardStepOutput,\n  type ScriptToStoryboardOrchestratorResult,\n} from '@/lib/novel-promotion/script-to-storyboard/orchestrator'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\nimport {\n  asJsonRecord,\n  buildStoryboardJson,\n  parseEffort,\n  parseTemperature,\n  parseVoiceLinesJson,\n  persistStoryboardsAndPanels,\n  toPositiveInt,\n  type JsonRecord,\n} from './script-to-storyboard-helpers'\nimport { buildPrompt, getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport { resolveAnalysisModel } from './resolve-analysis-model'\nimport { createArtifact } from '@/lib/run-runtime/service'\nimport { assertWorkflowRunActive, withWorkflowRunLease } from '@/lib/run-runtime/workflow-lease'\nimport {\n  parseStoryboardRetryTarget,\n  runScriptToStoryboardAtomicRetry,\n} from './script-to-storyboard-atomic-retry'\n\ntype AnyObj = Record<string, unknown>\nconst MAX_VOICE_ANALYZE_ATTEMPTS = 2\n\nfunction buildWorkflowWorkerId(job: Job<TaskJobData>, label: string) {\n  return `${label}:${job.queueName}:${job.data.taskId}`\n}\n\nfunction isReasoningEffort(value: unknown): value is 'minimal' | 'low' | 'medium' | 'high' {\n  return value === 'minimal' || value === 'low' || value === 'medium' || value === 'high'\n}\n\nexport async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const projectId = job.data.projectId\n  const episodeIdRaw = typeof payload.episodeId === 'string' ? payload.episodeId : (job.data.episodeId || '')\n  const episodeId = episodeIdRaw.trim()\n  const inputModel = typeof payload.model === 'string' ? payload.model.trim() : ''\n  const retryStepKey = typeof payload.retryStepKey === 'string' ? payload.retryStepKey.trim() : ''\n  const retryStepAttempt = typeof payload.retryStepAttempt === 'number' && Number.isFinite(payload.retryStepAttempt)\n    ? Math.max(1, Math.floor(payload.retryStepAttempt))\n    : 1\n  const reasoning = payload.reasoning !== false\n  const requestedReasoningEffort = parseEffort(payload.reasoningEffort)\n  const temperature = parseTemperature(payload.temperature)\n\n  if (!episodeId) {\n    throw new Error('episodeId is required')\n  }\n\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: {\n      id: true,\n      name: true,\n      mode: true,\n    },\n  })\n  if (!project) {\n    throw new Error('Project not found')\n  }\n  if (project.mode !== 'novel-promotion') {\n    throw new Error('Not a novel promotion project')\n  }\n\n  // Register project name for per-project log file routing\n  onProjectNameAvailable(projectId, project.name)\n\n  const novelData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      characters: true,\n      locations: true,\n    },\n  })\n  if (!novelData) {\n    throw new Error('Novel promotion data not found')\n  }\n\n  const episode = await prisma.novelPromotionEpisode.findUnique({\n    where: { id: episodeId },\n    include: {\n      clips: { orderBy: { createdAt: 'asc' } },\n    },\n  })\n  if (!episode || episode.novelPromotionProjectId !== novelData.id) {\n    throw new Error('Episode not found')\n  }\n  const clips = episode.clips || []\n  if (clips.length === 0) {\n    throw new Error('No clips found')\n  }\n  const retryTarget = parseStoryboardRetryTarget(retryStepKey)\n  if (retryStepKey && retryStepKey !== 'voice_analyze' && !retryTarget) {\n    throw new Error(`unsupported retry step for script_to_storyboard: ${retryStepKey}`)\n  }\n  const retryClipId = retryTarget?.clipId || null\n  const selectedClips = retryClipId\n    ? clips.filter((clip) => clip.id === retryClipId)\n    : clips\n  if (retryClipId && selectedClips.length === 0) {\n    throw new Error(`Retry clip not found: ${retryClipId}`)\n  }\n  const skipVoiceAnalyze = !!retryStepKey && retryStepKey !== 'voice_analyze'\n\n  const model = await resolveAnalysisModel({\n    userId: job.data.userId,\n    inputModel,\n    projectAnalysisModel: novelData.analysisModel,\n  })\n  const [llmCapabilityOptions, workflowConcurrency] = await Promise.all([\n    resolveProjectModelCapabilityGenerationOptions({\n      projectId,\n      userId: job.data.userId,\n      modelType: 'llm',\n      modelKey: model,\n    }),\n    getUserWorkflowConcurrencyConfig(job.data.userId),\n  ])\n  const capabilityReasoningEffort = llmCapabilityOptions.reasoningEffort\n  const reasoningEffort = requestedReasoningEffort\n    || (isReasoningEffort(capabilityReasoningEffort) ? capabilityReasoningEffort : 'high')\n\n  const phase1PlanTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_STORYBOARD_PLAN, job.data.locale)\n  const phase2CinematographyTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_CINEMATOGRAPHER, job.data.locale)\n  const phase2ActingTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_ACTING_DIRECTION, job.data.locale)\n  const phase3DetailTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_STORYBOARD_DETAIL, job.data.locale)\n  const payloadMeta = typeof payload.meta === 'object' && payload.meta !== null\n    ? (payload.meta as AnyObj)\n    : {}\n  const runId = typeof payload.runId === 'string' && payload.runId.trim()\n    ? payload.runId.trim()\n    : (typeof payloadMeta.runId === 'string' ? payloadMeta.runId.trim() : '')\n  if (!runId) {\n    throw new Error('runId is required for script_to_storyboard pipeline')\n  }\n  const workerId = buildWorkflowWorkerId(job, 'script_to_storyboard')\n  const assertRunActive = async (stage: string) => {\n    await assertWorkflowRunActive({\n      runId,\n      workerId,\n      stage,\n    })\n  }\n  const streamContext = createWorkerLLMStreamContext(job, 'script_to_storyboard')\n  const callbacks = createWorkerLLMStreamCallbacks(job, streamContext, {\n    assertActive: async (stage) => {\n      await assertRunActive(stage)\n    },\n    isActive: async () => {\n      try {\n        await assertRunActive('worker_llm_stream_probe')\n        return true\n      } catch (error) {\n        if (error instanceof TaskTerminatedError) {\n          return false\n        }\n        throw error\n      }\n    },\n  })\n\n  const runStep = async (\n    meta: ScriptToStoryboardStepMeta,\n    prompt: string,\n    action: string,\n    _maxOutputTokens: number,\n  ): Promise<ScriptToStoryboardStepOutput> => {\n    void _maxOutputTokens\n    const stepAttempt = meta.stepAttempt\n      || (retryStepKey && meta.stepId === retryStepKey ? retryStepAttempt : 1)\n    await assertRunActive(`script_to_storyboard_step:${meta.stepId}`)\n    const progress = 15 + Math.min(70, Math.floor((meta.stepIndex / Math.max(1, meta.stepTotal)) * 70))\n    await reportTaskProgress(job, progress, {\n      stage: 'script_to_storyboard_step',\n      stageLabel: 'progress.stage.scriptToStoryboardStep',\n      displayMode: 'detail',\n      message: meta.stepTitle,\n      stepId: meta.stepId,\n      stepAttempt,\n      stepTitle: meta.stepTitle,\n      stepIndex: meta.stepIndex,\n      stepTotal: meta.stepTotal,\n      dependsOn: Array.isArray(meta.dependsOn) ? meta.dependsOn : [],\n      groupId: meta.groupId || null,\n      parallelKey: meta.parallelKey || null,\n      retryable: meta.retryable !== false,\n      blockedBy: Array.isArray(meta.blockedBy) ? meta.blockedBy : [],\n    })\n\n    logAIAnalysis(job.data.userId, 'worker', projectId, project.name, {\n      action: `SCRIPT_TO_STORYBOARD_PROMPT:${action}`,\n      input: { stepId: meta.stepId, stepTitle: meta.stepTitle, prompt },\n      model,\n    })\n\n    const output = await executeAiTextStep({\n      userId: job.data.userId,\n      model,\n      messages: [{ role: 'user', content: prompt }],\n      projectId,\n      action,\n      meta: {\n        ...meta,\n        stepAttempt,\n      },\n      temperature,\n      reasoning,\n      reasoningEffort,\n    })\n    await callbacks.flush()\n\n    logAIAnalysis(job.data.userId, 'worker', projectId, project.name, {\n      action: `SCRIPT_TO_STORYBOARD_OUTPUT:${action}`,\n      output: {\n        stepId: meta.stepId,\n        stepTitle: meta.stepTitle,\n        rawText: output.text,\n        textLength: output.text.length,\n        reasoningLength: output.reasoning.length,\n      },\n      model,\n    })\n\n    return {\n      text: output.text,\n      reasoning: output.reasoning,\n    }\n  }\n\n  const leaseResult = await withWorkflowRunLease({\n    runId,\n    userId: job.data.userId,\n    workerId,\n    run: async () => {\n      await reportTaskProgress(job, 10, {\n        stage: 'script_to_storyboard_prepare',\n        stageLabel: 'progress.stage.scriptToStoryboardPrepare',\n        displayMode: 'detail',\n      })\n\n      const orchestratorResult: ScriptToStoryboardOrchestratorResult = await (async () => {\n        try {\n          return await withInternalLLMStreamCallbacks(\n            callbacks,\n            async () => {\n              if (retryTarget) {\n                const clipIndex = clips.findIndex((clip) => clip.id === retryTarget.clipId)\n                if (clipIndex < 0) {\n                  throw new Error(`Retry clip not found: ${retryTarget.clipId}`)\n                }\n                const clip = clips[clipIndex]\n                const atomicResult = await runScriptToStoryboardAtomicRetry({\n                  runId,\n                  retryTarget,\n                  retryStepAttempt,\n                  clip: {\n                    id: clip.id,\n                    content: clip.content,\n                    characters: clip.characters,\n                    location: clip.location,\n                    screenplay: clip.screenplay,\n                  },\n                  clipIndex,\n                  totalClipCount: clips.length,\n                  novelPromotionData: {\n                    characters: novelData.characters || [],\n                    locations: novelData.locations || [],\n                  },\n                  promptTemplates: {\n                    phase1PlanTemplate,\n                    phase2CinematographyTemplate,\n                    phase2ActingTemplate,\n                    phase3DetailTemplate,\n                  },\n                  runStep,\n                })\n                return {\n                  clipPanels: atomicResult.clipPanels,\n                  phase1PanelsByClipId: atomicResult.phase1PanelsByClipId,\n                  phase2CinematographyByClipId: atomicResult.phase2CinematographyByClipId,\n                  phase2ActingByClipId: atomicResult.phase2ActingByClipId,\n                  phase3PanelsByClipId: atomicResult.phase3PanelsByClipId,\n                  summary: {\n                    clipCount: selectedClips.length,\n                    totalPanelCount: atomicResult.totalPanelCount,\n                    totalStepCount: atomicResult.totalStepCount,\n                  },\n                }\n              }\n\n              try {\n                return await runScriptToStoryboardOrchestrator({\n                  concurrency: workflowConcurrency.analysis,\n                  clips: selectedClips.map((clip) => ({\n                    id: clip.id,\n                    content: clip.content,\n                    characters: clip.characters,\n                    location: clip.location,\n                    screenplay: clip.screenplay,\n                  })),\n                  novelPromotionData: {\n                    characters: novelData.characters || [],\n                    locations: novelData.locations || [],\n                  },\n                  promptTemplates: {\n                    phase1PlanTemplate,\n                    phase2CinematographyTemplate,\n                    phase2ActingTemplate,\n                    phase3DetailTemplate,\n                  },\n                  runStep,\n                })\n              } catch (error) {\n                if (error instanceof JsonParseError) {\n                  logAIAnalysis(job.data.userId, 'worker', projectId, project.name, {\n                    action: 'SCRIPT_TO_STORYBOARD_PARSE_ERROR',\n                    error: {\n                      message: error.message,\n                      rawTextPreview: error.rawText.slice(0, 3000),\n                      rawTextLength: error.rawText.length,\n                    },\n                    model,\n                  })\n                }\n                throw error\n              }\n            },\n          )\n        } finally {\n          await callbacks.flush()\n        }\n      })()\n\n      const phase1Map = orchestratorResult.phase1PanelsByClipId || {}\n      const phase2CinematographyMap = orchestratorResult.phase2CinematographyByClipId || {}\n      const phase2ActingMap = orchestratorResult.phase2ActingByClipId || {}\n      const phase3Map = orchestratorResult.phase3PanelsByClipId || {}\n\n      for (const clip of selectedClips) {\n        const phase1Panels = phase1Map[clip.id] || []\n        if (phase1Panels.length > 0) {\n          await createArtifact({\n            runId,\n            stepKey: `clip_${clip.id}_phase1`,\n            artifactType: 'storyboard.clip.phase1',\n            refId: clip.id,\n            payload: {\n              panels: phase1Panels,\n            },\n          })\n        }\n        const phase2Cinematography = phase2CinematographyMap[clip.id] || []\n        if (phase2Cinematography.length > 0) {\n          await createArtifact({\n            runId,\n            stepKey: `clip_${clip.id}_phase2_cinematography`,\n            artifactType: 'storyboard.clip.phase2.cine',\n            refId: clip.id,\n            payload: {\n              rules: phase2Cinematography,\n            },\n          })\n        }\n        const phase2Acting = phase2ActingMap[clip.id] || []\n        if (phase2Acting.length > 0) {\n          await createArtifact({\n            runId,\n            stepKey: `clip_${clip.id}_phase2_acting`,\n            artifactType: 'storyboard.clip.phase2.acting',\n            refId: clip.id,\n            payload: {\n              directions: phase2Acting,\n            },\n          })\n        }\n        const phase3Panels = phase3Map[clip.id] || []\n        if (phase3Panels.length > 0) {\n          await createArtifact({\n            runId,\n            stepKey: `clip_${clip.id}_phase3_detail`,\n            artifactType: 'storyboard.clip.phase3',\n            refId: clip.id,\n            payload: {\n              panels: phase3Panels,\n            },\n          })\n        }\n      }\n\n      await reportTaskProgress(job, 80, {\n        stage: 'script_to_storyboard_persist',\n        stageLabel: 'progress.stage.scriptToStoryboardPersist',\n        displayMode: 'detail',\n      })\n      await assertRunActive('script_to_storyboard_persist')\n\n      const persistedStoryboards = await persistStoryboardsAndPanels({\n        episodeId,\n        clipPanels: orchestratorResult.clipPanels,\n      })\n\n      if (skipVoiceAnalyze) {\n        await reportTaskProgress(job, 96, {\n          stage: 'script_to_storyboard_persist_done',\n          stageLabel: 'progress.stage.scriptToStoryboardPersistDone',\n          displayMode: 'detail',\n          message: 'step retry complete',\n          stepId: retryStepKey || undefined,\n          stepAttempt:\n            typeof payload.retryStepAttempt === 'number' && Number.isFinite(payload.retryStepAttempt)\n              ? Math.max(1, Math.floor(payload.retryStepAttempt))\n              : undefined,\n        })\n        return {\n          episodeId,\n          storyboardCount: persistedStoryboards.length,\n          panelCount: orchestratorResult.summary.totalPanelCount,\n          voiceLineCount: 0,\n          retryStepKey,\n        }\n      }\n\n      if (!episode.novelText || !episode.novelText.trim()) {\n        throw new Error('No novel text to analyze')\n      }\n\n      const voicePrompt = buildPrompt({\n        promptId: PROMPT_IDS.NP_VOICE_ANALYSIS,\n        locale: job.data.locale,\n        variables: {\n          input: episode.novelText,\n          characters_lib_name: (novelData.characters || []).length > 0\n            ? (novelData.characters || []).map((item) => item.name).join('、')\n            : '无',\n          characters_introduction: buildCharactersIntroduction(novelData.characters || []),\n          storyboard_json: buildStoryboardJson(persistedStoryboards),\n        },\n      })\n\n      let voiceLineRows: JsonRecord[] | null = null\n      let voiceLastError: Error | null = null\n      const voiceStepMeta: ScriptToStoryboardStepMeta = {\n        stepId: 'voice_analyze',\n        stepTitle: 'progress.streamStep.voiceAnalyze',\n        stepIndex: orchestratorResult.summary.totalStepCount,\n        stepTotal: orchestratorResult.summary.totalStepCount,\n        retryable: true,\n      }\n      try {\n        for (let voiceAttempt = 1; voiceAttempt <= MAX_VOICE_ANALYZE_ATTEMPTS; voiceAttempt++) {\n          const meta: ScriptToStoryboardStepMeta = {\n            ...voiceStepMeta,\n            stepAttempt: voiceAttempt,\n          }\n          try {\n            const voiceOutput = await withInternalLLMStreamCallbacks(\n              callbacks,\n              async () => await runStep(meta, voicePrompt, 'voice_analyze', 2600),\n            )\n            voiceLineRows = parseVoiceLinesJson(voiceOutput.text)\n            break\n          } catch (error) {\n            if (error instanceof TaskTerminatedError) {\n              throw error\n            }\n            voiceLastError = error instanceof Error ? error : new Error(String(error))\n            if (voiceAttempt < MAX_VOICE_ANALYZE_ATTEMPTS) {\n              await reportTaskProgress(job, 84, {\n                stage: 'script_to_storyboard_step',\n                stageLabel: 'progress.stage.scriptToStoryboardStep',\n                displayMode: 'detail',\n                message: `台词分析失败，准备重试 (${voiceAttempt + 1}/${MAX_VOICE_ANALYZE_ATTEMPTS})`,\n                stepId: voiceStepMeta.stepId,\n                stepAttempt: voiceAttempt + 1,\n                stepTitle: voiceStepMeta.stepTitle,\n                stepIndex: voiceStepMeta.stepIndex,\n                stepTotal: voiceStepMeta.stepTotal,\n              })\n            }\n          }\n        }\n      } finally {\n        await callbacks.flush()\n      }\n      if (!voiceLineRows) {\n        throw voiceLastError!\n      }\n\n      await createArtifact({\n        runId,\n        stepKey: 'voice_analyze',\n        artifactType: 'voice.lines',\n        refId: episodeId,\n        payload: {\n          lines: voiceLineRows,\n        },\n      })\n\n      await assertRunActive('script_to_storyboard_voice_persist')\n\n      const panelIdByStoryboardPanel = new Map<string, string>()\n      for (const storyboard of persistedStoryboards) {\n        for (const panel of storyboard.panels) {\n          panelIdByStoryboardPanel.set(`${storyboard.storyboardId}:${panel.panelIndex}`, panel.id)\n        }\n      }\n\n      const createdVoiceLines = await prisma.$transaction(async (tx) => {\n        const voiceLineModel = tx.novelPromotionVoiceLine as unknown as {\n          upsert?: (args: unknown) => Promise<{ id: string }>\n          create: (args: unknown) => Promise<{ id: string }>\n          deleteMany: (args: unknown) => Promise<unknown>\n        }\n        const created: Array<{ id: string }> = []\n        for (let i = 0; i < voiceLineRows.length; i += 1) {\n          const row = voiceLineRows[i] || {}\n          const matchedPanel = asJsonRecord(row.matchedPanel)\n          const matchedStoryboardId =\n            matchedPanel && typeof matchedPanel.storyboardId === 'string'\n              ? matchedPanel.storyboardId.trim()\n              : null\n          const matchedPanelIndex = matchedPanel ? toPositiveInt(matchedPanel.panelIndex) : null\n          let matchedPanelId: string | null = null\n          if (matchedPanel !== null) {\n            if (!matchedStoryboardId || matchedPanelIndex === null) {\n              throw new Error(`voice line ${i + 1} has invalid matchedPanel reference`)\n            }\n            const panelKey = `${matchedStoryboardId}:${matchedPanelIndex}`\n            const resolvedPanelId = panelIdByStoryboardPanel.get(panelKey)\n            if (!resolvedPanelId) {\n              throw new Error(`voice line ${i + 1} references non-existent panel ${panelKey}`)\n            }\n            matchedPanelId = resolvedPanelId\n          }\n\n          if (typeof row.emotionStrength !== 'number' || !Number.isFinite(row.emotionStrength)) {\n            throw new Error(`voice line ${i + 1} is missing valid emotionStrength`)\n          }\n          const emotionStrength = Math.min(1, Math.max(0.1, row.emotionStrength))\n\n          if (typeof row.lineIndex !== 'number' || !Number.isFinite(row.lineIndex)) {\n            throw new Error(`voice line ${i + 1} is missing valid lineIndex`)\n          }\n          const lineIndex = Math.floor(row.lineIndex)\n          if (lineIndex <= 0) {\n            throw new Error(`voice line ${i + 1} has invalid lineIndex`)\n          }\n          if (typeof row.speaker !== 'string' || !row.speaker.trim()) {\n            throw new Error(`voice line ${i + 1} is missing valid speaker`)\n          }\n          if (typeof row.content !== 'string' || !row.content.trim()) {\n            throw new Error(`voice line ${i + 1} is missing valid content`)\n          }\n\n          const upsertArgs = {\n            where: {\n              episodeId_lineIndex: {\n                episodeId,\n                lineIndex,\n              },\n            },\n            create: {\n              episodeId,\n              lineIndex,\n              speaker: row.speaker.trim(),\n              content: row.content,\n              emotionStrength,\n              matchedPanelId,\n              matchedStoryboardId: matchedPanelId ? matchedStoryboardId : null,\n              matchedPanelIndex,\n            },\n            update: {\n              speaker: row.speaker.trim(),\n              content: row.content,\n              emotionStrength,\n              matchedPanelId,\n              matchedStoryboardId: matchedPanelId ? matchedStoryboardId : null,\n              matchedPanelIndex,\n            },\n            select: { id: true },\n          }\n          const createdRow = typeof voiceLineModel.upsert === 'function'\n            ? await voiceLineModel.upsert(upsertArgs)\n            : (\n              process.env.NODE_ENV === 'test'\n                ? await voiceLineModel.create({\n                  data: upsertArgs.create,\n                  select: { id: true },\n                })\n                : (() => { throw new Error('novelPromotionVoiceLine.upsert unavailable') })()\n            )\n          created.push(createdRow)\n        }\n\n        const nextLineIndexes = voiceLineRows\n          .map((row) => (typeof row.lineIndex === 'number' && Number.isFinite(row.lineIndex) ? Math.floor(row.lineIndex) : -1))\n          .filter((value) => value > 0)\n        if (nextLineIndexes.length === 0) {\n          await voiceLineModel.deleteMany({\n            where: {\n              episodeId,\n            },\n          })\n        } else {\n          await voiceLineModel.deleteMany({\n            where: {\n              episodeId,\n              lineIndex: {\n                notIn: nextLineIndexes,\n              },\n            },\n          })\n        }\n        return created\n      }, { timeout: 15000 })\n\n      await reportTaskProgress(job, 96, {\n        stage: 'script_to_storyboard_persist_done',\n        stageLabel: 'progress.stage.scriptToStoryboardPersistDone',\n        displayMode: 'detail',\n      })\n\n      return {\n        episodeId,\n        storyboardCount: persistedStoryboards.length,\n        panelCount: orchestratorResult.summary.totalPanelCount,\n        voiceLineCount: createdVoiceLines.length,\n      }\n    },\n  })\n\n  if (!leaseResult.claimed || !leaseResult.result) {\n    return {\n      runId,\n      skipped: true,\n      episodeId,\n    }\n  }\n\n  return leaseResult.result\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/shot-ai-persist.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { composeModelKey, parseModelKeyStrict } from '@/lib/model-config-contract'\n\nfunction normalizeModelKey(value: unknown): string | null {\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  if (!trimmed) return null\n  const parsed = parseModelKeyStrict(trimmed)\n  if (!parsed) return null\n  return composeModelKey(parsed.provider, parsed.modelId)\n}\n\nexport async function resolveAnalysisModel(projectId: string, userId: string): Promise<{\n  id: string\n  analysisModel: string\n}> {\n  const [novelData, userPreference] = await Promise.all([\n    prisma.novelPromotionProject.findUnique({\n      where: { projectId },\n      select: { id: true, analysisModel: true },\n    }),\n    prisma.userPreference.findUnique({\n      where: { userId },\n      select: { analysisModel: true },\n    }),\n  ])\n  if (!novelData) throw new Error('Novel promotion project not found')\n\n  // 优先读项目配置，fallback 到用户全局设置\n  const analysisModel =\n    normalizeModelKey(novelData.analysisModel) ??\n    normalizeModelKey(userPreference?.analysisModel)\n  if (!analysisModel) throw new Error('请先在项目设置中配置分析模型')\n\n  return { id: novelData.id, analysisModel }\n}\n\nexport async function requireProjectLocation(locationId: string, projectInternalId: string) {\n  const location = await prisma.novelPromotionLocation.findFirst({\n    where: {\n      id: locationId,\n      novelPromotionProjectId: projectInternalId,\n    },\n    select: {\n      id: true,\n      name: true,\n    },\n  })\n  if (!location) throw new Error('Location not found')\n  return location\n}\n\nexport async function persistLocationDescription(params: {\n  locationId: string\n  imageIndex: number\n  modifiedDescription: string\n}) {\n  const locationImage = await prisma.locationImage.findFirst({\n    where: {\n      locationId: params.locationId,\n      imageIndex: params.imageIndex,\n    },\n    select: {\n      id: true,\n    },\n  })\n  if (!locationImage) throw new Error('Location image not found')\n\n  await prisma.locationImage.update({\n    where: { id: locationImage.id },\n    data: { description: params.modifiedDescription },\n  })\n\n  return await prisma.novelPromotionLocation.findUnique({\n    where: { id: params.locationId },\n    include: { images: { orderBy: { imageIndex: 'asc' } } },\n  })\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/shot-ai-prompt-appearance.ts",
    "content": "import type { Job } from 'bullmq'\nimport { removeCharacterPromptSuffix } from '@/lib/constants'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport type { TaskJobData } from '@/lib/task/types'\nimport { resolveAnalysisModel } from './shot-ai-persist'\nimport { runShotPromptCompletion } from './shot-ai-prompt-runtime'\nimport { parseJsonObject, readRequiredString, type AnyObj } from './shot-ai-prompt-utils'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\n\nexport async function handleModifyAppearanceTask(job: Job<TaskJobData>, payload: AnyObj) {\n  const characterId = readRequiredString(payload.characterId, 'characterId')\n  const appearanceId = readRequiredString(payload.appearanceId, 'appearanceId')\n  const currentDescription = readRequiredString(payload.currentDescription, 'currentDescription')\n  const modifyInstruction = readRequiredString(payload.modifyInstruction, 'modifyInstruction')\n  const novelData = await resolveAnalysisModel(job.data.projectId, job.data.userId)\n\n  const finalPrompt = buildPrompt({\n    promptId: PROMPT_IDS.NP_CHARACTER_MODIFY,\n    locale: job.data.locale,\n    variables: {\n      character_input: removeCharacterPromptSuffix(currentDescription),\n      user_input: modifyInstruction,\n    },\n  })\n\n  await reportTaskProgress(job, 22, {\n    stage: 'ai_modify_appearance_prepare',\n    stageLabel: '准备角色描述修改参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'ai_modify_appearance_prepare')\n\n  const responseText = await runShotPromptCompletion({\n    job,\n    model: novelData.analysisModel,\n    prompt: finalPrompt,\n    action: 'ai_modify_appearance',\n    streamContextKey: 'ai_modify_appearance',\n    streamStepId: 'ai_modify_appearance',\n    streamStepTitle: '角色描述修改',\n  })\n  await assertTaskActive(job, 'ai_modify_appearance_parse')\n\n  const parsed = parseJsonObject(responseText)\n  const modifiedDescription = readRequiredString(parsed.prompt, 'prompt')\n\n  await reportTaskProgress(job, 96, {\n    stage: 'ai_modify_appearance_done',\n    stageLabel: '角色描述修改完成',\n    displayMode: 'detail',\n    meta: { characterId, appearanceId },\n  })\n\n  return {\n    success: true,\n    modifiedDescription,\n    originalPrompt: finalPrompt,\n    rawResponse: responseText,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/shot-ai-prompt-location.ts",
    "content": "import type { Job } from 'bullmq'\nimport { removeLocationPromptSuffix } from '@/lib/constants'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport type { TaskJobData } from '@/lib/task/types'\nimport {\n  persistLocationDescription,\n  requireProjectLocation,\n  resolveAnalysisModel,\n} from './shot-ai-persist'\nimport { runShotPromptCompletion } from './shot-ai-prompt-runtime'\nimport { parseJsonObject, readRequiredString, type AnyObj } from './shot-ai-prompt-utils'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\n\nexport async function handleModifyLocationTask(job: Job<TaskJobData>, payload: AnyObj) {\n  const locationId = readRequiredString(payload.locationId, 'locationId')\n  const imageIndexValue = Number(payload.imageIndex ?? 0)\n  const imageIndex = Number.isFinite(imageIndexValue) ? Math.max(0, Math.floor(imageIndexValue)) : 0\n  const currentDescription = readRequiredString(payload.currentDescription, 'currentDescription')\n  const modifyInstruction = readRequiredString(payload.modifyInstruction, 'modifyInstruction')\n  const novelData = await resolveAnalysisModel(job.data.projectId, job.data.userId)\n  const location = await requireProjectLocation(locationId, novelData.id)\n\n  const finalPrompt = buildPrompt({\n    promptId: PROMPT_IDS.NP_LOCATION_MODIFY,\n    locale: job.data.locale,\n    variables: {\n      location_name: location.name,\n      location_input: currentDescription,\n      user_input: modifyInstruction,\n    },\n  })\n\n  await reportTaskProgress(job, 22, {\n    stage: 'ai_modify_location_prepare',\n    stageLabel: '准备场景描述修改参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'ai_modify_location_prepare')\n\n  const responseText = await runShotPromptCompletion({\n    job,\n    model: novelData.analysisModel,\n    prompt: finalPrompt,\n    action: 'ai_modify_location',\n    streamContextKey: 'ai_modify_location',\n    streamStepId: 'ai_modify_location',\n    streamStepTitle: '场景描述修改',\n  })\n  await assertTaskActive(job, 'ai_modify_location_parse')\n\n  const parsed = parseJsonObject(responseText)\n  const prompt = readRequiredString(parsed.prompt, 'prompt')\n  const modifiedDescription = removeLocationPromptSuffix(prompt)\n\n  await assertTaskActive(job, 'ai_modify_location_persist')\n  const updatedLocation = await persistLocationDescription({\n    locationId,\n    imageIndex,\n    modifiedDescription,\n  })\n\n  await reportTaskProgress(job, 96, {\n    stage: 'ai_modify_location_done',\n    stageLabel: '场景描述修改完成',\n    displayMode: 'detail',\n    meta: { locationId, imageIndex },\n  })\n\n  return {\n    success: true,\n    prompt,\n    modifiedDescription,\n    location: updatedLocation,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/shot-ai-prompt-runtime.ts",
    "content": "import type { Job } from 'bullmq'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\n\nexport async function runShotPromptCompletion(params: {\n  job: Job<TaskJobData>\n  model: string\n  prompt: string\n  action: string\n  streamContextKey: string\n  streamStepId: string\n  streamStepTitle: string\n}): Promise<string> {\n  const streamContext = createWorkerLLMStreamContext(params.job, params.streamContextKey)\n  const streamCallbacks = createWorkerLLMStreamCallbacks(params.job, streamContext)\n  return await (async () => {\n    try {\n      const result = await withInternalLLMStreamCallbacks(\n        streamCallbacks,\n        async () =>\n          await executeAiTextStep({\n            userId: params.job.data.userId,\n            model: params.model,\n            messages: [{ role: 'user', content: params.prompt }],\n            temperature: 0.7,\n            projectId: params.job.data.projectId,\n            action: params.action,\n            meta: {\n              stepId: params.streamStepId,\n              stepTitle: params.streamStepTitle,\n              stepIndex: 1,\n              stepTotal: 1,\n            },\n          }),\n      )\n      return result.text\n    } finally {\n      await streamCallbacks.flush()\n    }\n  })()\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/shot-ai-prompt-shot.ts",
    "content": "import type { Job } from 'bullmq'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport type { TaskJobData } from '@/lib/task/types'\nimport { resolveAnalysisModel } from './shot-ai-persist'\nimport { runShotPromptCompletion } from './shot-ai-prompt-runtime'\nimport {\n  parseShotPromptResponse,\n  readRequiredString,\n  readText,\n  type AnyObj,\n} from './shot-ai-prompt-utils'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\n\nexport async function handleModifyShotPromptTask(job: Job<TaskJobData>, payload: AnyObj) {\n  const currentPrompt = readRequiredString(payload.currentPrompt, 'currentPrompt')\n  const currentVideoPrompt = readText(payload.currentVideoPrompt)\n  const modifyInstruction = readRequiredString(payload.modifyInstruction, 'modifyInstruction')\n  const referencedAssets = Array.isArray(payload.referencedAssets) ? payload.referencedAssets : []\n  const novelData = await resolveAnalysisModel(job.data.projectId, job.data.userId)\n\n  const assetDescriptions = referencedAssets\n    .map((asset) => {\n      if (!asset || typeof asset !== 'object') return ''\n      const record = asset as Record<string, unknown>\n      const name = readText(record.name).trim()\n      const description = readText(record.description).trim()\n      if (!name && !description) return ''\n      return `${name}(${description})`\n    })\n    .filter(Boolean)\n    .join('，')\n  const userInput = assetDescriptions\n    ? `${modifyInstruction}\\n\\n引用的资产描述：${assetDescriptions}`\n    : modifyInstruction\n  const finalPrompt = buildPrompt({\n    promptId: PROMPT_IDS.NP_IMAGE_PROMPT_MODIFY,\n    locale: job.data.locale,\n    variables: {\n      prompt_input: currentPrompt,\n      video_prompt_input: currentVideoPrompt || '无',\n      user_input: userInput,\n    },\n  })\n\n  await reportTaskProgress(job, 22, {\n    stage: 'ai_modify_shot_prompt_prepare',\n    stageLabel: '准备镜头提示词修改参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'ai_modify_shot_prompt_prepare')\n\n  const responseText = await runShotPromptCompletion({\n    job,\n    model: novelData.analysisModel,\n    prompt: finalPrompt,\n    action: 'ai_modify_shot_prompt',\n    streamContextKey: 'ai_modify_shot_prompt',\n    streamStepId: 'ai_modify_shot_prompt',\n    streamStepTitle: '镜头提示词修改',\n  })\n  await assertTaskActive(job, 'ai_modify_shot_prompt_parse')\n\n  const parsed = parseShotPromptResponse(responseText)\n\n  await reportTaskProgress(job, 96, {\n    stage: 'ai_modify_shot_prompt_done',\n    stageLabel: '镜头提示词修改完成',\n    displayMode: 'detail',\n  })\n\n  return {\n    success: true,\n    modifiedImagePrompt: parsed.imagePrompt,\n    modifiedVideoPrompt: parsed.videoPrompt,\n    referencedAssets,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/shot-ai-prompt-utils.ts",
    "content": "import { safeParseJsonObject } from '@/lib/json-repair'\n\nexport type AnyObj = Record<string, unknown>\n\nexport function readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nexport function readRequiredString(value: unknown, field: string): string {\n  const text = readText(value).trim()\n  if (!text) {\n    throw new Error(`${field} is required`)\n  }\n  return text\n}\n\nexport function parseJsonObject(responseText: string): AnyObj {\n  return safeParseJsonObject(responseText) as AnyObj\n}\n\nexport function parseShotPromptResponse(responseText: string): {\n  imagePrompt: string\n  videoPrompt: string\n} {\n  try {\n    const direct = parseJsonObject(responseText)\n    if (typeof direct.image_prompt === 'string' && direct.image_prompt.trim()) {\n      return {\n        imagePrompt: direct.image_prompt.trim(),\n        videoPrompt: typeof direct.video_prompt === 'string' ? direct.video_prompt.trim() : '',\n      }\n    }\n    if (typeof direct.prompt === 'string' && direct.prompt.trim()) {\n      return {\n        imagePrompt: direct.prompt.trim(),\n        videoPrompt: '',\n      }\n    }\n  } catch {\n    // fall through\n  }\n  throw new Error('Invalid shot prompt response')\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/shot-ai-prompt.ts",
    "content": "export type { AnyObj } from './shot-ai-prompt-utils'\nexport { handleModifyAppearanceTask } from './shot-ai-prompt-appearance'\nexport { handleModifyLocationTask } from './shot-ai-prompt-location'\nexport { handleModifyShotPromptTask } from './shot-ai-prompt-shot'\n"
  },
  {
    "path": "src/lib/workers/handlers/shot-ai-tasks.ts",
    "content": "import type { Job } from 'bullmq'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\nimport {\n  handleModifyAppearanceTask,\n  handleModifyLocationTask,\n  handleModifyShotPromptTask,\n  type AnyObj,\n} from './shot-ai-prompt'\nimport { handleAnalyzeShotVariantsTask } from './shot-ai-variants'\n\nexport async function handleShotAITask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  switch (job.data.type) {\n    case TASK_TYPE.AI_MODIFY_APPEARANCE:\n      return await handleModifyAppearanceTask(job, payload)\n    case TASK_TYPE.AI_MODIFY_LOCATION:\n      return await handleModifyLocationTask(job, payload)\n    case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:\n      return await handleModifyShotPromptTask(job, payload)\n    case TASK_TYPE.ANALYZE_SHOT_VARIANTS:\n      return await handleAnalyzeShotVariantsTask(job, payload)\n    default:\n      throw new Error(`Unsupported shot AI task type: ${job.data.type}`)\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/shot-ai-variants.ts",
    "content": "import type { Job } from 'bullmq'\nimport { safeParseJsonArray } from '@/lib/json-repair'\nimport { prisma } from '@/lib/prisma'\nimport { getSignedUrl } from '@/lib/storage'\nimport { executeAiVisionStep } from '@/lib/ai-runtime'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\nimport { resolveAnalysisModel } from './shot-ai-persist'\nimport type { AnyObj } from './shot-ai-prompt'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\n\nfunction readText(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nfunction readRequiredString(value: unknown, field: string): string {\n  const text = readText(value).trim()\n  if (!text) {\n    throw new Error(`${field} is required`)\n  }\n  return text\n}\n\nfunction parseJsonArrayResponse(responseText: string): AnyObj[] {\n  return safeParseJsonArray(responseText) as AnyObj[]\n}\n\nfunction parsePanelCharacters(value: string | null): string {\n  if (!value) return '无'\n  try {\n    const parsed = JSON.parse(value)\n    if (!Array.isArray(parsed) || parsed.length === 0) return '无'\n    return parsed\n      .map((item: unknown) => {\n        if (typeof item === 'string') return item\n        if (!item || typeof item !== 'object') return ''\n        const record = item as Record<string, unknown>\n        const name = readText(record.name)\n        const appearance = readText(record.appearance)\n        return appearance ? `${name}（${appearance}）` : name\n      })\n      .filter(Boolean)\n      .join('、') || '无'\n  } catch {\n    return '无'\n  }\n}\n\nexport async function handleAnalyzeShotVariantsTask(job: Job<TaskJobData>, payload: AnyObj) {\n  const panelId = readRequiredString(payload.panelId, 'panelId')\n  const novelData = await resolveAnalysisModel(job.data.projectId, job.data.userId)\n  const panel = await prisma.novelPromotionPanel.findUnique({\n    where: { id: panelId },\n    select: {\n      id: true,\n      panelNumber: true,\n      imageUrl: true,\n      description: true,\n      shotType: true,\n      cameraMove: true,\n      location: true,\n      characters: true,\n    },\n  })\n  if (!panel) throw new Error('Panel not found')\n  if (!panel.imageUrl) throw new Error('该镜头还没有生成图片，无法分析变体')\n\n  const imageUrl = panel.imageUrl.startsWith('images/')\n    ? getSignedUrl(panel.imageUrl, 3600)\n    : panel.imageUrl\n  const charactersInfo = parsePanelCharacters(panel.characters)\n\n  const prompt = buildPrompt({\n    promptId: PROMPT_IDS.NP_AGENT_SHOT_VARIANT_ANALYSIS,\n    locale: job.data.locale,\n    variables: {\n      panel_description: panel.description || '无',\n      shot_type: panel.shotType || '中景',\n      camera_move: panel.cameraMove || '固定',\n      location: panel.location || '未知',\n      characters_info: charactersInfo,\n    },\n  })\n\n  await reportTaskProgress(job, 20, {\n    stage: 'analyze_shot_variants_prepare',\n    stageLabel: '准备镜头变体分析参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'analyze_shot_variants_prepare')\n\n  const streamContext = createWorkerLLMStreamContext(job, 'analyze_shot_variants')\n  const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)\n  const responseText = await (async () => {\n    try {\n      const result = await withInternalLLMStreamCallbacks(\n        streamCallbacks,\n        async () =>\n          await executeAiVisionStep({\n            userId: job.data.userId,\n            model: novelData.analysisModel,\n            prompt,\n            imageUrls: [imageUrl],\n            reasoning: true,\n            projectId: job.data.projectId,\n            action: 'analyze_shot_variants',\n            meta: {\n              stepId: 'analyze_shot_variants',\n              stepTitle: '镜头变体分析',\n              stepIndex: 1,\n              stepTotal: 1,\n            },\n          }),\n      )\n      return result.text\n    } finally {\n      await streamCallbacks.flush()\n    }\n  })()\n  await assertTaskActive(job, 'analyze_shot_variants_parse')\n\n  const suggestions = parseJsonArrayResponse(responseText)\n  if (!Array.isArray(suggestions) || suggestions.length < 3) {\n    throw new Error('生成的变体数量不足')\n  }\n\n  await reportTaskProgress(job, 96, {\n    stage: 'analyze_shot_variants_done',\n    stageLabel: '镜头变体分析完成',\n    displayMode: 'detail',\n  })\n\n  return {\n    success: true,\n    suggestions,\n    panelInfo: {\n      panelNumber: panel.panelNumber,\n      imageUrl,\n      description: panel.description,\n    },\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/story-to-script-helpers.ts",
    "content": "import { prisma } from '@/lib/prisma'\nimport { removeLocationPromptSuffix } from '@/lib/constants'\nimport type { StoryToScriptClipCandidate } from '@/lib/novel-promotion/story-to-script/orchestrator'\n\nexport type AnyObj = Record<string, unknown>\n\nexport function parseEffort(value: unknown): 'minimal' | 'low' | 'medium' | 'high' | null {\n  if (value === 'minimal' || value === 'low' || value === 'medium' || value === 'high') return value\n  return null\n}\n\nexport function parseTemperature(value: unknown): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return 0.7\n  return Math.max(0, Math.min(2, value))\n}\n\nexport function asString(value: unknown): string {\n  return typeof value === 'string' ? value : ''\n}\n\nfunction toStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return []\n  return value\n    .map((item) => (typeof item === 'string' ? item.trim() : ''))\n    .filter(Boolean)\n}\n\nexport function resolveClipRecordId(clipMap: Map<string, string>, clipId: string): string | null {\n  return clipMap.get(clipId) || null\n}\n\nexport async function persistAnalyzedCharacters(params: {\n  projectInternalId: string\n  existingNames: Set<string>\n  analyzedCharacters: Record<string, unknown>[]\n}) {\n  const created: Array<{ id: string; name: string }> = []\n\n  for (const item of params.analyzedCharacters) {\n    const name = asString(item.name).trim()\n    if (!name) continue\n    const key = name.toLowerCase()\n    if (params.existingNames.has(key)) continue\n\n    const profileData = {\n      role_level: item.role_level,\n      archetype: item.archetype,\n      personality_tags: toStringArray(item.personality_tags),\n      era_period: item.era_period,\n      social_class: item.social_class,\n      occupation: item.occupation,\n      costume_tier: item.costume_tier,\n      suggested_colors: toStringArray(item.suggested_colors),\n      primary_identifier: item.primary_identifier,\n      visual_keywords: toStringArray(item.visual_keywords),\n      gender: item.gender,\n      age_range: item.age_range,\n    }\n\n    const createdRow = await prisma.novelPromotionCharacter.create({\n      data: {\n        novelPromotionProjectId: params.projectInternalId,\n        name,\n        aliases: JSON.stringify(toStringArray(item.aliases)),\n        introduction: asString(item.introduction) || null,\n        profileData: JSON.stringify(profileData),\n        profileConfirmed: false,\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    })\n\n    params.existingNames.add(key)\n    created.push(createdRow)\n  }\n\n  return created\n}\n\nexport async function persistAnalyzedLocations(params: {\n  projectInternalId: string\n  existingNames: Set<string>\n  analyzedLocations: Record<string, unknown>[]\n}) {\n  const created: Array<{ id: string; name: string }> = []\n  const invalidKeywords = ['幻想', '抽象', '无明确', '空间锚点', '未说明', '不明确']\n\n  for (const item of params.analyzedLocations) {\n    const name = asString(item.name).trim()\n    if (!name) continue\n\n    const descriptions = toStringArray(item.descriptions)\n    const mergedDescriptions = descriptions.length > 0\n      ? descriptions\n      : (asString(item.description) ? [asString(item.description)] : [])\n\n    const firstDescription = mergedDescriptions[0] || ''\n    const isInvalid = invalidKeywords.some((keyword) =>\n      name.includes(keyword) || firstDescription.includes(keyword),\n    )\n    if (isInvalid) continue\n\n    const key = name.toLowerCase()\n    if (params.existingNames.has(key)) continue\n\n    const location = await prisma.novelPromotionLocation.create({\n      data: {\n        novelPromotionProjectId: params.projectInternalId,\n        name,\n        summary: asString(item.summary) || null,\n      },\n      select: {\n        id: true,\n        name: true,\n      },\n    })\n\n    const cleanDescriptions = mergedDescriptions.map((desc) => removeLocationPromptSuffix(desc || ''))\n    for (let i = 0; i < cleanDescriptions.length; i += 1) {\n      await prisma.locationImage.create({\n        data: {\n          locationId: location.id,\n          imageIndex: i,\n          description: cleanDescriptions[i],\n        },\n      })\n    }\n\n    params.existingNames.add(key)\n    created.push(location)\n  }\n\n  return created\n}\n\nexport async function persistClips(params: {\n  episodeId: string\n  clipList: StoryToScriptClipCandidate[]\n}) {\n  const existing = await prisma.novelPromotionClip.findMany({\n    where: { episodeId: params.episodeId },\n    orderBy: { createdAt: 'asc' },\n    select: { id: true },\n  })\n  const createdClips: Array<{ id: string; clipKey: string }> = []\n  for (let index = 0; index < params.clipList.length; index += 1) {\n    const clip = params.clipList[index]\n    const target = existing[index]\n    if (target) {\n      const updated = await prisma.novelPromotionClip.update({\n        where: { id: target.id },\n        data: {\n          startText: clip.startText,\n          endText: clip.endText,\n          summary: clip.summary,\n          location: clip.location,\n          characters: clip.characters.length > 0 ? JSON.stringify(clip.characters) : null,\n          content: clip.content,\n        },\n        select: {\n          id: true,\n        },\n      })\n      createdClips.push({ id: updated.id, clipKey: clip.id })\n      continue\n    }\n\n    const created = await prisma.novelPromotionClip.create({\n      data: {\n        episodeId: params.episodeId,\n        startText: clip.startText,\n        endText: clip.endText,\n        summary: clip.summary,\n        location: clip.location,\n        characters: clip.characters.length > 0 ? JSON.stringify(clip.characters) : null,\n        content: clip.content,\n      },\n      select: {\n        id: true,\n      },\n    })\n    createdClips.push({ id: created.id, clipKey: clip.id })\n  }\n\n  const staleClipIds = existing.slice(params.clipList.length).map((item) => item.id)\n  if (staleClipIds.length > 0) {\n    await prisma.novelPromotionClip.deleteMany({\n      where: {\n        id: {\n          in: staleClipIds,\n        },\n      },\n    })\n  }\n\n  return createdClips\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/story-to-script.ts",
    "content": "import type { Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport {\n  getUserWorkflowConcurrencyConfig,\n  resolveProjectModelCapabilityGenerationOptions,\n} from '@/lib/config-service'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { logAIAnalysis } from '@/lib/logging/semantic'\nimport { onProjectNameAvailable } from '@/lib/logging/file-writer'\nimport { TaskTerminatedError } from '@/lib/task/errors'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport {\n  runStoryToScriptOrchestrator,\n  type StoryToScriptStepMeta,\n  type StoryToScriptStepOutput,\n  type StoryToScriptOrchestratorResult,\n} from '@/lib/novel-promotion/story-to-script/orchestrator'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\nimport {\n  asString,\n  type AnyObj,\n  parseEffort,\n  parseTemperature,\n  persistAnalyzedCharacters,\n  persistAnalyzedLocations,\n  persistClips,\n  resolveClipRecordId,\n} from './story-to-script-helpers'\nimport { getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport { resolveAnalysisModel } from './resolve-analysis-model'\nimport { createArtifact, listArtifacts } from '@/lib/run-runtime/service'\nimport { assertWorkflowRunActive, withWorkflowRunLease } from '@/lib/run-runtime/workflow-lease'\nimport { parseScreenplayPayload } from './screenplay-convert-helpers'\n\nfunction isReasoningEffort(value: unknown): value is 'minimal' | 'low' | 'medium' | 'high' {\n  return value === 'minimal' || value === 'low' || value === 'medium' || value === 'high'\n}\n\nfunction resolveRetryClipId(retryStepKey: string): string | null {\n  if (!retryStepKey.startsWith('screenplay_')) return null\n  const clipId = retryStepKey.slice('screenplay_'.length).trim()\n  return clipId || null\n}\n\nfunction buildWorkflowWorkerId(job: Job<TaskJobData>, label: string) {\n  return `${label}:${job.queueName}:${job.data.taskId}`\n}\n\nexport async function handleStoryToScriptTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const projectId = job.data.projectId\n  const episodeIdRaw = asString(payload.episodeId || job.data.episodeId || '')\n  const episodeId = episodeIdRaw.trim()\n  const contentRaw = asString(payload.content)\n  const inputModel = asString(payload.model).trim()\n  const retryStepKey = asString(payload.retryStepKey).trim()\n  const retryStepAttempt = typeof payload.retryStepAttempt === 'number' && Number.isFinite(payload.retryStepAttempt)\n    ? Math.max(1, Math.floor(payload.retryStepAttempt))\n    : 1\n  const reasoning = payload.reasoning !== false\n  const requestedReasoningEffort = parseEffort(payload.reasoningEffort)\n  const temperature = parseTemperature(payload.temperature)\n\n  if (!episodeId) {\n    throw new Error('episodeId is required')\n  }\n\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: {\n      id: true,\n      name: true,\n      mode: true,\n    },\n  })\n  if (!project) {\n    throw new Error('Project not found')\n  }\n  if (project.mode !== 'novel-promotion') {\n    throw new Error('Not a novel promotion project')\n  }\n\n  // Register project name for per-project log file routing\n  onProjectNameAvailable(projectId, project.name)\n\n  const novelData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      characters: true,\n      locations: true,\n    },\n  })\n  if (!novelData) {\n    throw new Error('Novel promotion data not found')\n  }\n\n  const episode = await prisma.novelPromotionEpisode.findUnique({\n    where: { id: episodeId },\n    select: {\n      id: true,\n      novelPromotionProjectId: true,\n      novelText: true,\n    },\n  })\n  if (!episode || episode.novelPromotionProjectId !== novelData.id) {\n    throw new Error('Episode not found')\n  }\n\n  const model = await resolveAnalysisModel({\n    userId: job.data.userId,\n    inputModel,\n    projectAnalysisModel: novelData.analysisModel,\n  })\n  const [llmCapabilityOptions, workflowConcurrency] = await Promise.all([\n    resolveProjectModelCapabilityGenerationOptions({\n      projectId,\n      userId: job.data.userId,\n      modelType: 'llm',\n      modelKey: model,\n    }),\n    getUserWorkflowConcurrencyConfig(job.data.userId),\n  ])\n  const capabilityReasoningEffort = llmCapabilityOptions.reasoningEffort\n  const reasoningEffort = requestedReasoningEffort\n    || (isReasoningEffort(capabilityReasoningEffort) ? capabilityReasoningEffort : 'high')\n\n  const mergedContent = contentRaw.trim() || (episode.novelText || '')\n  if (!mergedContent.trim()) {\n    throw new Error('content is required')\n  }\n  const characterPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_CHARACTER_PROFILE, job.data.locale)\n  const locationPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_SELECT_LOCATION, job.data.locale)\n  const clipPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_AGENT_CLIP, job.data.locale)\n  const screenplayPromptTemplate = getPromptTemplate(PROMPT_IDS.NP_SCREENPLAY_CONVERSION, job.data.locale)\n  const maxLength = 30000\n  const content = mergedContent.length > maxLength ? mergedContent.slice(0, maxLength) : mergedContent\n  const payloadMeta = typeof payload.meta === 'object' && payload.meta !== null\n    ? (payload.meta as AnyObj)\n    : {}\n  const runId = typeof payload.runId === 'string' && payload.runId.trim()\n    ? payload.runId.trim()\n    : (typeof payloadMeta.runId === 'string' ? payloadMeta.runId.trim() : '')\n  if (!runId) {\n    throw new Error('runId is required for story_to_script pipeline')\n  }\n  const retryClipId = resolveRetryClipId(retryStepKey)\n  if (retryStepKey && !retryClipId) {\n    throw new Error(`unsupported retry step for story_to_script: ${retryStepKey}`)\n  }\n  const workerId = buildWorkflowWorkerId(job, 'story_to_script')\n  const assertRunActive = async (stage: string) => {\n    await assertWorkflowRunActive({\n      runId,\n      workerId,\n      stage,\n    })\n  }\n  const streamContext = createWorkerLLMStreamContext(job, 'story_to_script')\n  const callbacks = createWorkerLLMStreamCallbacks(job, streamContext, {\n    assertActive: async (stage) => {\n      await assertRunActive(stage)\n    },\n    isActive: async () => {\n      try {\n        await assertRunActive('worker_llm_stream_probe')\n        return true\n      } catch (error) {\n        if (error instanceof TaskTerminatedError) {\n          return false\n        }\n        throw error\n      }\n    },\n  })\n\n  const runStep = async (\n    meta: StoryToScriptStepMeta,\n    prompt: string,\n    action: string,\n    _maxOutputTokens: number,\n  ): Promise<StoryToScriptStepOutput> => {\n    void _maxOutputTokens\n    const stepAttempt = meta.stepAttempt\n      || (retryStepKey && meta.stepId === retryStepKey ? retryStepAttempt : 1)\n    await assertRunActive(`story_to_script_step:${meta.stepId}`)\n    const progress = 15 + Math.min(55, Math.floor((meta.stepIndex / Math.max(1, meta.stepTotal)) * 55))\n    await reportTaskProgress(job, progress, {\n      stage: 'story_to_script_step',\n      stageLabel: 'progress.stage.storyToScriptStep',\n      displayMode: 'detail',\n      message: meta.stepTitle,\n      stepId: meta.stepId,\n      stepAttempt,\n      stepTitle: meta.stepTitle,\n      stepIndex: meta.stepIndex,\n      stepTotal: meta.stepTotal,\n      dependsOn: Array.isArray(meta.dependsOn) ? meta.dependsOn : [],\n      groupId: meta.groupId || null,\n      parallelKey: meta.parallelKey || null,\n      retryable: meta.retryable !== false,\n      blockedBy: Array.isArray(meta.blockedBy) ? meta.blockedBy : [],\n    })\n\n    logAIAnalysis(job.data.userId, 'worker', projectId, project.name, {\n      action: `STORY_TO_SCRIPT_PROMPT:${action}`,\n      input: { stepId: meta.stepId, stepTitle: meta.stepTitle, prompt },\n      model,\n    })\n\n    const output = await executeAiTextStep({\n      userId: job.data.userId,\n      model,\n      messages: [{ role: 'user', content: prompt }],\n      projectId,\n      action,\n      meta: {\n        ...meta,\n        stepAttempt,\n      },\n      temperature,\n      reasoning,\n      reasoningEffort,\n    })\n    await callbacks.flush()\n\n    logAIAnalysis(job.data.userId, 'worker', projectId, project.name, {\n      action: `STORY_TO_SCRIPT_OUTPUT:${action}`,\n      output: {\n        stepId: meta.stepId,\n        stepTitle: meta.stepTitle,\n        rawText: output.text,\n        textLength: output.text.length,\n        reasoningLength: output.reasoning.length,\n      },\n      model,\n    })\n\n    return {\n      text: output.text,\n      reasoning: output.reasoning,\n    }\n  }\n\n  const leaseResult = await withWorkflowRunLease({\n    runId,\n    userId: job.data.userId,\n    workerId,\n    run: async () => {\n      await reportTaskProgress(job, 10, {\n        stage: 'story_to_script_prepare',\n        stageLabel: 'progress.stage.storyToScriptPrepare',\n        displayMode: 'detail',\n      })\n\n      if (retryClipId) {\n        const splitArtifacts = await listArtifacts({\n          runId,\n          artifactType: 'clips.split',\n          limit: 1,\n        })\n        const latestSplit = splitArtifacts[0]\n        const splitPayload = latestSplit && typeof latestSplit.payload === 'object' && latestSplit.payload !== null\n          ? (latestSplit.payload as Record<string, unknown>)\n          : null\n        if (!splitPayload) {\n          throw new Error('missing clips.split artifact for retry')\n        }\n\n        const clipRows = Array.isArray(splitPayload.clipList) ? splitPayload.clipList : []\n        const retryClip = clipRows.find((item) => {\n          if (!item || typeof item !== 'object' || Array.isArray(item)) return false\n          return asString((item as Record<string, unknown>).id).trim() === retryClipId\n        }) as Record<string, unknown> | undefined\n        if (!retryClip) {\n          throw new Error(`retry clip not found in artifact: ${retryClipId}`)\n        }\n\n        const clipContent = asString(retryClip.content)\n        if (!clipContent.trim()) {\n          throw new Error(`retry clip content is empty: ${retryClipId}`)\n        }\n\n        const screenplayPrompt = screenplayPromptTemplate\n          .replace('{clip_content}', clipContent)\n          .replace('{locations_lib_name}', asString(splitPayload.locationsLibName) || '无')\n          .replace('{characters_lib_name}', asString(splitPayload.charactersLibName) || '无')\n          .replace('{characters_introduction}', asString(splitPayload.charactersIntroduction) || '暂无角色介绍')\n          .replace('{clip_id}', retryClipId)\n\n        const stepMeta: StoryToScriptStepMeta = {\n          stepId: retryStepKey,\n          stepAttempt: retryStepAttempt,\n          stepTitle: 'progress.streamStep.screenplayConversion',\n          stepIndex: 1,\n          stepTotal: 1,\n          dependsOn: ['split_clips'],\n          retryable: true,\n        }\n        let screenplay: AnyObj | null = null\n        try {\n          const stepOutput = await (async () => {\n            try {\n              return await withInternalLLMStreamCallbacks(\n                callbacks,\n                async () => await runStep(stepMeta, screenplayPrompt, 'screenplay_conversion', 2200),\n              )\n            } finally {\n              await callbacks.flush()\n            }\n          })()\n          screenplay = parseScreenplayPayload(stepOutput.text)\n        } catch (error) {\n          await createArtifact({\n            runId,\n            stepKey: retryStepKey,\n            artifactType: 'screenplay.clip',\n            refId: retryClipId,\n            payload: {\n              clipId: retryClipId,\n              success: false,\n              error: error instanceof Error ? error.message : String(error),\n            },\n          })\n          throw error\n        }\n        if (!screenplay) {\n          throw new Error('retry screenplay output is empty')\n        }\n        await createArtifact({\n          runId,\n          stepKey: retryStepKey,\n          artifactType: 'screenplay.clip',\n          refId: retryClipId,\n          payload: {\n            clipId: retryClipId,\n            success: true,\n            sceneCount: Array.isArray(screenplay.scenes) ? screenplay.scenes.length : 0,\n            screenplay,\n          },\n        })\n\n        let clipRecord = await prisma.novelPromotionClip.findFirst({\n          where: {\n            episodeId,\n            startText: asString(retryClip.startText) || null,\n            endText: asString(retryClip.endText) || null,\n          },\n          select: { id: true },\n        })\n        if (!clipRecord) {\n          clipRecord = await prisma.novelPromotionClip.create({\n            data: {\n              episodeId,\n              startText: asString(retryClip.startText) || null,\n              endText: asString(retryClip.endText) || null,\n              summary: asString(retryClip.summary),\n              location: asString(retryClip.location) || null,\n              characters: Array.isArray(retryClip.characters) ? JSON.stringify(retryClip.characters) : null,\n              content: clipContent,\n            },\n            select: { id: true },\n          })\n        }\n        await prisma.novelPromotionClip.update({\n          where: { id: clipRecord.id },\n          data: {\n            screenplay: JSON.stringify(screenplay),\n          },\n        })\n\n        await reportTaskProgress(job, 96, {\n          stage: 'story_to_script_persist_done',\n          stageLabel: 'progress.stage.storyToScriptPersistDone',\n          displayMode: 'detail',\n          message: 'retry step completed',\n          stepId: retryStepKey,\n          stepAttempt: retryStepAttempt,\n          stepTitle: 'progress.streamStep.screenplayConversion',\n          stepIndex: 1,\n          stepTotal: 1,\n        })\n\n        return {\n          episodeId,\n          clipCount: 1,\n          screenplaySuccessCount: 1,\n          screenplayFailedCount: 0,\n          persistedCharacters: 0,\n          persistedLocations: 0,\n          persistedClips: 1,\n          retryStepKey,\n        }\n      }\n\n      const result: StoryToScriptOrchestratorResult = await (async () => {\n        try {\n          return await withInternalLLMStreamCallbacks(\n            callbacks,\n            async () => await runStoryToScriptOrchestrator({\n              concurrency: workflowConcurrency.analysis,\n              content,\n              baseCharacters: (novelData.characters || []).map((item) => item.name),\n              baseLocations: (novelData.locations || []).map((item) => item.name),\n              baseCharacterIntroductions: (novelData.characters || []).map((item) => ({\n                name: item.name,\n                introduction: item.introduction || '',\n              })),\n              promptTemplates: {\n                characterPromptTemplate,\n                locationPromptTemplate,\n                clipPromptTemplate,\n                screenplayPromptTemplate,\n              },\n              runStep,\n            }),\n          )\n        } finally {\n          await callbacks.flush()\n        }\n      })()\n\n      await createArtifact({\n        runId,\n        stepKey: 'analyze_characters',\n        artifactType: 'analysis.characters',\n        refId: episodeId,\n        payload: {\n          characters: result.analyzedCharacters,\n          raw: result.charactersObject,\n        },\n      })\n      await createArtifact({\n        runId,\n        stepKey: 'analyze_locations',\n        artifactType: 'analysis.locations',\n        refId: episodeId,\n        payload: {\n          locations: result.analyzedLocations,\n          raw: result.locationsObject,\n        },\n      })\n      await createArtifact({\n        runId,\n        stepKey: 'split_clips',\n        artifactType: 'clips.split',\n        refId: episodeId,\n        payload: {\n          clipList: result.clipList,\n          charactersLibName: result.charactersLibName,\n          locationsLibName: result.locationsLibName,\n          charactersIntroduction: result.charactersIntroduction,\n        },\n      })\n      for (const screenplayResult of result.screenplayResults) {\n        await createArtifact({\n          runId,\n          stepKey: `screenplay_${screenplayResult.clipId}`,\n          artifactType: 'screenplay.clip',\n          refId: screenplayResult.clipId,\n          payload: {\n            ...screenplayResult,\n          },\n        })\n      }\n\n      if (result.summary.screenplayFailedCount > 0) {\n        const failed = result.screenplayResults.filter((item) => !item.success)\n        const preview = failed\n          .slice(0, 3)\n          .map((item) => `${item.clipId}:${item.error || 'unknown error'}`)\n          .join(' | ')\n        throw new Error(\n          `STORY_TO_SCRIPT_PARTIAL_FAILED: ${result.summary.screenplayFailedCount}/${result.summary.clipCount} screenplay steps failed. ${preview}`,\n        )\n      }\n\n      await reportTaskProgress(job, 80, {\n        stage: 'story_to_script_persist',\n        stageLabel: 'progress.stage.storyToScriptPersist',\n        displayMode: 'detail',\n      })\n      await assertRunActive('story_to_script_persist')\n\n      const episodeStillExists = await prisma.novelPromotionEpisode.findUnique({\n        where: { id: episodeId },\n        select: { id: true },\n      })\n      if (!episodeStillExists) {\n        throw new Error(`NOT_FOUND: Episode ${episodeId} was deleted while the task was running`)\n      }\n\n      const existingCharacterNames = new Set<string>(\n        (novelData.characters || []).map((item) => String(item.name || '').toLowerCase()),\n      )\n      const existingLocationNames = new Set<string>(\n        (novelData.locations || []).map((item) => String(item.name || '').toLowerCase()),\n      )\n\n      const createdCharacters = await persistAnalyzedCharacters({\n        projectInternalId: novelData.id,\n        existingNames: existingCharacterNames,\n        analyzedCharacters: result.analyzedCharacters,\n      })\n\n      const createdLocations = await persistAnalyzedLocations({\n        projectInternalId: novelData.id,\n        existingNames: existingLocationNames,\n        analyzedLocations: result.analyzedLocations,\n      })\n\n      const createdClipRows = await persistClips({\n        episodeId,\n        clipList: result.clipList,\n      })\n      const clipIdMap = new Map(createdClipRows.map((item) => [item.clipKey, item.id]))\n\n      for (const screenplayResult of result.screenplayResults) {\n        if (!screenplayResult.success || !screenplayResult.screenplay) continue\n        const clipRecordId = resolveClipRecordId(clipIdMap, screenplayResult.clipId)\n        if (!clipRecordId) continue\n        await prisma.novelPromotionClip.update({\n          where: { id: clipRecordId },\n          data: {\n            screenplay: JSON.stringify(screenplayResult.screenplay),\n          },\n        })\n      }\n\n      await reportTaskProgress(job, 96, {\n        stage: 'story_to_script_persist_done',\n        stageLabel: 'progress.stage.storyToScriptPersistDone',\n        displayMode: 'detail',\n      })\n\n      return {\n        episodeId,\n        clipCount: result.summary.clipCount,\n        screenplaySuccessCount: result.summary.screenplaySuccessCount,\n        screenplayFailedCount: result.summary.screenplayFailedCount,\n        persistedCharacters: createdCharacters.length,\n        persistedLocations: createdLocations.length,\n        persistedClips: createdClipRows.length,\n      }\n    },\n  })\n\n  if (!leaseResult.claimed || !leaseResult.result) {\n    return {\n      runId,\n      skipped: true,\n      episodeId,\n    }\n  }\n  return leaseResult.result\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/voice-analyze-helpers.ts",
    "content": "import { safeParseJson, safeParseJsonArray } from '@/lib/json-repair'\n\nexport interface StoryboardPanelLike {\n  panelIndex: number\n  srtSegment: string | null\n  description: string | null\n  characters: string | null\n}\n\nexport interface StoryboardLike {\n  id: string\n  panels: StoryboardPanelLike[]\n}\n\nexport interface VoiceLineMatchedPanel {\n  storyboardId?: string\n  panelIndex?: number\n}\n\nexport interface VoiceLinePayload {\n  lineIndex?: number\n  speaker?: string\n  content?: string\n  emotionStrength?: number\n  matchedPanel?: VoiceLineMatchedPanel | null\n}\n\nfunction parseVoiceLinePayload(value: unknown): VoiceLinePayload | null {\n  if (!value || typeof value !== 'object') return null\n  const record = value as Record<string, unknown>\n  const matchedPanelRaw =\n    record.matchedPanel && typeof record.matchedPanel === 'object'\n      ? (record.matchedPanel as Record<string, unknown>)\n      : null\n  return {\n    lineIndex: typeof record.lineIndex === 'number' ? record.lineIndex : undefined,\n    speaker: typeof record.speaker === 'string' ? record.speaker : undefined,\n    content: typeof record.content === 'string' ? record.content : undefined,\n    emotionStrength: typeof record.emotionStrength === 'number' ? record.emotionStrength : undefined,\n    matchedPanel: matchedPanelRaw\n      ? {\n        storyboardId: typeof matchedPanelRaw.storyboardId === 'string' ? matchedPanelRaw.storyboardId : undefined,\n        panelIndex: typeof matchedPanelRaw.panelIndex === 'number' ? matchedPanelRaw.panelIndex : undefined,\n      }\n      : null,\n  }\n}\n\nexport function buildStoryboardJson(storyboards: StoryboardLike[]): string {\n  const panelsData: Array<{\n    storyboardId: string\n    panelIndex: number\n    text_segment: string\n    description: string\n    characters: string\n  }> = []\n\n  for (const sb of storyboards) {\n    const panels = sb.panels || []\n    for (const panel of panels) {\n      panelsData.push({\n        storyboardId: sb.id,\n        panelIndex: panel.panelIndex,\n        text_segment: panel.srtSegment || '',\n        description: panel.description || '',\n        characters: panel.characters || '',\n      })\n    }\n  }\n\n  if (panelsData.length === 0) {\n    return '无分镜数据'\n  }\n\n  return JSON.stringify(panelsData, null, 2)\n}\n\nexport function parseVoiceLinesJson(responseText: string): VoiceLinePayload[] {\n  const parsed = safeParseJsonArray(responseText)\n  if (parsed.length === 0) {\n    const raw = safeParseJson(responseText)\n    if (Array.isArray(raw) && raw.length === 0) {\n      return []\n    }\n    throw new Error('Invalid voice lines data structure')\n  }\n  const voiceLines = parsed\n    .map((item) => parseVoiceLinePayload(item))\n    .filter((item): item is VoiceLinePayload => Boolean(item))\n  if (voiceLines.length === 0) {\n    throw new Error('Invalid voice lines data structure')\n  }\n  return voiceLines\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/voice-analyze.ts",
    "content": "import type { Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport { buildCharactersIntroduction } from '@/lib/constants'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'\nimport type { TaskJobData } from '@/lib/task/types'\nimport {\n  buildStoryboardJson,\n  parseVoiceLinesJson,\n  type VoiceLinePayload,\n} from './voice-analyze-helpers'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport { resolveAnalysisModel } from './resolve-analysis-model'\n\nconst MAX_VOICE_ANALYZE_ATTEMPTS = 2\n\nexport async function handleVoiceAnalyzeTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as Record<string, unknown>\n  const projectId = job.data.projectId\n  const episodeIdRaw =\n    typeof payload.episodeId === 'string'\n      ? payload.episodeId\n      : typeof job.data.episodeId === 'string'\n        ? job.data.episodeId\n        : ''\n  const episodeId = episodeIdRaw.trim()\n\n  if (!episodeId) {\n    throw new Error('episodeId is required')\n  }\n\n  const project = await prisma.project.findUnique({\n    where: { id: projectId },\n    select: {\n      id: true,\n      mode: true,\n    },\n  })\n  if (!project) {\n    throw new Error('Project not found')\n  }\n  if (project.mode !== 'novel-promotion') {\n    throw new Error('Not a novel promotion project')\n  }\n\n  const novelPromotionData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      characters: true,\n    },\n  })\n  if (!novelPromotionData) {\n    throw new Error('Novel promotion data not found')\n  }\n\n  const episode = await prisma.novelPromotionEpisode.findUnique({\n    where: { id: episodeId },\n    include: {\n      storyboards: {\n        include: {\n          clip: true,\n          panels: {\n            orderBy: { panelIndex: 'asc' },\n          },\n        },\n        orderBy: { createdAt: 'asc' },\n      },\n    },\n  })\n  if (!episode) {\n    throw new Error('Episode not found')\n  }\n  if (episode.novelPromotionProjectId !== novelPromotionData.id) {\n    throw new Error('Episode does not belong to this project')\n  }\n\n  const novelText = episode.novelText\n  if (!novelText) {\n    throw new Error('No novel text to analyze')\n  }\n\n  const analysisModel = await resolveAnalysisModel({\n    userId: job.data.userId,\n    inputModel: payload.model,\n    projectAnalysisModel: novelPromotionData.analysisModel,\n  })\n\n  const charactersLibName = novelPromotionData.characters.length > 0\n    ? novelPromotionData.characters.map((c) => c.name).join('、')\n    : '无'\n  const charactersIntroduction = buildCharactersIntroduction(novelPromotionData.characters)\n  const storyboardJson = buildStoryboardJson(episode.storyboards || [])\n  const promptTemplate = buildPrompt({\n    promptId: PROMPT_IDS.NP_VOICE_ANALYSIS,\n    locale: job.data.locale,\n    variables: {\n      input: novelText,\n      characters_lib_name: charactersLibName,\n      characters_introduction: charactersIntroduction,\n      storyboard_json: storyboardJson,\n    },\n  })\n\n  await reportTaskProgress(job, 20, {\n    stage: 'voice_analyze_prepare',\n    stageLabel: '准备台词分析参数',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'voice_analyze_prepare')\n\n  const streamContext = createWorkerLLMStreamContext(job, 'voice_analyze')\n  const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)\n  const panelIdByStoryboardPanel = new Map<string, string>()\n  for (const storyboard of episode.storyboards || []) {\n    for (const panel of storyboard.panels || []) {\n      panelIdByStoryboardPanel.set(`${storyboard.id}:${panel.panelIndex}`, panel.id)\n    }\n  }\n  if (panelIdByStoryboardPanel.size === 0) {\n    throw new Error('No storyboard panels found for voice matching')\n  }\n\n  type StrictVoiceLine = {\n    lineIndex: number\n    speaker: string\n    content: string\n    emotionStrength: number\n    matchedPanelId: string | null\n    matchedStoryboardId: string | null\n    matchedPanelIndex: number | null\n  }\n  let voiceLinesData: StrictVoiceLine[] | null = null\n  let lastAnalyzeError: Error | null = null\n\n  try {\n    for (let attempt = 1; attempt <= MAX_VOICE_ANALYZE_ATTEMPTS; attempt += 1) {\n      try {\n        const completion = await withInternalLLMStreamCallbacks(\n          streamCallbacks,\n          async () =>\n            await executeAiTextStep({\n              userId: job.data.userId,\n              model: analysisModel,\n              messages: [{ role: 'user', content: promptTemplate }],\n              projectId,\n              action: 'voice_analyze',\n              meta: {\n                stepId: 'voice_analyze',\n                stepAttempt: attempt,\n                stepTitle: '台词分析',\n                stepIndex: 1,\n                stepTotal: 1,\n              },\n            }),\n        )\n\n        const responseText = completion.text\n        if (!responseText) {\n          throw new Error('No response from AI')\n        }\n\n        const parsedLines = parseVoiceLinesJson(responseText)\n        const strictLines: StrictVoiceLine[] = parsedLines.map((lineData: VoiceLinePayload, index: number) => {\n          if (typeof lineData.lineIndex !== 'number' || !Number.isFinite(lineData.lineIndex)) {\n            throw new Error(`voice line ${index + 1} is missing valid lineIndex`)\n          }\n          const lineIndex = Math.floor(lineData.lineIndex)\n          if (lineIndex <= 0) {\n            throw new Error(`voice line ${index + 1} has invalid lineIndex`)\n          }\n          if (typeof lineData.speaker !== 'string' || !lineData.speaker.trim()) {\n            throw new Error(`voice line ${index + 1} is missing valid speaker`)\n          }\n          if (typeof lineData.content !== 'string' || !lineData.content.trim()) {\n            throw new Error(`voice line ${index + 1} is missing valid content`)\n          }\n          if (typeof lineData.emotionStrength !== 'number' || !Number.isFinite(lineData.emotionStrength)) {\n            throw new Error(`voice line ${index + 1} is missing valid emotionStrength`)\n          }\n\n          const matchedPanel = lineData.matchedPanel\n          if (!matchedPanel) {\n            return {\n              lineIndex,\n              speaker: lineData.speaker.trim(),\n              content: lineData.content,\n              emotionStrength: Math.min(1, Math.max(0.1, lineData.emotionStrength)),\n              matchedPanelId: null,\n              matchedStoryboardId: null,\n              matchedPanelIndex: null,\n            }\n          }\n\n          const storyboardId = typeof matchedPanel.storyboardId === 'string' ? matchedPanel.storyboardId.trim() : ''\n          const panelIndex = typeof matchedPanel.panelIndex === 'number' && Number.isFinite(matchedPanel.panelIndex)\n            ? Math.floor(matchedPanel.panelIndex)\n            : null\n          if (!storyboardId || panelIndex === null || panelIndex < 0) {\n            throw new Error(`voice line ${index + 1} has invalid matchedPanel`)\n          }\n\n          const panelKey = `${storyboardId}:${panelIndex}`\n          const panelId = panelIdByStoryboardPanel.get(panelKey)\n          if (!panelId) {\n            throw new Error(`voice line ${index + 1} references non-existent panel ${panelKey}`)\n          }\n\n          return {\n            lineIndex,\n            speaker: lineData.speaker.trim(),\n            content: lineData.content,\n            emotionStrength: Math.min(1, Math.max(0.1, lineData.emotionStrength)),\n            matchedPanelId: panelId,\n            matchedStoryboardId: storyboardId,\n            matchedPanelIndex: panelIndex,\n          }\n        })\n\n        voiceLinesData = strictLines\n        break\n      } catch (error) {\n        lastAnalyzeError = error instanceof Error ? error : new Error(String(error))\n      }\n    }\n  } finally {\n    await streamCallbacks.flush()\n  }\n\n  if (!voiceLinesData) {\n    throw lastAnalyzeError || new Error('voice analyze failed')\n  }\n\n  await reportTaskProgress(job, 82, {\n    stage: 'voice_analyze_persist',\n    stageLabel: '保存台词分析结果',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'voice_analyze_persist')\n\n  const createdVoiceLines = await prisma.$transaction(async (tx) => {\n    const voiceLineModel = tx.novelPromotionVoiceLine as unknown as {\n      upsert?: (args: unknown) => Promise<{\n        id: string\n        speaker: string\n        matchedStoryboardId: string | null\n      }>\n      create: (args: unknown) => Promise<{\n        id: string\n        speaker: string\n        matchedStoryboardId: string | null\n      }>\n      deleteMany: (args: unknown) => Promise<unknown>\n    }\n    const created: Array<{\n      id: string\n      speaker: string\n      matchedStoryboardId: string | null\n    }> = []\n\n    for (let i = 0; i < voiceLinesData.length; i += 1) {\n      const lineData = voiceLinesData[i]\n\n      const upsertArgs = {\n        where: {\n          episodeId_lineIndex: {\n            episodeId,\n            lineIndex: lineData.lineIndex,\n          },\n        },\n        create: {\n          episodeId,\n          lineIndex: lineData.lineIndex,\n          speaker: lineData.speaker,\n          content: lineData.content,\n          emotionStrength: lineData.emotionStrength,\n          matchedPanelId: lineData.matchedPanelId,\n          matchedStoryboardId: lineData.matchedStoryboardId,\n          matchedPanelIndex: lineData.matchedPanelIndex,\n        },\n        update: {\n          speaker: lineData.speaker,\n          content: lineData.content,\n          emotionStrength: lineData.emotionStrength,\n          matchedPanelId: lineData.matchedPanelId,\n          matchedStoryboardId: lineData.matchedStoryboardId,\n          matchedPanelIndex: lineData.matchedPanelIndex,\n        },\n        select: {\n          id: true,\n          speaker: true,\n          matchedStoryboardId: true,\n        },\n      }\n      const voiceLine = typeof voiceLineModel.upsert === 'function'\n        ? await voiceLineModel.upsert(upsertArgs)\n        : (\n          process.env.NODE_ENV === 'test'\n            ? await voiceLineModel.create({\n              data: upsertArgs.create,\n              select: upsertArgs.select,\n            })\n            : (() => { throw new Error('novelPromotionVoiceLine.upsert unavailable') })()\n        )\n      created.push(voiceLine)\n    }\n\n    const incomingLineIndexes = new Set<number>(voiceLinesData.map((item) => item.lineIndex))\n    if (incomingLineIndexes.size === 0) {\n      await voiceLineModel.deleteMany({\n        where: {\n          episodeId,\n        },\n      })\n    } else {\n      await voiceLineModel.deleteMany({\n        where: {\n          episodeId,\n          lineIndex: {\n            notIn: Array.from(incomingLineIndexes),\n          },\n        },\n      })\n    }\n\n    return created\n  })\n\n  const speakerStats: Record<string, number> = {}\n  for (const line of createdVoiceLines) {\n    speakerStats[line.speaker] = (speakerStats[line.speaker] || 0) + 1\n  }\n  const matchedCount = createdVoiceLines.filter((line) => line.matchedStoryboardId).length\n\n  await reportTaskProgress(job, 96, {\n    stage: 'voice_analyze_persist_done',\n    stageLabel: '台词分析结果已保存',\n    displayMode: 'detail',\n  })\n\n  return {\n    episodeId,\n    count: createdVoiceLines.length,\n    matchedCount,\n    speakerStats,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/handlers/voice-design.ts",
    "content": "import type { Job } from 'bullmq'\nimport {\n  createVoiceDesign,\n  validatePreviewText,\n  validateVoicePrompt,\n  type VoiceDesignInput,\n} from '@/lib/providers/bailian/voice-design'\nimport { getProviderConfig } from '@/lib/api-config'\nimport { reportTaskProgress } from '@/lib/workers/shared'\nimport { assertTaskActive } from '@/lib/workers/utils'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nfunction readRequiredString(value: unknown, field: string): string {\n  if (typeof value !== 'string' || !value.trim()) {\n    throw new Error(`${field} is required`)\n  }\n  return value.trim()\n}\n\nfunction readLanguage(value: unknown): 'zh' | 'en' {\n  return value === 'en' ? 'en' : 'zh'\n}\n\nexport async function handleVoiceDesignTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as Record<string, unknown>\n  const voicePrompt = readRequiredString(payload.voicePrompt, 'voicePrompt')\n  const previewText = readRequiredString(payload.previewText, 'previewText')\n  const preferredName = typeof payload.preferredName === 'string' && payload.preferredName.trim()\n    ? payload.preferredName.trim()\n    : 'custom_voice'\n  const language = readLanguage(payload.language)\n\n  const promptValidation = validateVoicePrompt(voicePrompt)\n  if (!promptValidation.valid) {\n    throw new Error(promptValidation.error || 'invalid voicePrompt')\n  }\n  const textValidation = validatePreviewText(previewText)\n  if (!textValidation.valid) {\n    throw new Error(textValidation.error || 'invalid previewText')\n  }\n\n  await reportTaskProgress(job, 25, {\n    stage: 'voice_design_submit',\n    stageLabel: '提交声音设计任务',\n    displayMode: 'detail',\n  })\n  await assertTaskActive(job, 'voice_design_submit')\n\n  const { apiKey } = await getProviderConfig(job.data.userId, 'bailian')\n  const input: VoiceDesignInput = {\n    voicePrompt,\n    previewText,\n    preferredName,\n    language,\n  }\n  const designed = await createVoiceDesign(input, apiKey)\n  if (!designed.success) {\n    throw new Error(designed.error || '声音设计失败')\n  }\n\n  await reportTaskProgress(job, 96, {\n    stage: 'voice_design_done',\n    stageLabel: '声音设计完成',\n    displayMode: 'detail',\n  })\n\n  return {\n    success: true,\n    voiceId: designed.voiceId,\n    targetModel: designed.targetModel,\n    audioBase64: designed.audioBase64,\n    sampleRate: designed.sampleRate,\n    responseFormat: designed.responseFormat,\n    usageCount: designed.usageCount,\n    requestId: designed.requestId,\n    taskType: job.data.type === TASK_TYPE.ASSET_HUB_VOICE_DESIGN ? TASK_TYPE.ASSET_HUB_VOICE_DESIGN : TASK_TYPE.VOICE_DESIGN,\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/image.worker.ts",
    "content": "import { Worker, type Job } from 'bullmq'\nimport { queueRedis } from '@/lib/redis'\nimport { QUEUE_NAME } from '@/lib/task/queues'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\nimport { getUserWorkflowConcurrencyConfig } from '@/lib/config-service'\nimport { reportTaskProgress, withTaskLifecycle } from './shared'\nimport { withUserConcurrencyGate } from './user-concurrency-gate'\nimport {\n  handleAssetHubImageTask,\n  handleAssetHubModifyTask,\n  handleCharacterImageTask,\n  handleLocationImageTask,\n  handleModifyAssetImageTask,\n  handlePanelImageTask,\n  handlePanelVariantTask,\n} from './handlers/image-task-handlers'\n\ntype AnyObj = Record<string, unknown>\n\nasync function processImageTask(job: Job<TaskJobData>) {\n  await reportTaskProgress(job, 5, { stage: 'received' })\n\n  switch (job.data.type) {\n    case TASK_TYPE.IMAGE_CHARACTER:\n      return await handleCharacterImageTask(job)\n    case TASK_TYPE.IMAGE_LOCATION:\n      return await handleLocationImageTask(job)\n    case TASK_TYPE.REGENERATE_GROUP: {\n      const payload = (job.data.payload || {}) as AnyObj\n      if (payload.type === 'character') {\n        return await handleCharacterImageTask(job)\n      }\n      return await handleLocationImageTask(job)\n    }\n    case TASK_TYPE.MODIFY_ASSET_IMAGE:\n      return await handleModifyAssetImageTask(job)\n    case TASK_TYPE.ASSET_HUB_IMAGE:\n      return await handleAssetHubImageTask(job)\n    case TASK_TYPE.ASSET_HUB_MODIFY:\n      return await handleAssetHubModifyTask(job)\n    case TASK_TYPE.IMAGE_PANEL:\n      return await handlePanelImageTask(job)\n    case TASK_TYPE.PANEL_VARIANT:\n      return await handlePanelVariantTask(job)\n    default:\n      throw new Error(`Unsupported image task type: ${job.data.type}`)\n  }\n}\n\nexport function createImageWorker() {\n  return new Worker<TaskJobData>(\n    QUEUE_NAME.IMAGE,\n    async (job) => await withTaskLifecycle(job, async (taskJob) => {\n      const workflowConcurrency = await getUserWorkflowConcurrencyConfig(taskJob.data.userId)\n      return await withUserConcurrencyGate({\n        scope: 'image',\n        userId: taskJob.data.userId,\n        limit: workflowConcurrency.image,\n        run: async () => await processImageTask(taskJob),\n      })\n    }),\n    {\n      connection: queueRedis,\n      concurrency: Number.parseInt(process.env.QUEUE_CONCURRENCY_IMAGE || '20', 10) || 20,\n    },\n  )\n}\n"
  },
  {
    "path": "src/lib/workers/index.ts",
    "content": "import 'dotenv/config'\nimport { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'\nimport { createImageWorker } from './image.worker'\nimport { createVideoWorker } from './video.worker'\nimport { createVoiceWorker } from './voice.worker'\nimport { createTextWorker } from './text.worker'\n\nconst workers = [createImageWorker(), createVideoWorker(), createVoiceWorker(), createTextWorker()]\n\n_ulogInfo('[Workers] started:', workers.length)\n\nfor (const worker of workers) {\n  worker.on('ready', () => {\n    _ulogInfo(`[Workers] ready: ${worker.name}`)\n  })\n\n  worker.on('error', (err) => {\n    _ulogError(`[Workers] error: ${worker.name}`, err.message)\n  })\n\n  worker.on('failed', (job, err) => {\n    _ulogError(`[Workers] job failed: ${worker.name}`, {\n      jobId: job?.id,\n      taskId: job?.data?.taskId,\n      taskType: job?.data?.type,\n      error: err.message,\n    })\n  })\n}\n\nasync function shutdown(signal: string) {\n  _ulogInfo(`[Workers] shutdown signal: ${signal}`)\n  await Promise.all(workers.map(async (worker) => await worker.close()))\n  process.exit(0)\n}\n\nprocess.on('SIGINT', () => void shutdown('SIGINT'))\nprocess.on('SIGTERM', () => void shutdown('SIGTERM'))\n"
  },
  {
    "path": "src/lib/workers/shared.ts",
    "content": "import { UnrecoverableError, type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport type { LLMStreamChunk } from '@/lib/llm-observe/types'\nimport { TaskTerminatedError } from '@/lib/task/errors'\nimport {\n  rollbackTaskBillingForTask,\n  touchTaskHeartbeat,\n  tryMarkTaskCompleted,\n  tryMarkTaskFailed,\n  tryMarkTaskProcessing,\n  tryUpdateTaskProgress,\n  updateTaskBillingInfo,\n} from '@/lib/task/service'\nimport { publishTaskEvent, publishTaskStreamEvent } from '@/lib/task/publisher'\nimport { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, TASK_TYPE, type SSEEvent, type TaskBillingInfo, type TaskJobData } from '@/lib/task/types'\nimport { buildTaskProgressMessage, getTaskStageLabel } from '@/lib/task/progress-message'\nimport { normalizeAnyError } from '@/lib/errors/normalize'\nimport { rollbackTaskBilling, settleTaskBilling } from '@/lib/billing'\nimport { withTextUsageCollection } from '@/lib/billing/runtime-usage'\nimport { onProjectNameAvailable } from '@/lib/logging/file-writer'\nimport type { NormalizedError } from '@/lib/errors/types'\nimport { mapTaskSSEEventToRunEvents } from '@/lib/run-runtime/task-bridge'\nimport { publishRunEvent } from '@/lib/run-runtime/publisher'\nimport { RUN_EVENT_TYPE } from '@/lib/run-runtime/types'\n\nfunction toObject(value: unknown): Record<string, unknown> {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}\n  return value as Record<string, unknown>\n}\n\nfunction readStringField(payload: Record<string, unknown>, key: string): string | null {\n  const value = payload[key]\n  if (typeof value !== 'string') return null\n  const trimmed = value.trim()\n  return trimmed ? trimmed : null\n}\n\nfunction readPositiveIntField(payload: Record<string, unknown>, key: string): number | null {\n  const value = payload[key]\n  if (typeof value === 'number' && Number.isFinite(value) && value > 0) {\n    return Math.floor(value)\n  }\n  if (typeof value === 'string' && value.trim()) {\n    const parsed = Number.parseInt(value, 10)\n    if (Number.isFinite(parsed) && parsed > 0) {\n      return parsed\n    }\n  }\n  return null\n}\n\nfunction extractFlowFields(jobData: TaskJobData): Record<string, unknown> {\n  const payload = toObject(jobData.payload)\n  const flowId = readStringField(payload, 'flowId')\n  const flowStageTitle = readStringField(payload, 'flowStageTitle')\n  const flowStageIndex = readPositiveIntField(payload, 'flowStageIndex')\n  const flowStageTotal = readPositiveIntField(payload, 'flowStageTotal')\n  const payloadMeta = toObject(payload.meta)\n  const runId = readStringField(payload, 'runId') || readStringField(payloadMeta, 'runId')\n\n  return {\n    ...(flowId ? { flowId } : {}),\n    ...(flowStageTitle ? { flowStageTitle } : {}),\n    ...(flowStageIndex ? { flowStageIndex } : {}),\n    ...(flowStageTotal ? { flowStageTotal } : {}),\n    ...(runId ? { runId } : {}),\n  }\n}\n\nfunction withFlowFields(jobData: TaskJobData, payload?: Record<string, unknown> | null): Record<string, unknown> {\n  const base = { ...(payload || {}) }\n  const flowFields = extractFlowFields(jobData)\n  for (const [key, value] of Object.entries(flowFields)) {\n    if (base[key] === undefined || base[key] === null || base[key] === '') {\n      base[key] = value\n    }\n  }\n  return base\n}\n\nfunction resolveRunId(jobData: TaskJobData): string | null {\n  const flowFields = extractFlowFields(jobData)\n  const runId = flowFields.runId\n  return typeof runId === 'string' && runId.trim() ? runId.trim() : null\n}\n\nfunction buildWorkerLogger(data: TaskJobData, queueName: string) {\n  return createScopedLogger({\n    module: `worker.${queueName}`,\n    requestId: data.trace?.requestId || undefined,\n    taskId: data.taskId,\n    projectId: data.projectId,\n    userId: data.userId,\n  })\n}\n\nconst RUN_STREAM_REPLAY_PERSIST_TYPES = new Set<string>([\n  TASK_TYPE.STORY_TO_SCRIPT_RUN,\n  TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n])\n\nconst DIRECT_RUN_EVENT_TASK_TYPES = new Set<string>([\n  TASK_TYPE.STORY_TO_SCRIPT_RUN,\n  TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n])\n\nfunction shouldPersistRunStreamReplay(taskType: string): boolean {\n  return RUN_STREAM_REPLAY_PERSIST_TYPES.has(taskType)\n}\n\nfunction shouldDirectPublishRunEvents(taskType: string): boolean {\n  return DIRECT_RUN_EVENT_TASK_TYPES.has(taskType)\n}\n\nasync function publishMirroredRunEvents(params: {\n  taskId: string\n  projectId: string\n  userId: string\n  taskType: string\n  targetType: string\n  targetId: string\n  episodeId?: string | null\n  eventType: typeof TASK_SSE_EVENT_TYPE[keyof typeof TASK_SSE_EVENT_TYPE]\n  payload?: Record<string, unknown> | null\n}) {\n  if (!shouldDirectPublishRunEvents(params.taskType)) return\n\n  const message: SSEEvent = {\n    id: `direct:${params.taskId}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`,\n    type: params.eventType,\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    ts: new Date().toISOString(),\n    taskType: params.taskType,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    episodeId: params.episodeId || null,\n    payload: (params.payload || null) as SSEEvent['payload'],\n  }\n  const runEvents = mapTaskSSEEventToRunEvents(message)\n  for (const event of runEvents) {\n    await publishRunEvent(event)\n  }\n}\n\nasync function publishLifecycleEvent(params: {\n  taskId: string\n  projectId: string\n  userId: string\n  type: typeof TASK_EVENT_TYPE[keyof typeof TASK_EVENT_TYPE]\n  taskType: string\n  targetType: string\n  targetId: string\n  episodeId?: string | null\n  payload?: Record<string, unknown> | null\n  persist?: boolean\n}) {\n  await publishTaskEvent({\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    type: params.type,\n    taskType: params.taskType,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    episodeId: params.episodeId || null,\n    payload: params.payload,\n    persist: params.persist,\n  })\n\n  await publishMirroredRunEvents({\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    taskType: params.taskType,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    episodeId: params.episodeId || null,\n    eventType: TASK_SSE_EVENT_TYPE.LIFECYCLE,\n    payload: {\n      ...params.payload,\n      lifecycleType:\n        params.type === TASK_EVENT_TYPE.PROGRESS\n          ? TASK_EVENT_TYPE.PROCESSING\n          : params.type,\n    },\n  })\n}\n\nasync function publishStreamEvent(params: {\n  taskId: string\n  projectId: string\n  userId: string\n  taskType: string\n  targetType: string\n  targetId: string\n  episodeId?: string | null\n  payload?: Record<string, unknown> | null\n  persist?: boolean\n}) {\n  await publishTaskStreamEvent({\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    taskType: params.taskType,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    episodeId: params.episodeId || null,\n    payload: params.payload,\n    persist: params.persist,\n  })\n\n  await publishMirroredRunEvents({\n    taskId: params.taskId,\n    projectId: params.projectId,\n    userId: params.userId,\n    taskType: params.taskType,\n    targetType: params.targetType,\n    targetId: params.targetId,\n    episodeId: params.episodeId || null,\n    eventType: TASK_SSE_EVENT_TYPE.STREAM,\n    payload: params.payload,\n  })\n}\n\nfunction resolveQueueAttempts(job: Job<TaskJobData>): number {\n  const attempts = (job.opts?.attempts ?? 1)\n  const value = typeof attempts === 'number' && Number.isFinite(attempts) ? Math.floor(attempts) : 1\n  return Math.max(1, value)\n}\n\nfunction resolveAttemptsMade(job: Job<TaskJobData>): number {\n  const attemptsMade = job.attemptsMade\n  const value = typeof attemptsMade === 'number' && Number.isFinite(attemptsMade) ? Math.floor(attemptsMade) : 0\n  return Math.max(0, value)\n}\n\nfunction resolveNextBackoffMs(job: Job<TaskJobData>, failedAttempt: number): number | null {\n  const backoff = job.opts?.backoff\n  if (typeof backoff === 'number' && Number.isFinite(backoff) && backoff > 0) {\n    return Math.floor(backoff)\n  }\n  if (!backoff || typeof backoff !== 'object') return null\n\n  const backoffRecord = backoff as { type?: unknown; delay?: unknown }\n  const baseDelay = typeof backoffRecord.delay === 'number' && Number.isFinite(backoffRecord.delay)\n    ? Math.max(0, Math.floor(backoffRecord.delay))\n    : 0\n  if (baseDelay <= 0) return null\n\n  const type = typeof backoffRecord.type === 'string' ? backoffRecord.type : 'fixed'\n  if (type === 'exponential') {\n    const exponent = Math.max(0, failedAttempt - 1)\n    return baseDelay * Math.pow(2, exponent)\n  }\n  return baseDelay\n}\n\nfunction shouldRetryInQueue(params: {\n  job: Job<TaskJobData>\n  normalizedError: NormalizedError\n}): {\n  enabled: boolean\n  failedAttempt: number\n  maxAttempts: number\n  nextBackoffMs: number | null\n} {\n  const maxAttempts = resolveQueueAttempts(params.job)\n  const failedAttempt = resolveAttemptsMade(params.job) + 1\n  const enabled = params.normalizedError.retryable && failedAttempt < maxAttempts\n  return {\n    enabled,\n    failedAttempt,\n    maxAttempts,\n    nextBackoffMs: resolveNextBackoffMs(params.job, failedAttempt),\n  }\n}\n\nfunction buildErrorCauseChain(input: unknown): Array<{ name: string; message: string }> {\n  const chain: Array<{ name: string; message: string }> = []\n  const seen = new Set<unknown>()\n  let current: unknown = input\n\n  for (let depth = 0; depth < 6; depth += 1) {\n    if (!current || seen.has(current)) break\n    seen.add(current)\n    if (!(current instanceof Error)) {\n      chain.push({ name: typeof current, message: String(current) })\n      break\n    }\n    chain.push({\n      name: current.name || 'Error',\n      message: current.message || '',\n    })\n    const next = (current as Error & { cause?: unknown }).cause\n    if (!next) break\n    current = next\n  }\n\n  return chain\n}\n\nasync function resolveProjectNameForLogging(projectId: string): Promise<void> {\n  try {\n    const project = await prisma.project.findUnique({\n      where: { id: projectId },\n      select: { name: true },\n    })\n    if (project?.name) {\n      onProjectNameAvailable(projectId, project.name)\n    }\n  } catch {\n    // Swallow – log file routing failure should never crash the worker.\n  }\n}\n\nexport async function withTaskLifecycle(job: Job<TaskJobData>, handler: (job: Job<TaskJobData>) => Promise<Record<string, unknown> | void>) {\n  const data = job.data\n  const taskId = data.taskId\n  const logger = buildWorkerLogger(data, job.queueName)\n  const startedAt = Date.now()\n  let billingInfo = (data.billingInfo || null) as TaskBillingInfo | null\n\n  // Register project name for per-project log file routing\n  void resolveProjectNameForLogging(data.projectId)\n\n  const heartbeatTimer = setInterval(() => {\n    void touchTaskHeartbeat(taskId)\n  }, 10_000)\n\n  try {\n    logger.info({\n      action: 'worker.start',\n      message: 'worker started',\n      details: {\n        queue: job.queueName,\n        taskType: data.type,\n        targetType: data.targetType,\n        targetId: data.targetId,\n        episodeId: data.episodeId || null,\n      },\n    })\n    const markedProcessing = await tryMarkTaskProcessing(taskId)\n    if (!markedProcessing) {\n      const rollbackResult = await rollbackTaskBillingForTask({\n        taskId,\n        billingInfo,\n      })\n      if (rollbackResult.billingInfo) {\n        billingInfo = rollbackResult.billingInfo\n      }\n      if (rollbackResult.attempted && !rollbackResult.rolledBack) {\n        logger.error({\n          action: 'worker.skip.terminated.rollback_failed',\n          message: 'task is terminal and billing rollback failed',\n          errorCode: 'BILLING_COMPENSATION_FAILED',\n        })\n      }\n      logger.info({\n        action: 'worker.skip.terminated',\n        message: 'task is not active, skip worker execution',\n      })\n      return\n    }\n    const processingPayload = withFlowFields(data, {\n      queue: job.queueName,\n      stage: 'received',\n      stageLabel: getTaskStageLabel('received'),\n      displayMode: 'loading',\n      trace: {\n        requestId: data.trace?.requestId || null,\n      },\n    })\n    if (shouldDirectPublishRunEvents(data.type)) {\n      const runId = resolveRunId(data)\n      if (runId) {\n        await publishRunEvent({\n          runId,\n          projectId: data.projectId,\n          userId: data.userId,\n          eventType: RUN_EVENT_TYPE.RUN_START,\n          payload: {\n            ...processingPayload,\n            message: buildTaskProgressMessage({\n              eventType: TASK_EVENT_TYPE.PROCESSING,\n              taskType: data.type,\n              payload: processingPayload,\n            }),\n          },\n        })\n      }\n    }\n    await publishLifecycleEvent({\n      taskId,\n      projectId: data.projectId,\n      userId: data.userId,\n      type: TASK_EVENT_TYPE.PROCESSING,\n      taskType: data.type,\n      targetType: data.targetType,\n      targetId: data.targetId,\n      episodeId: data.episodeId || null,\n      payload: {\n        ...processingPayload,\n        message: buildTaskProgressMessage({\n          eventType: TASK_EVENT_TYPE.PROCESSING,\n          taskType: data.type,\n          payload: processingPayload,\n        }),\n      },\n    })\n\n    const { result, textUsage } = await withTextUsageCollection(async () => await handler(job))\n    if (billingInfo?.billable) {\n      billingInfo = (await settleTaskBilling({\n        id: taskId,\n        projectId: data.projectId,\n        userId: data.userId,\n        billingInfo,\n      }, {\n        result: (result || undefined) as Record<string, unknown> | void,\n        textUsage,\n      })) as TaskBillingInfo\n      await updateTaskBillingInfo(taskId, billingInfo)\n    }\n    const markedCompleted = await tryMarkTaskCompleted(taskId, result || null)\n    if (!markedCompleted) {\n      logger.info({\n        action: 'worker.skip.completed',\n        message: 'task already terminal, skip completed event',\n        durationMs: Date.now() - startedAt,\n      })\n      return\n    }\n    logger.info({\n      action: 'worker.completed',\n      message: 'worker completed',\n      durationMs: Date.now() - startedAt,\n      details: result || null,\n    })\n    const completedPayload = withFlowFields(data, {\n      ...(result || {}),\n      displayMode: 'loading',\n      trace: {\n        requestId: data.trace?.requestId || null,\n      },\n    })\n    await publishLifecycleEvent({\n      taskId,\n      projectId: data.projectId,\n      userId: data.userId,\n      type: TASK_EVENT_TYPE.COMPLETED,\n      taskType: data.type,\n      targetType: data.targetType,\n      targetId: data.targetId,\n      episodeId: data.episodeId || null,\n      payload: {\n        ...completedPayload,\n        message: buildTaskProgressMessage({\n          eventType: TASK_EVENT_TYPE.COMPLETED,\n          taskType: data.type,\n          payload: completedPayload,\n        }),\n      },\n    })\n  } catch (error: unknown) {\n    if (error instanceof TaskTerminatedError) {\n      if (billingInfo?.billable) {\n        billingInfo = (await rollbackTaskBilling({\n          id: taskId,\n          billingInfo,\n        })) as TaskBillingInfo\n        await updateTaskBillingInfo(taskId, billingInfo)\n      }\n      logger.info({\n        action: 'worker.terminated',\n        message: error.message,\n        durationMs: Date.now() - startedAt,\n      })\n      throw new UnrecoverableError(`Task terminated: ${error.message}`)\n    }\n\n    const normalizedError = normalizeAnyError(error, { context: 'worker' })\n    const retryDecision = shouldRetryInQueue({\n      job,\n      normalizedError,\n    })\n    const errorCauseChain = buildErrorCauseChain(error)\n    const workerFailureLog = {\n      action: 'worker.failed',\n      message: normalizedError.message,\n      errorCode: normalizedError.code,\n      retryable: normalizedError.retryable,\n      provider: normalizedError.provider || undefined,\n      durationMs: Date.now() - startedAt,\n      details: {\n        queue: job.queueName,\n        taskType: data.type,\n        targetType: data.targetType,\n        targetId: data.targetId,\n      },\n      error:\n        error instanceof Error\n          ? {\n            name: error.name,\n            message: error.message,\n            stack: error.stack,\n            code: normalizedError.code,\n            retryable: normalizedError.retryable,\n            causeChain: errorCauseChain,\n          }\n          : {\n            message: String(error),\n            code: normalizedError.code,\n            retryable: normalizedError.retryable,\n            causeChain: errorCauseChain,\n          },\n    }\n    if (retryDecision.enabled) {\n      logger.error({\n        ...workerFailureLog,\n        action: 'worker.failed.retryable',\n        message: `retryable failure: ${normalizedError.message}`,\n      })\n    } else {\n      logger.error(workerFailureLog)\n    }\n    if (retryDecision.enabled) {\n      logger.error({\n        action: 'worker.retry.scheduled',\n        message: 'retryable worker error, queue retry scheduled',\n        errorCode: normalizedError.code,\n        retryable: normalizedError.retryable,\n        durationMs: Date.now() - startedAt,\n        details: {\n          queue: job.queueName,\n          taskType: data.type,\n          targetType: data.targetType,\n          targetId: data.targetId,\n          failedAttempt: retryDecision.failedAttempt,\n          maxAttempts: retryDecision.maxAttempts,\n          nextBackoffMs: retryDecision.nextBackoffMs,\n        },\n      })\n\n      const retryPayload = withFlowFields(data, {\n        stage: 'retrying',\n        stageLabel: 'progress.runtime.stage.retrying',\n        displayMode: 'detail',\n        error: normalizedError,\n        retry: {\n          failedAttempt: retryDecision.failedAttempt,\n          maxAttempts: retryDecision.maxAttempts,\n          nextBackoffMs: retryDecision.nextBackoffMs,\n        },\n        trace: {\n          requestId: data.trace?.requestId || null,\n        },\n      })\n\n      try {\n        await publishLifecycleEvent({\n          taskId,\n          projectId: data.projectId,\n          userId: data.userId,\n          type: TASK_EVENT_TYPE.PROGRESS,\n          taskType: data.type,\n          targetType: data.targetType,\n          targetId: data.targetId,\n          episodeId: data.episodeId || null,\n          payload: {\n            ...retryPayload,\n            message: `Retry scheduled (${retryDecision.failedAttempt}/${retryDecision.maxAttempts}): ${normalizedError.message}`,\n          },\n          persist: false,\n        })\n      } catch (publishError) {\n        logger.warn({\n          action: 'worker.retry.progress_publish_failed',\n          message: 'failed to publish retry progress event',\n          details: {\n            queue: job.queueName,\n            taskType: data.type,\n            taskId,\n          },\n          error: publishError instanceof Error ? publishError.message : String(publishError),\n        })\n      }\n\n      throw (error instanceof Error ? error : new Error(normalizedError.message || 'Task failed'))\n    }\n\n    if (billingInfo?.billable) {\n      billingInfo = (await rollbackTaskBilling({\n        id: taskId,\n        billingInfo,\n      })) as TaskBillingInfo\n      await updateTaskBillingInfo(taskId, billingInfo)\n    }\n    const markedFailed = await tryMarkTaskFailed(taskId, normalizedError.code, normalizedError.message)\n    if (!markedFailed) {\n      logger.info({\n        action: 'worker.skip.failed',\n        message: 'task already terminal, skip failed event',\n        durationMs: Date.now() - startedAt,\n      })\n      throw new UnrecoverableError('task already terminal')\n    }\n    const failedPayload = withFlowFields(data, {\n      error: normalizedError,\n      displayMode: 'loading',\n      trace: {\n        requestId: data.trace?.requestId || null,\n      },\n    }) as Record<string, unknown>\n    if (process.env.NODE_ENV !== 'production' && error instanceof Error && typeof error.stack === 'string') {\n      failedPayload.errorStack = error.stack.slice(0, 8000)\n    }\n    await publishLifecycleEvent({\n      taskId,\n      projectId: data.projectId,\n      userId: data.userId,\n      type: TASK_EVENT_TYPE.FAILED,\n      taskType: data.type,\n      targetType: data.targetType,\n      targetId: data.targetId,\n      episodeId: data.episodeId || null,\n      payload: {\n        ...failedPayload,\n        message: normalizedError.message || buildTaskProgressMessage({\n          eventType: TASK_EVENT_TYPE.FAILED,\n          taskType: data.type,\n          payload: failedPayload,\n        }),\n      },\n    })\n\n    // Re-throw as UnrecoverableError so BullMQ records the job as failed\n    // (without this, BullMQ thinks the job succeeded and never logs failure)\n    // UnrecoverableError prevents BullMQ auto-retry since we already handle task state in app layer\n    throw new UnrecoverableError(normalizedError.message || 'Task failed')\n  } finally {\n    clearInterval(heartbeatTimer)\n  }\n}\n\nexport async function reportTaskProgress(job: Job<TaskJobData>, progress: number, payload?: Record<string, unknown>) {\n  const value = Math.max(0, Math.min(99, Math.floor(progress)))\n  const logger = buildWorkerLogger(job.data, job.queueName)\n  const nextPayload: Record<string, unknown> = withFlowFields(job.data, payload)\n  const stage = typeof nextPayload.stage === 'string' ? nextPayload.stage : null\n  if (stage && typeof nextPayload.stageLabel !== 'string') {\n    nextPayload.stageLabel = getTaskStageLabel(stage)\n  }\n  if (typeof nextPayload.displayMode !== 'string') {\n    nextPayload.displayMode = 'loading'\n  }\n  if (typeof nextPayload.message !== 'string') {\n    nextPayload.message = buildTaskProgressMessage({\n      eventType: TASK_EVENT_TYPE.PROGRESS,\n      taskType: job.data.type,\n      progress: value,\n      payload: nextPayload,\n    })\n  }\n\n  logger.info({\n    action: 'worker.progress',\n    message: 'worker progress update',\n    details: {\n      progress: value,\n      ...nextPayload,\n    },\n  })\n\n  const updated = await tryUpdateTaskProgress(job.data.taskId, value, nextPayload)\n  if (!updated) {\n    return\n  }\n  await publishLifecycleEvent({\n    taskId: job.data.taskId,\n    projectId: job.data.projectId,\n    userId: job.data.userId,\n    type: TASK_EVENT_TYPE.PROGRESS,\n    taskType: job.data.type,\n    targetType: job.data.targetType,\n    targetId: job.data.targetId,\n    episodeId: job.data.episodeId || null,\n    payload: {\n      progress: value,\n      ...nextPayload,\n      trace: {\n        requestId: job.data.trace?.requestId || null,\n      },\n    },\n    persist: shouldPersistRunStreamReplay(job.data.type),\n  })\n}\n\nexport async function reportTaskStreamChunk(\n  job: Job<TaskJobData>,\n  chunk: LLMStreamChunk,\n  payload?: Record<string, unknown>,\n) {\n  const mergedPayload: Record<string, unknown> = withFlowFields(job.data, {\n    ...(payload || {}),\n    displayMode: 'detail',\n    stream: chunk,\n    done: false,\n    message: payload?.message || (chunk.kind === 'reasoning' ? 'progress.runtime.llm.reasoning' : 'progress.runtime.llm.output'),\n  })\n\n  await publishStreamEvent({\n    taskId: job.data.taskId,\n    projectId: job.data.projectId,\n    userId: job.data.userId,\n    taskType: job.data.type,\n    targetType: job.data.targetType,\n    targetId: job.data.targetId,\n    episodeId: job.data.episodeId || null,\n    payload: {\n      ...mergedPayload,\n      trace: {\n        requestId: job.data.trace?.requestId || null,\n      },\n    },\n    persist: shouldPersistRunStreamReplay(job.data.type),\n  })\n}\n"
  },
  {
    "path": "src/lib/workers/text.worker.ts",
    "content": "import { Worker, type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { queueRedis } from '@/lib/redis'\nimport { executeAiTextStep } from '@/lib/ai-runtime'\nimport { withInternalLLMStreamCallbacks, type InternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'\nimport type { LLMStreamKind } from '@/lib/llm-observe/types'\nimport { QUEUE_NAME } from '@/lib/task/queues'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\nimport { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'\nimport { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'\nimport {\n  executePhase1,\n  executePhase2,\n  executePhase2Acting,\n  executePhase3,\n  type ActingDirection,\n  type CharacterAsset,\n  type LocationAsset,\n  type PhotographyRule,\n} from '@/lib/storyboard-phases'\nimport { getProjectModelConfig } from '@/lib/config-service'\nimport { reportTaskProgress, reportTaskStreamChunk, withTaskLifecycle } from './shared'\nimport { assertTaskActive } from './utils'\nimport { handleStoryToScriptTask } from './handlers/story-to-script'\nimport { handleScriptToStoryboardTask } from './handlers/script-to-storyboard'\nimport { handleVoiceAnalyzeTask } from './handlers/voice-analyze'\nimport { handleAssetHubAIDesignTask } from './handlers/asset-hub-ai-design'\nimport { handleClipsBuildTask } from './handlers/clips-build'\nimport { handleAnalyzeNovelTask } from './handlers/analyze-novel'\nimport { handleScreenplayConvertTask } from './handlers/screenplay-convert'\nimport { handleEpisodeSplitTask } from './handlers/episode-split'\nimport { handleAnalyzeGlobalTask } from './handlers/analyze-global'\nimport { handleAssetHubAIModifyTask } from './handlers/asset-hub-ai-modify'\nimport { handleReferenceToCharacterTask } from './handlers/reference-to-character'\nimport { handleShotAITask } from './handlers/shot-ai-tasks'\nimport { handleCharacterProfileTask } from './handlers/character-profile'\n\ntype AnyObj = Record<string, unknown>\ntype JsonRecord = Record<string, unknown>\n\ntype WorkerLLMStreamContext = {\n  streamRunId: string\n  nextSeqByStepLane: Record<string, number>\n}\n\ntype WorkerInternalLLMStreamCallbacks = InternalLLMStreamCallbacks & {\n  flush: () => Promise<void>\n}\n\nfunction createWorkerLLMStreamContext(job: Job<TaskJobData>, label = 'worker'): WorkerLLMStreamContext {\n  return {\n    streamRunId: `run:${job.data.taskId}:${label}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`,\n    nextSeqByStepLane: {},\n  }\n}\n\nfunction nextWorkerStreamSeq(streamContext: WorkerLLMStreamContext, stepId: string | null, lane: string) {\n  const key = `${stepId || '__default'}|${lane || 'main'}`\n  const current = streamContext.nextSeqByStepLane[key] || 1\n  streamContext.nextSeqByStepLane[key] = current + 1\n  return current\n}\n\nfunction createWorkerLLMStreamCallbacks(\n  job: Job<TaskJobData>,\n  streamContext: WorkerLLMStreamContext,\n): WorkerInternalLLMStreamCallbacks {\n  const maxChunkChars = 128\n  let publishQueue: Promise<void> = Promise.resolve()\n\n  const enqueue = (work: () => Promise<void>) => {\n    publishQueue = publishQueue\n      .catch(() => undefined)\n      .then(work)\n  }\n\n  return {\n    onStage: ({ stage, provider, step }) => {\n      const stageLabel =\n        stage === 'submit'\n          ? 'progress.runtime.stage.llmSubmit'\n          : stage === 'streaming'\n            ? 'progress.runtime.stage.llmStreaming'\n            : stage === 'fallback'\n              ? 'progress.runtime.stage.llmFallbackNonStream'\n              : 'progress.runtime.stage.llmCompleted'\n      const stageKey = `worker_llm_${stage}`\n      const stepId = typeof step?.id === 'string' && step.id.trim() ? step.id.trim() : null\n      const stepTitle = typeof step?.title === 'string' && step.title.trim() ? step.title.trim() : null\n      const stepIndex =\n        typeof step?.index === 'number' && Number.isFinite(step.index) ? Math.max(1, Math.floor(step.index)) : null\n      const stepTotal =\n        typeof step?.total === 'number' && Number.isFinite(step.total)\n          ? Math.max(stepIndex || 1, Math.floor(step.total))\n          : null\n      enqueue(async () => {\n        await reportTaskProgress(job, 65, {\n          stage: stageKey,\n          stageLabel,\n          displayMode: 'detail',\n          message: stageLabel,\n          streamRunId: streamContext.streamRunId,\n          ...(stepId ? { stepId } : {}),\n          ...(stepTitle ? { stepTitle } : {}),\n          ...(stepIndex ? { stepIndex } : {}),\n          ...(stepTotal ? { stepTotal } : {}),\n          meta: {\n            provider: provider || null,\n          },\n        })\n      })\n    },\n    onChunk: ({ kind, delta, lane, step }) => {\n      if (!delta) return\n      const stepId = typeof step?.id === 'string' && step.id.trim() ? step.id.trim() : null\n      const stepTitle = typeof step?.title === 'string' && step.title.trim() ? step.title.trim() : null\n      const stepIndex =\n        typeof step?.index === 'number' && Number.isFinite(step.index) ? Math.max(1, Math.floor(step.index)) : null\n      const stepTotal =\n        typeof step?.total === 'number' && Number.isFinite(step.total)\n          ? Math.max(stepIndex || 1, Math.floor(step.total))\n          : null\n      const laneKey = lane || (kind === 'reasoning' ? 'reasoning' : 'main')\n      for (let i = 0; i < delta.length; i += maxChunkChars) {\n        const piece = delta.slice(i, i + maxChunkChars)\n        if (!piece) continue\n        enqueue(async () => {\n          await reportTaskStreamChunk(\n            job,\n            {\n              kind: kind as LLMStreamKind,\n              delta: piece,\n              seq: nextWorkerStreamSeq(streamContext, stepId, laneKey),\n              lane: laneKey,\n            },\n            {\n              stage: 'worker_llm_stream',\n              stageLabel: 'progress.runtime.stage.llmStreaming',\n              displayMode: 'detail',\n              done: false,\n              message: kind === 'reasoning' ? 'progress.runtime.llm.reasoning' : 'progress.runtime.llm.output',\n              streamRunId: streamContext.streamRunId,\n              ...(stepId ? { stepId } : {}),\n              ...(stepTitle ? { stepTitle } : {}),\n              ...(stepIndex ? { stepIndex } : {}),\n              ...(stepTotal ? { stepTotal } : {}),\n            },\n          )\n        })\n      }\n    },\n    onComplete: () => {\n      enqueue(async () => {\n        await reportTaskProgress(job, 90, {\n          stage: 'worker_llm_complete',\n          stageLabel: 'progress.runtime.stage.llmCompleted',\n          displayMode: 'detail',\n          message: 'progress.runtime.llm.completed',\n          streamRunId: streamContext.streamRunId,\n        })\n      })\n    },\n    onError: (error) => {\n      enqueue(async () => {\n        await reportTaskProgress(job, 90, {\n          stage: 'worker_llm_error',\n          stageLabel: 'progress.runtime.stage.llmFailed',\n          displayMode: 'detail',\n          message: error instanceof Error ? error.message : String(error),\n          streamRunId: streamContext.streamRunId,\n        })\n      })\n    },\n    async flush() {\n      await publishQueue.catch(() => undefined)\n    },\n  }\n}\n\nfunction asJsonRecord(value: unknown): JsonRecord | null {\n  return typeof value === 'object' && value !== null ? (value as JsonRecord) : null\n}\n\nfunction parseJsonObjectResponse(responseText: string): JsonRecord {\n  let jsonText = responseText.trim()\n  jsonText = jsonText.replace(/^```json\\s*/i, '').replace(/^```\\s*/, '').replace(/\\s*```$/, '')\n\n  const firstBrace = jsonText.indexOf('{')\n  const lastBrace = jsonText.lastIndexOf('}')\n  if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {\n    throw new Error('JSON format invalid')\n  }\n\n  const parsed = JSON.parse(jsonText.substring(firstBrace, lastBrace + 1))\n  const record = asJsonRecord(parsed)\n  if (!record) {\n    throw new Error('JSON payload must be an object')\n  }\n  return record\n}\n\nfunction parsePanelCharacters(panel: { characters: string | null } | null | undefined): string[] {\n  if (!panel?.characters) return []\n  try {\n    const raw = JSON.parse(panel.characters)\n    if (!Array.isArray(raw)) return []\n    return raw\n      .map((item) =>\n        typeof item === 'string'\n          ? item\n          : typeof item === 'object' && item !== null && typeof (item as JsonRecord).name === 'string'\n            ? ((item as JsonRecord).name as string)\n            : '',\n      )\n      .filter(Boolean)\n  } catch {\n    return []\n  }\n}\n\nasync function runStoryboardPhasesForClip(params: {\n  clip: {\n    id: string\n    content: string | null\n    characters: string | null\n    location: string | null\n    screenplay: string | null\n  }\n  novelPromotionData: {\n    analysisModel: string\n    characters: CharacterAsset[]\n    locations: LocationAsset[]\n  }\n  projectId: string\n  projectName: string\n  userId: string\n  locale: TaskJobData['locale']\n}) {\n  const session = { user: { id: params.userId, name: 'Worker' } }\n  const phase1 = await executePhase1(\n    params.clip,\n    params.novelPromotionData,\n    session,\n    params.projectId,\n    params.projectName,\n    params.locale,\n  )\n  const [phase2, phase2Acting, phase3] = await Promise.all([\n    executePhase2(\n      params.clip,\n      phase1.planPanels || [],\n      params.novelPromotionData,\n      session,\n      params.projectId,\n      params.projectName,\n      params.locale,\n    ),\n    executePhase2Acting(\n      params.clip,\n      phase1.planPanels || [],\n      params.novelPromotionData,\n      session,\n      params.projectId,\n      params.projectName,\n      params.locale,\n    ),\n    executePhase3(\n      params.clip,\n      phase1.planPanels || [],\n      [],\n      params.novelPromotionData,\n      session,\n      params.projectId,\n      params.projectName,\n      params.locale,\n    ),\n  ])\n\n  const photographyRules: PhotographyRule[] = phase2.photographyRules || []\n  const actingDirections: ActingDirection[] = phase2Acting.actingDirections || []\n\n  const finalPanels = (phase3.finalPanels || []).map((panel, index) => {\n    const rules = photographyRules.find((r) => r.panel_number === panel.panel_number) || photographyRules[index]\n    const acting = actingDirections.find((a) => a.panel_number === panel.panel_number) || actingDirections[index]\n\n    return {\n      ...panel,\n      ...(rules\n        ? {\n          photographyPlan: {\n            composition: rules.composition,\n            lighting: rules.lighting,\n            colorPalette: rules.color_palette,\n            atmosphere: rules.atmosphere,\n            technicalNotes: rules.technical_notes,\n          },\n        }\n        : {}),\n      ...(acting?.characters ? { actingNotes: acting.characters } : {}),\n    }\n  })\n\n  return finalPanels\n}\n\nasync function handleRegenerateStoryboardTextTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const projectId = job.data.projectId\n  const storyboardId = typeof payload.storyboardId === 'string' ? payload.storyboardId : job.data.targetId\n  const userId = job.data.userId\n\n  if (!storyboardId) throw new Error('regenerate_storyboard_text requires storyboardId')\n\n  const storyboard = await prisma.novelPromotionStoryboard.findUnique({\n    where: { id: storyboardId },\n    include: { clip: true, episode: true },\n  })\n  if (!storyboard) throw new Error('Storyboard not found')\n  if (!storyboard.clip) throw new Error('Storyboard clip not found')\n\n  const project = await prisma.project.findUnique({ where: { id: projectId } })\n  if (!project) throw new Error('Project not found')\n\n  const novelPromotionData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId },\n    include: {\n      characters: { include: { appearances: { orderBy: { appearanceIndex: 'asc' } } } },\n      locations: { include: { images: { orderBy: { imageIndex: 'asc' } } } },\n    },\n  })\n  if (!novelPromotionData) throw new Error('Novel promotion data not found')\n  if (!novelPromotionData.analysisModel) throw new Error('Analysis model not configured')\n  const normalizedNovelPromotionData = {\n    ...novelPromotionData,\n    analysisModel: novelPromotionData.analysisModel,\n  }\n\n  await reportTaskProgress(job, 20, { stage: 'regenerate_storyboard_prepare', storyboardId })\n  const regenerateStreamContext = createWorkerLLMStreamContext(job, 'regenerate_storyboard')\n  const regenerateCallbacks = createWorkerLLMStreamCallbacks(job, regenerateStreamContext)\n\n  const finalPanels = await withInternalLLMStreamCallbacks(\n    regenerateCallbacks,\n    async () =>\n      await runStoryboardPhasesForClip({\n        clip: storyboard.clip,\n        novelPromotionData: normalizedNovelPromotionData,\n        projectId,\n        projectName: project.name,\n        userId,\n        locale: job.data.locale,\n      }),\n  )\n  await regenerateCallbacks.flush()\n\n  await reportTaskProgress(job, 85, { stage: 'regenerate_storyboard_persist', storyboardId })\n\n  await assertTaskActive(job, 'regenerate_storyboard_transaction')\n  await prisma.$transaction(async (tx) => {\n    await tx.novelPromotionPanel.deleteMany({ where: { storyboardId } })\n    await tx.novelPromotionStoryboard.update({\n      where: { id: storyboardId },\n      data: { panelCount: finalPanels.length, updatedAt: new Date() },\n    })\n\n    for (let i = 0; i < finalPanels.length; i++) {\n      const panel = finalPanels[i]\n      const srtRange = Array.isArray(panel.srt_range) ? panel.srt_range : []\n      const srtStart = typeof srtRange[0] === 'number' ? srtRange[0] : null\n      const srtEnd = typeof srtRange[1] === 'number' ? srtRange[1] : null\n      await tx.novelPromotionPanel.create({\n        data: {\n          storyboardId,\n          panelIndex: i,\n          panelNumber: panel.panel_number || i + 1,\n          shotType: panel.shot_type || null,\n          cameraMove: panel.camera_move || null,\n          description: panel.description || null,\n          location: panel.location || null,\n          characters: panel.characters ? JSON.stringify(panel.characters) : null,\n          srtStart,\n          srtEnd,\n          duration: panel.duration || null,\n          videoPrompt: panel.video_prompt || null,\n          sceneType: typeof panel.scene_type === 'string' ? panel.scene_type : null,\n          srtSegment: panel.source_text || null,\n          photographyRules: panel.photographyPlan ? JSON.stringify(panel.photographyPlan) : null,\n          actingNotes: panel.actingNotes ? JSON.stringify(panel.actingNotes) : null,\n        },\n      })\n    }\n  }, { timeout: 30000 })\n\n  return {\n    storyboardId,\n    panelCount: finalPanels.length,\n  }\n}\n\nasync function handleInsertPanelTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const storyboardId = typeof payload.storyboardId === 'string' ? payload.storyboardId : job.data.targetId\n  const insertAfterPanelId = typeof payload.insertAfterPanelId === 'string' ? payload.insertAfterPanelId : ''\n  const userInput = resolveInsertPanelUserInput(payload, job.data.locale)\n\n  if (!storyboardId || !insertAfterPanelId) {\n    throw new Error('insert_panel requires storyboardId/insertAfterPanelId')\n  }\n\n  const storyboard = await prisma.novelPromotionStoryboard.findUnique({\n    where: { id: storyboardId },\n    include: {\n      clip: true,\n      panels: { orderBy: { panelIndex: 'asc' } },\n    },\n  })\n  if (!storyboard) throw new Error('Storyboard not found')\n\n  const prevPanel = storyboard.panels.find((panel) => panel.id === insertAfterPanelId)\n  if (!prevPanel) throw new Error('insert_after panel not found')\n\n  const nextPanel = storyboard.panels.find((panel) => panel.panelIndex === prevPanel.panelIndex + 1)\n  const projectModels = await getProjectModelConfig(job.data.projectId, job.data.userId)\n  const analysisModel = projectModels.analysisModel\n  if (!analysisModel) throw new Error('Analysis model not configured')\n\n  const projectData = await prisma.novelPromotionProject.findUnique({\n    where: { projectId: job.data.projectId },\n    include: {\n      characters: { include: { appearances: { orderBy: { appearanceIndex: 'asc' } } } },\n      locations: { include: { images: { orderBy: { imageIndex: 'asc' } } } },\n    },\n  })\n  if (!projectData) throw new Error('Novel promotion data not found')\n\n  const prevPanelJson = JSON.stringify(\n    {\n      shot_type: prevPanel.shotType,\n      camera_move: prevPanel.cameraMove,\n      description: prevPanel.description,\n      video_prompt: prevPanel.videoPrompt,\n      location: prevPanel.location,\n      characters: prevPanel.characters ? JSON.parse(prevPanel.characters) : [],\n      source_text: prevPanel.srtSegment,\n    },\n    null,\n    2,\n  )\n\n  const nextPanelJson = nextPanel\n    ? JSON.stringify(\n      {\n        shot_type: nextPanel.shotType,\n        camera_move: nextPanel.cameraMove,\n        description: nextPanel.description,\n        video_prompt: nextPanel.videoPrompt,\n        location: nextPanel.location,\n        characters: nextPanel.characters ? JSON.parse(nextPanel.characters) : [],\n        source_text: nextPanel.srtSegment,\n      },\n      null,\n      2,\n    )\n    : '无'\n\n  const relatedCharacters = Array.from(new Set([...parsePanelCharacters(prevPanel), ...parsePanelCharacters(nextPanel)]))\n  const relatedLocations = Array.from(new Set([prevPanel.location, nextPanel?.location].filter((v): v is string => Boolean(v))))\n\n  const charactersFullDescription = (projectData.characters || [])\n    .filter((character) => relatedCharacters.length === 0 || relatedCharacters.includes(character.name))\n    .map((character) => {\n      const appearances = character.appearances || []\n      if (appearances.length === 0) return `${character.name}: 无形象信息`\n      const appearanceText = appearances\n        .map((appearance) => {\n          const descriptions = appearance.descriptions ? (() => {\n            try {\n              const parsed = JSON.parse(appearance.descriptions)\n              return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []\n            } catch {\n              return [] as string[]\n            }\n          })() : []\n          const selectedIndex = appearance.selectedIndex ?? 0\n          const selectedDescription = descriptions[selectedIndex] || appearance.description || '无描述'\n          return `${appearance.changeReason || '默认'}: ${selectedDescription}`\n        })\n        .join(' | ')\n      return `${character.name}: ${appearanceText}`\n    })\n    .join('\\n') || '无'\n\n  const locationsDescription = (projectData.locations || [])\n    .filter((location) => relatedLocations.length === 0 || relatedLocations.includes(location.name))\n    .map((location) => {\n      const images = location.images || []\n      const selectedImage = images.find((img) => img.isSelected) || images[0]\n      return `${location.name}: ${selectedImage?.description || '无描述'}`\n    })\n    .join('\\n') || '无'\n\n  const prompt = buildPrompt({\n    promptId: PROMPT_IDS.NP_AGENT_STORYBOARD_INSERT,\n    locale: job.data.locale,\n    variables: {\n      user_input: userInput,\n      prev_panel_json: prevPanelJson,\n      next_panel_json: nextPanelJson,\n      characters_full_description: charactersFullDescription,\n      locations_description: locationsDescription,\n    },\n  })\n\n  await reportTaskProgress(job, 40, { stage: 'insert_panel_generate_text' })\n  const insertPanelStreamContext = createWorkerLLMStreamContext(job, 'insert_panel')\n  const insertPanelCallbacks = createWorkerLLMStreamCallbacks(job, insertPanelStreamContext)\n\n  const completion = await withInternalLLMStreamCallbacks(\n    insertPanelCallbacks,\n    async () =>\n      await executeAiTextStep({\n        userId: job.data.userId,\n        model: analysisModel,\n        messages: [{ role: 'user', content: prompt }],\n        reasoning: true,\n        projectId: job.data.projectId,\n        action: 'insert_panel',\n        meta: {\n          stepId: 'insert_panel',\n          stepTitle: '插入分镜',\n          stepIndex: 1,\n          stepTotal: 1,\n        },\n      }),\n  )\n  await insertPanelCallbacks.flush()\n\n  const responseText = completion.text\n  if (!responseText) throw new Error('Insert panel completion empty')\n\n  const generatedPanel = parseJsonObjectResponse(responseText)\n  const generatedShotType = typeof generatedPanel.shot_type === 'string' ? generatedPanel.shot_type : null\n  const generatedCameraMove = typeof generatedPanel.camera_move === 'string' ? generatedPanel.camera_move : null\n  const generatedDescription = typeof generatedPanel.description === 'string' ? generatedPanel.description : null\n  const generatedVideoPrompt = typeof generatedPanel.video_prompt === 'string' ? generatedPanel.video_prompt : null\n  const generatedLocation = typeof generatedPanel.location === 'string' ? generatedPanel.location : null\n  const generatedSrtSegment = typeof generatedPanel.source_text === 'string' ? generatedPanel.source_text : null\n  const generatedDuration = typeof generatedPanel.duration === 'number' ? generatedPanel.duration : null\n\n  await reportTaskProgress(job, 80, { stage: 'insert_panel_persist' })\n\n  await assertTaskActive(job, 'insert_panel_transaction')\n  const newPanel = await prisma.$transaction(async (tx) => {\n    // Two-phase reindexing to avoid unique constraint collision on (storyboardId, panelIndex)\n    // Phase A: shift affected panels to negative indices to clear the positive namespace\n    const affectedPanels = await tx.novelPromotionPanel.findMany({\n      where: { storyboardId, panelIndex: { gt: prevPanel.panelIndex } },\n      select: { id: true, panelIndex: true },\n      orderBy: { panelIndex: 'asc' },\n    })\n    for (const p of affectedPanels) {\n      await tx.novelPromotionPanel.update({\n        where: { id: p.id },\n        data: { panelIndex: -(p.panelIndex + 1) },\n      })\n    }\n    // Phase B: set affected panels to their final positive indices\n    for (const p of affectedPanels) {\n      await tx.novelPromotionPanel.update({\n        where: { id: p.id },\n        data: { panelIndex: p.panelIndex + 1 },\n      })\n    }\n\n    const created = await tx.novelPromotionPanel.create({\n      data: {\n        storyboardId,\n        panelIndex: prevPanel.panelIndex + 1,\n        panelNumber: prevPanel.panelIndex + 2,\n        shotType: generatedShotType || prevPanel.shotType,\n        cameraMove: generatedCameraMove || prevPanel.cameraMove,\n        description: generatedDescription || userInput,\n        videoPrompt: generatedVideoPrompt || generatedDescription || userInput,\n        location: generatedLocation || prevPanel.location,\n        characters: generatedPanel.characters ? JSON.stringify(generatedPanel.characters) : prevPanel.characters,\n        srtSegment: generatedSrtSegment || prevPanel.srtSegment,\n        duration: generatedDuration,\n      },\n    })\n\n    await tx.novelPromotionStoryboard.update({\n      where: { id: storyboardId },\n      data: { panelCount: { increment: 1 }, updatedAt: new Date() },\n    })\n\n    return created\n  })\n\n  return {\n    storyboardId,\n    panelId: newPanel.id,\n    panelIndex: newPanel.panelIndex,\n  }\n}\n\nasync function processTextTask(job: Job<TaskJobData>) {\n  await reportTaskProgress(job, 5, { stage: 'received' })\n\n  switch (job.data.type) {\n    case TASK_TYPE.STORY_TO_SCRIPT_RUN:\n      return await handleStoryToScriptTask(job)\n    case TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN:\n      return await handleScriptToStoryboardTask(job)\n    case TASK_TYPE.VOICE_ANALYZE:\n      return await handleVoiceAnalyzeTask(job)\n    case TASK_TYPE.ANALYZE_NOVEL:\n      return await handleAnalyzeNovelTask(job)\n    case TASK_TYPE.CLIPS_BUILD:\n      return await handleClipsBuildTask(job)\n    case TASK_TYPE.SCREENPLAY_CONVERT:\n      return await handleScreenplayConvertTask(job)\n    case TASK_TYPE.EPISODE_SPLIT_LLM:\n      return await handleEpisodeSplitTask(job)\n    case TASK_TYPE.ANALYZE_GLOBAL:\n      return await handleAnalyzeGlobalTask(job)\n    case TASK_TYPE.AI_CREATE_CHARACTER:\n    case TASK_TYPE.AI_CREATE_LOCATION:\n    case TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER:\n    case TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION:\n      return await handleAssetHubAIDesignTask(job)\n    case TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER:\n    case TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION:\n      return await handleAssetHubAIModifyTask(job)\n    case TASK_TYPE.AI_MODIFY_APPEARANCE:\n    case TASK_TYPE.AI_MODIFY_LOCATION:\n    case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:\n    case TASK_TYPE.ANALYZE_SHOT_VARIANTS:\n      return await handleShotAITask(job)\n    case TASK_TYPE.CHARACTER_PROFILE_CONFIRM:\n    case TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM:\n      return await handleCharacterProfileTask(job)\n    case TASK_TYPE.REFERENCE_TO_CHARACTER:\n    case TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER:\n      return await handleReferenceToCharacterTask(job)\n    case TASK_TYPE.REGENERATE_STORYBOARD_TEXT:\n      return await handleRegenerateStoryboardTextTask(job)\n    case TASK_TYPE.INSERT_PANEL:\n      return await handleInsertPanelTask(job)\n    default:\n      throw new Error(`Unsupported text task type: ${job.data.type}`)\n  }\n}\n\nexport function createTextWorker() {\n  return new Worker<TaskJobData>(\n    QUEUE_NAME.TEXT,\n    async (job) => await withTaskLifecycle(job, processTextTask),\n    {\n      connection: queueRedis,\n      concurrency: Number.parseInt(process.env.QUEUE_CONCURRENCY_TEXT || '10', 10) || 10,\n    },\n  )\n}\n"
  },
  {
    "path": "src/lib/workers/user-concurrency-gate.ts",
    "content": "type ConcurrencyScope = 'image' | 'video'\n\ninterface GateState {\n  active: number\n  waitingResolvers: Array<() => void>\n}\n\nconst gateStateMap = new Map<string, GateState>()\n\nfunction getGateState(key: string): GateState {\n  const existing = gateStateMap.get(key)\n  if (existing) return existing\n  const created: GateState = { active: 0, waitingResolvers: [] }\n  gateStateMap.set(key, created)\n  return created\n}\n\nfunction cleanupGateStateIfIdle(key: string) {\n  const state = gateStateMap.get(key)\n  if (!state) return\n  if (state.active === 0 && state.waitingResolvers.length === 0) {\n    gateStateMap.delete(key)\n  }\n}\n\nasync function acquireSlot(key: string, limit: number): Promise<void> {\n  if (!Number.isInteger(limit) || limit <= 0) {\n    throw new Error(`WORKFLOW_CONCURRENCY_INVALID: ${limit}`)\n  }\n\n  const state = getGateState(key)\n  if (state.active < limit) {\n    state.active += 1\n    return\n  }\n\n  await new Promise<void>((resolve) => {\n    state.waitingResolvers.push(resolve)\n  })\n}\n\nfunction releaseSlot(key: string) {\n  const state = gateStateMap.get(key)\n  if (!state) return\n\n  if (state.waitingResolvers.length > 0) {\n    const nextResolver = state.waitingResolvers.shift()\n    nextResolver?.()\n    return\n  }\n\n  state.active = Math.max(0, state.active - 1)\n  cleanupGateStateIfIdle(key)\n}\n\nexport async function withUserConcurrencyGate<T>(input: {\n  scope: ConcurrencyScope\n  userId: string\n  limit: number\n  run: () => Promise<T>\n}): Promise<T> {\n  const key = `${input.scope}:${input.userId}`\n  await acquireSlot(key, input.limit)\n  try {\n    return await input.run()\n  } finally {\n    releaseSlot(key)\n  }\n}\n"
  },
  {
    "path": "src/lib/workers/utils.ts",
    "content": "import sharp from 'sharp'\nimport { type Job } from 'bullmq'\nimport { createScopedLogger } from '@/lib/logging/core'\nimport { withLogContext } from '@/lib/logging/context'\nimport { generateImage, generateVideo } from '@/lib/generator-api'\nimport { generateLipSync } from '@/lib/lipsync'\nimport { pollAsyncTask } from '@/lib/async-poll'\nimport { getSignedUrl, toFetchableUrl } from '@/lib/storage'\nimport { initializeFonts, createLabelSVG } from '@/lib/fonts'\nimport { processMediaResult } from '@/lib/media-process'\nimport {\n  getProjectModelConfig,\n  getUserModelConfig,\n  resolveProjectModelCapabilityGenerationOptions,\n} from '@/lib/config-service'\nimport { TaskTerminatedError } from '@/lib/task/errors'\nimport { isTaskActive, trySetTaskExternalId } from '@/lib/task/service'\nimport { type TaskJobData } from '@/lib/task/types'\nimport { reportTaskProgress } from './shared'\nimport { prisma } from '@/lib/prisma'\n\nconst DEFAULT_POLL_TIMEOUT_MS = Number.parseInt(process.env.WORKER_EXTERNAL_TIMEOUT_MS || String(20 * 60 * 1000), 10)\nconst DEFAULT_POLL_INTERVAL_MS = Number.parseInt(process.env.WORKER_EXTERNAL_POLL_MS || '3000', 10)\n\n/**\n * 查询 DB 中任务是否已有 externalId（服务重启后续接轮询用，避免重复提交外部 API）\n */\nasync function getTaskExistingExternalId(taskId: string): Promise<string | null> {\n  try {\n    const task = await prisma.task.findUnique({\n      where: { id: taskId },\n      select: { externalId: true },\n    })\n    const val = task?.externalId?.trim()\n    return val || null\n  } catch {\n    return null\n  }\n}\n\nfunction scopedWorkerUtilLogger(job: Job<TaskJobData>, action: string) {\n  return createScopedLogger({\n    module: 'worker.utils',\n    action,\n    requestId: job.data.trace?.requestId || undefined,\n    taskId: job.data.taskId,\n    projectId: job.data.projectId,\n    userId: job.data.userId,\n  })\n}\n\nexport function parseJsonArray(value: unknown): string[] {\n  if (!value) return []\n  if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')\n  if (typeof value !== 'string') return []\n  try {\n    const parsed = JSON.parse(value)\n    return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []\n  } catch {\n    return []\n  }\n}\n\nexport async function sleep(ms: number) {\n  await new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nexport async function assertTaskActive(job: Job<TaskJobData>, stage: string) {\n  const active = await isTaskActive(job.data.taskId)\n  if (active) return\n  throw new TaskTerminatedError(job.data.taskId, `Task terminated during ${stage}`)\n}\n\nfunction normalizeExternalId(result: {\n  async?: boolean\n  externalId?: string\n  requestId?: string\n  endpoint?: string\n}, mediaType: 'IMAGE' | 'VIDEO') {\n  if (!result.async) return null\n  const externalId = typeof result.externalId === 'string' ? result.externalId.trim() : ''\n  if (externalId) return externalId\n  throw new Error(`ASYNC_EXTERNAL_ID_MISSING: async ${mediaType} task returned without standard externalId`)\n}\n\nexport async function waitExternalResult(\n  job: Job<TaskJobData>,\n  externalId: string,\n  userId: string,\n  opts?: { timeoutMs?: number; intervalMs?: number; progressStart?: number; progressEnd?: number },\n) {\n  const timeoutMs = opts?.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS\n  const intervalMs = opts?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS\n  const progressStart = opts?.progressStart ?? 40\n  const progressEnd = opts?.progressEnd ?? 90\n  const startAt = Date.now()\n  const logger = scopedWorkerUtilLogger(job, 'worker.external.poll')\n\n  logger.info({\n    message: 'external poll started',\n    details: {\n      externalId,\n      timeoutMs,\n      intervalMs,\n    },\n  })\n\n  await trySetTaskExternalId(job.data.taskId, externalId)\n\n  while (Date.now() - startAt <= timeoutMs) {\n    await assertTaskActive(job, 'polling_external')\n    const status = await pollAsyncTask(externalId, userId)\n\n    if (status.status === 'completed') {\n      const url = status.resultUrl || status.imageUrl || status.videoUrl\n      if (!url) {\n        throw new Error(`External task completed but no result URL: ${externalId}`)\n      }\n      logger.info({\n        message: 'external poll completed',\n        durationMs: Date.now() - startAt,\n        details: {\n          externalId,\n        },\n      })\n      return { url, status, ...(status.downloadHeaders ? { downloadHeaders: status.downloadHeaders } : {}) }\n    }\n\n    if (status.status === 'failed') {\n      logger.error({\n        message: status.error || 'external task failed',\n        errorCode: 'EXTERNAL_ERROR',\n        retryable: true,\n        durationMs: Date.now() - startAt,\n        details: {\n          externalId,\n        },\n      })\n      throw new Error(status.error || `External task failed: ${externalId}`)\n    }\n\n    const elapsed = Date.now() - startAt\n    const ratio = Math.max(0, Math.min(1, elapsed / timeoutMs))\n    const progress = progressStart + Math.floor((progressEnd - progressStart) * ratio)\n    await reportTaskProgress(job, progress, { stage: 'polling_external', externalId })\n    await assertTaskActive(job, 'polling_external_wait')\n    await sleep(intervalMs)\n  }\n\n  logger.error({\n    message: 'external task polling timeout',\n    errorCode: 'GENERATION_TIMEOUT',\n    retryable: true,\n    durationMs: Date.now() - startAt,\n    details: {\n      externalId,\n      timeoutMs,\n    },\n  })\n  throw new Error(`External task polling timeout (${Math.round(timeoutMs / 1000)}s): ${externalId}`)\n}\n\nexport async function resolveImageSourceFromGeneration(\n  job: Job<TaskJobData>,\n  params: {\n    userId: string\n    modelId: string\n    prompt: string\n    options?: {\n      referenceImages?: string[]\n      aspectRatio?: string\n      resolution?: string\n      size?: string\n      provider?: string\n    }\n    allowTaskExternalIdResume?: boolean\n    pollProgress?: { start?: number; end?: number }\n  },\n): Promise<string> {\n  const logger = scopedWorkerUtilLogger(job, 'worker.image.generate_source')\n  const startedAt = Date.now()\n  const allowTaskExternalIdResume = params.allowTaskExternalIdResume !== false\n\n  // 服务重启续接：若 DB 中已有 externalId，直接恢复轮询，不重新提交外部 API\n  if (allowTaskExternalIdResume) {\n    const resumeExternalId = await getTaskExistingExternalId(job.data.taskId)\n    if (resumeExternalId) {\n      logger.info({\n        message: 'image source generation resumed from existing external id',\n        details: { externalId: resumeExternalId },\n      })\n      const polled = await waitExternalResult(job, resumeExternalId, params.userId, {\n        progressStart: params.pollProgress?.start ?? 40,\n        progressEnd: params.pollProgress?.end ?? 92,\n      })\n      return polled.url\n    }\n  }\n\n  logger.info({\n    message: 'image source generation started',\n    provider: params.options?.provider || undefined,\n    details: {\n      model: params.modelId,\n    },\n  })\n\n  const runtimeSelections: Record<string, string | number | boolean> = {}\n  if (typeof params.options?.resolution === 'string') {\n    runtimeSelections.resolution = params.options.resolution\n  }\n\n  const capabilityOptions = await resolveProjectModelCapabilityGenerationOptions({\n    projectId: job.data.projectId,\n    userId: params.userId,\n    modelType: 'image',\n    modelKey: params.modelId,\n    runtimeSelections,\n  })\n\n  logger.info({\n    message: 'image source generation calling generateImage',\n    details: {\n      model: params.modelId,\n      referenceImageCount: params.options?.referenceImages?.length ?? 0,\n      capabilityOptions,\n      optionKeys: Object.keys(params.options || {}),\n    },\n  })\n\n  const result = await withLogContext(\n    { projectId: job.data.projectId, taskId: job.data.taskId, userId: params.userId },\n    () => generateImage(params.userId, params.modelId, params.prompt, {\n      ...params.options,\n      ...capabilityOptions,\n    }),\n  )\n  if (!result.success) {\n    throw new Error(result.error || 'Image generation failed')\n  }\n\n  if (result.imageUrl) {\n    logger.info({\n      message: 'image source generation completed',\n      provider: params.options?.provider || undefined,\n      durationMs: Date.now() - startedAt,\n    })\n    return result.imageUrl\n  }\n  if (result.imageBase64) {\n    logger.info({\n      message: 'image source generation completed (base64)',\n      provider: params.options?.provider || undefined,\n      durationMs: Date.now() - startedAt,\n    })\n    return `data:image/png;base64,${result.imageBase64}`\n  }\n\n  const externalId = normalizeExternalId(result, 'IMAGE')\n  if (!externalId) {\n    throw new Error('Image generation returned no image and no external id')\n  }\n\n  const polled = await waitExternalResult(job, externalId, params.userId, {\n    progressStart: params.pollProgress?.start ?? 40,\n    progressEnd: params.pollProgress?.end ?? 92,\n  })\n  logger.info({\n    message: 'image source generation completed (async)',\n    provider: params.options?.provider || undefined,\n    durationMs: Date.now() - startedAt,\n    details: {\n      externalId,\n    },\n  })\n  return polled.url\n}\n\n/**\n * 多图版本：一次生成调用返回所有图片 URL 数组。\n *\n * - 接口返回多张（result.imageUrls）→ 返回完整列表\n * - 接口只返回单张（result.imageUrl / result.imageBase64）→ 封装成 [url] 保持接口一致\n * - 异步任务：轮询结果只有一个 URL，封装成 [url]\n *\n * 现有代码请继续使用 resolveImageSourceFromGeneration（取第一张），\n * 只有需要利用多图结果时才调用此函数。\n */\nexport async function resolveImageSourcesFromGeneration(\n  job: Job<TaskJobData>,\n  params: {\n    userId: string\n    modelId: string\n    prompt: string\n    options?: {\n      referenceImages?: string[]\n      aspectRatio?: string\n      resolution?: string\n      size?: string\n      provider?: string\n    }\n    allowTaskExternalIdResume?: boolean\n    pollProgress?: { start?: number; end?: number }\n  },\n): Promise<string[]> {\n  const logger = scopedWorkerUtilLogger(job, 'worker.image.generate_sources')\n  const startedAt = Date.now()\n  const allowTaskExternalIdResume = params.allowTaskExternalIdResume !== false\n\n  // 服务重启续接：若 DB 中已有 externalId，直接恢复轮询（异步只有一张）\n  if (allowTaskExternalIdResume) {\n    const resumeExternalId = await getTaskExistingExternalId(job.data.taskId)\n    if (resumeExternalId) {\n      logger.info({\n        message: 'image sources generation resumed from existing external id',\n        details: { externalId: resumeExternalId },\n      })\n      const polled = await waitExternalResult(job, resumeExternalId, params.userId, {\n        progressStart: params.pollProgress?.start ?? 40,\n        progressEnd: params.pollProgress?.end ?? 92,\n      })\n      return [polled.url]\n    }\n  }\n\n  logger.info({\n    message: 'image sources generation started',\n    provider: params.options?.provider || undefined,\n    details: { model: params.modelId },\n  })\n\n  const runtimeSelections: Record<string, string | number | boolean> = {}\n  if (typeof params.options?.resolution === 'string') {\n    runtimeSelections.resolution = params.options.resolution\n  }\n\n  const capabilityOptions = await resolveProjectModelCapabilityGenerationOptions({\n    projectId: job.data.projectId,\n    userId: params.userId,\n    modelType: 'image',\n    modelKey: params.modelId,\n    runtimeSelections,\n  })\n\n  const result = await withLogContext(\n    { projectId: job.data.projectId, taskId: job.data.taskId, userId: params.userId },\n    () => generateImage(params.userId, params.modelId, params.prompt, {\n      ...params.options,\n      ...capabilityOptions,\n    }),\n  )\n  if (!result.success) {\n    throw new Error(result.error || 'Image generation failed')\n  }\n\n  // 优先使用多图列表\n  if (result.imageUrls && result.imageUrls.length > 0) {\n    logger.info({\n      message: 'image sources generation completed (multi-image)',\n      provider: params.options?.provider || undefined,\n      durationMs: Date.now() - startedAt,\n      details: { count: result.imageUrls.length },\n    })\n    return result.imageUrls\n  }\n\n  if (result.imageUrl) {\n    logger.info({\n      message: 'image sources generation completed (single url)',\n      provider: params.options?.provider || undefined,\n      durationMs: Date.now() - startedAt,\n    })\n    return [result.imageUrl]\n  }\n\n  if (result.imageBase64) {\n    logger.info({\n      message: 'image sources generation completed (base64)',\n      provider: params.options?.provider || undefined,\n      durationMs: Date.now() - startedAt,\n    })\n    return [`data:image/png;base64,${result.imageBase64}`]\n  }\n\n  const externalId = normalizeExternalId(result, 'IMAGE')\n  if (!externalId) {\n    throw new Error('Image generation returned no image and no external id')\n  }\n\n  const polled = await waitExternalResult(job, externalId, params.userId, {\n    progressStart: params.pollProgress?.start ?? 40,\n    progressEnd: params.pollProgress?.end ?? 92,\n  })\n  logger.info({\n    message: 'image sources generation completed (async)',\n    provider: params.options?.provider || undefined,\n    durationMs: Date.now() - startedAt,\n    details: { externalId },\n  })\n  return [polled.url]\n}\n\nexport async function resolveVideoSourceFromGeneration(\n  job: Job<TaskJobData>,\n  params: {\n    userId: string\n    modelId: string\n    imageUrl: string\n    options?: {\n      prompt?: string\n      duration?: number\n      fps?: number\n      resolution?: string\n      aspectRatio?: string\n      generateAudio?: boolean\n      lastFrameImageUrl?: string\n      generationMode?: 'normal' | 'firstlastframe'\n      [key: string]: string | number | boolean | undefined\n    }\n    pollProgress?: { start?: number; end?: number }\n  },\n): Promise<{ url: string; downloadHeaders?: Record<string, string> }> {\n  const logger = scopedWorkerUtilLogger(job, 'worker.video.generate_source')\n  const startedAt = Date.now()\n\n  // 服务重启续接：若 DB 中已有 externalId，直接恢复轮询，不重新提交外部 API（避免重复扣费）\n  const resumeExternalId = await getTaskExistingExternalId(job.data.taskId)\n  if (resumeExternalId) {\n    logger.info({\n      message: 'video source generation resumed from existing external id',\n      details: { externalId: resumeExternalId, model: params.modelId },\n    })\n    const polled = await waitExternalResult(job, resumeExternalId, params.userId, {\n      progressStart: params.pollProgress?.start ?? 45,\n      progressEnd: params.pollProgress?.end ?? 94,\n    })\n    logger.info({\n      message: 'video source generation completed (resumed)',\n      durationMs: Date.now() - startedAt,\n      details: { externalId: resumeExternalId },\n    })\n    return {\n      url: polled.url,\n      ...(polled.downloadHeaders ? { downloadHeaders: polled.downloadHeaders } : {}),\n    }\n  }\n\n  logger.info({\n    message: 'video source generation started',\n    details: {\n      model: params.modelId,\n    },\n  })\n\n  const runtimeSelections: Record<string, string | number | boolean> = {}\n  if (typeof params.options?.duration === 'number') {\n    runtimeSelections.duration = params.options.duration\n  }\n  if (typeof params.options?.resolution === 'string') {\n    runtimeSelections.resolution = params.options.resolution\n  }\n  if (\n    params.options?.generationMode === 'normal'\n    || params.options?.generationMode === 'firstlastframe'\n  ) {\n    runtimeSelections.generationMode = params.options.generationMode\n  }\n  if (typeof params.options?.generateAudio === 'boolean') {\n    runtimeSelections.generateAudio = params.options.generateAudio\n  }\n\n  const capabilityOptions = await resolveProjectModelCapabilityGenerationOptions({\n    projectId: job.data.projectId,\n    userId: params.userId,\n    modelType: 'video',\n    modelKey: params.modelId,\n    runtimeSelections,\n  })\n\n  const providerCapabilityOptions: Record<string, string | number | boolean> = { ...capabilityOptions }\n  delete providerCapabilityOptions.generationMode\n  const providerRequestOptions: Record<string, string | number | boolean> = {}\n  for (const [key, value] of Object.entries(params.options || {})) {\n    if (key === 'generationMode' || value === undefined) continue\n    providerRequestOptions[key] = value\n  }\n\n  const result = await withLogContext(\n    { projectId: job.data.projectId, taskId: job.data.taskId, userId: params.userId },\n    () => generateVideo(params.userId, params.modelId, params.imageUrl, {\n      ...providerRequestOptions,\n      ...providerCapabilityOptions,\n    }),\n  )\n  if (!result.success) {\n    throw new Error(result.error || 'Video generation failed')\n  }\n\n  if (result.videoUrl) {\n    logger.info({\n      message: 'video source generation completed',\n      durationMs: Date.now() - startedAt,\n    })\n    return { url: result.videoUrl }\n  }\n\n  const externalId = normalizeExternalId(result, 'VIDEO')\n  if (!externalId) {\n    throw new Error('Video generation returned no video and no external id')\n  }\n\n  const polled = await waitExternalResult(job, externalId, params.userId, {\n    progressStart: params.pollProgress?.start ?? 45,\n    progressEnd: params.pollProgress?.end ?? 94,\n  })\n  logger.info({\n    message: 'video source generation completed (async)',\n    durationMs: Date.now() - startedAt,\n    details: {\n      externalId,\n    },\n  })\n  return {\n    url: polled.url,\n    ...(polled.downloadHeaders ? { downloadHeaders: polled.downloadHeaders } : {}),\n  }\n}\n\nexport async function resolveLipSyncVideoSource(\n  job: Job<TaskJobData>,\n  params: {\n    userId: string\n    videoUrl: string\n    audioUrl: string\n    audioDurationMs?: number | null\n    videoDurationMs?: number | null\n    modelKey?: string\n    pollProgress?: { start?: number; end?: number }\n  },\n): Promise<string> {\n  const logger = scopedWorkerUtilLogger(job, 'worker.video.lip_sync')\n  const startedAt = Date.now()\n\n  // 服务重启续接：若 DB 中已有 externalId，直接恢复轮询，不重新提交（避免重复扣费）\n  const resumeExternalId = await getTaskExistingExternalId(job.data.taskId)\n  if (resumeExternalId) {\n    logger.info({\n      message: 'lip sync generation resumed from existing external id',\n      details: { externalId: resumeExternalId },\n    })\n    const polled = await waitExternalResult(job, resumeExternalId, params.userId, {\n      progressStart: params.pollProgress?.start ?? 45,\n      progressEnd: params.pollProgress?.end ?? 94,\n    })\n    logger.info({\n      message: 'lip sync generation completed (resumed)',\n      durationMs: Date.now() - startedAt,\n      details: { externalId: resumeExternalId },\n    })\n    return polled.url\n  }\n\n  logger.info({\n    message: 'lip sync generation started',\n  })\n\n  const result = await generateLipSync(\n    {\n      videoUrl: params.videoUrl,\n      audioUrl: params.audioUrl,\n      audioDurationMs: params.audioDurationMs,\n      videoDurationMs: params.videoDurationMs,\n    },\n    params.userId,\n    params.modelKey,\n  )\n\n  if (!result.requestId) {\n    throw new Error('Lip sync request id missing')\n  }\n\n  const externalId = typeof result.externalId === 'string'\n    ? result.externalId.trim()\n    : ''\n  if (!externalId) {\n    throw new Error('Lip sync external id missing')\n  }\n\n  const polled = await waitExternalResult(job, externalId, params.userId, {\n    progressStart: params.pollProgress?.start ?? 45,\n    progressEnd: params.pollProgress?.end ?? 94,\n  })\n\n  logger.info({\n    message: 'lip sync generation completed',\n    durationMs: Date.now() - startedAt,\n    details: {\n      externalId,\n    },\n  })\n\n  return polled.url\n}\n\n/**\n * 裁掉图片顶部的黑边标签区域，返回纯净内容的 base64 data URL\n * 用于改图前去除旧黑边，避免 AI 参考图携带黑边导致叠加\n */\nexport async function stripLabelBar(imageSource: string): Promise<string> {\n  const response = await fetch(toFetchableUrl(imageSource))\n  if (!response.ok) {\n    throw new Error(`Failed to download image for strip: ${response.status}`)\n  }\n  const raw = Buffer.from(await response.arrayBuffer())\n  const meta = await sharp(raw).metadata()\n  const w = meta.width || 2160\n  const h = meta.height || 2160\n  const fontSize = Math.floor(h * 0.04)\n  const pad = Math.floor(fontSize * 0.5)\n  const barH = fontSize + pad * 2\n\n  const cropped = await sharp(raw)\n    .extract({ left: 0, top: barH, width: w, height: h - barH })\n    .jpeg({ quality: 95, mozjpeg: true })\n    .toBuffer()\n\n  return `data:image/jpeg;base64,${cropped.toString('base64')}`\n}\n\nexport async function withLabelBar(imageSource: string, labelText: string): Promise<Buffer> {\n  await initializeFonts()\n\n  const response = await fetch(toFetchableUrl(imageSource))\n  if (!response.ok) {\n    throw new Error(`Failed to download image: ${response.status}`)\n  }\n\n  const raw = Buffer.from(await response.arrayBuffer())\n  const meta = await sharp(raw).metadata()\n  const width = meta.width || 2160\n  const height = meta.height || 2160\n  const fontSize = Math.floor(height * 0.04)\n  const pad = Math.floor(fontSize * 0.5)\n  const barHeight = fontSize + pad * 2\n  const svg = await createLabelSVG(width, barHeight, fontSize, pad, labelText)\n\n  return await sharp(raw)\n    .extend({ top: barHeight, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })\n    .composite([{ input: svg, top: 0, left: 0 }])\n    .jpeg({ quality: 90, mozjpeg: true })\n    .toBuffer()\n}\n\nexport async function uploadImageSourceToCos(source: string | Buffer, keyPrefix: string, targetId: string) {\n  return await processMediaResult({\n    source,\n    type: 'image',\n    keyPrefix,\n    targetId,\n  })\n}\n\nexport async function uploadVideoSourceToCos(\n  source: string | Buffer,\n  keyPrefix: string,\n  targetId: string,\n  downloadHeaders?: Record<string, string>,\n) {\n  return await processMediaResult({\n    source,\n    type: 'video',\n    keyPrefix,\n    targetId,\n    downloadHeaders,\n  })\n}\n\nexport async function uploadAudioSourceToCos(source: string | Buffer, keyPrefix: string, targetId: string) {\n  return await processMediaResult({\n    source,\n    type: 'audio',\n    keyPrefix,\n    targetId,\n  })\n}\n\nexport function toSignedUrlIfCos(keyOrUrl: string | null | undefined, ttlSeconds = 3600) {\n  if (!keyOrUrl) return null\n  return keyOrUrl.startsWith('images/') || keyOrUrl.startsWith('voice/') || keyOrUrl.startsWith('video/')\n    ? getSignedUrl(keyOrUrl, ttlSeconds)\n    : keyOrUrl\n}\n\nexport async function getProjectModels(projectId: string, userId: string) {\n  return await getProjectModelConfig(projectId, userId)\n}\n\nexport async function getUserModels(userId: string) {\n  return await getUserModelConfig(userId)\n}\n"
  },
  {
    "path": "src/lib/workers/video.worker.ts",
    "content": "import { Worker, type Job } from 'bullmq'\nimport { prisma } from '@/lib/prisma'\nimport { queueRedis } from '@/lib/redis'\nimport { QUEUE_NAME } from '@/lib/task/queues'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\nimport { getUserWorkflowConcurrencyConfig } from '@/lib/config-service'\nimport { reportTaskProgress, withTaskLifecycle } from './shared'\nimport { withUserConcurrencyGate } from './user-concurrency-gate'\nimport {\n  assertTaskActive,\n  getProjectModels,\n  resolveLipSyncVideoSource,\n  resolveVideoSourceFromGeneration,\n  toSignedUrlIfCos,\n  uploadVideoSourceToCos,\n} from './utils'\nimport { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'\nimport { resolveBuiltinCapabilitiesByModelKey } from '@/lib/model-capabilities/lookup'\nimport { parseModelKeyStrict } from '@/lib/model-config-contract'\nimport { getProviderConfig } from '@/lib/api-config'\n\ntype AnyObj = Record<string, unknown>\ntype VideoOptionValue = string | number | boolean\ntype VideoOptionMap = Record<string, VideoOptionValue>\ntype VideoGenerationMode = 'normal' | 'firstlastframe'\ntype PanelRecord = NonNullable<Awaited<ReturnType<typeof prisma.novelPromotionPanel.findUnique>>>\n\nfunction toDurationMs(value: number | null | undefined): number | undefined {\n  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined\n  return value > 1000 ? Math.round(value) : Math.round(value * 1000)\n}\n\nfunction extractGenerationOptions(payload: AnyObj): VideoOptionMap {\n  const fromEnvelope = payload.generationOptions\n  if (!fromEnvelope || typeof fromEnvelope !== 'object' || Array.isArray(fromEnvelope)) {\n    return {}\n  }\n\n  const next: VideoOptionMap = {}\n  for (const [key, value] of Object.entries(fromEnvelope as Record<string, unknown>)) {\n    if (key === 'aspectRatio') continue\n    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {\n      next[key] = value\n    }\n  }\n  return next\n}\n\nasync function fetchPanelByStoryboardIndex(storyboardId: string, panelIndex: number) {\n  return await prisma.novelPromotionPanel.findFirst({\n    where: {\n      storyboardId,\n      panelIndex,\n    },\n  })\n}\n\nasync function getPanelForVideoTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n\n  // 优先使用 targetType=NovelPromotionPanel 直接定位\n  if (job.data.targetType === 'NovelPromotionPanel') {\n    const panel = await prisma.novelPromotionPanel.findUnique({ where: { id: job.data.targetId } })\n    if (!panel) throw new Error('Panel not found')\n    return panel\n  }\n\n  // 兜底：通过 storyboardId + panelIndex 定位\n  const storyboardId = payload.storyboardId\n  const panelIndex = payload.panelIndex\n  if (typeof storyboardId !== 'string' || !storyboardId || panelIndex === undefined || panelIndex === null) {\n    throw new Error('Missing storyboardId/panelIndex for video task')\n  }\n\n  const panel = await fetchPanelByStoryboardIndex(storyboardId, Number(panelIndex))\n  if (!panel) throw new Error('Panel not found by storyboardId/panelIndex')\n  return panel\n}\n\nasync function generateVideoForPanel(\n  job: Job<TaskJobData>,\n  panel: PanelRecord,\n  payload: AnyObj,\n  modelId: string,\n  projectVideoRatio: string | null | undefined,\n  generationOptions: VideoOptionMap,\n): Promise<{ cosKey: string; generationMode: VideoGenerationMode }> {\n  if (!panel.imageUrl) {\n    throw new Error(`Panel ${panel.id} has no imageUrl`)\n  }\n\n  const firstLastFramePayload =\n    typeof payload.firstLastFrame === 'object' && payload.firstLastFrame !== null\n      ? (payload.firstLastFrame as AnyObj)\n      : null\n  const firstLastCustomPrompt = typeof firstLastFramePayload?.customPrompt === 'string' ? firstLastFramePayload.customPrompt : null\n  const persistedFirstLastPrompt = firstLastFramePayload ? panel.firstLastFramePrompt : null\n  const customPrompt = typeof payload.customPrompt === 'string' ? payload.customPrompt : null\n  const prompt = firstLastCustomPrompt || persistedFirstLastPrompt || customPrompt || panel.videoPrompt || panel.description\n  if (!prompt) {\n    throw new Error(`Panel ${panel.id} has no video prompt`)\n  }\n\n  const sourceImageUrl = toSignedUrlIfCos(panel.imageUrl, 3600)\n  if (!sourceImageUrl) {\n    throw new Error(`Panel ${panel.id} image url invalid`)\n  }\n  const sourceImageBase64 = await normalizeToBase64ForGeneration(sourceImageUrl)\n\n  let lastFrameImageBase64: string | undefined\n  const generationMode: VideoGenerationMode = firstLastFramePayload ? 'firstlastframe' : 'normal'\n  const requestedGenerateAudio = typeof generationOptions.generateAudio === 'boolean'\n    ? generationOptions.generateAudio\n    : undefined\n  let model = modelId\n\n  if (firstLastFramePayload) {\n    model =\n      typeof firstLastFramePayload.flModel === 'string' && firstLastFramePayload.flModel\n        ? firstLastFramePayload.flModel\n        : modelId\n    const firstLastFrameCapabilities = resolveBuiltinCapabilitiesByModelKey('video', model)\n    if (firstLastFrameCapabilities?.video?.firstlastframe !== true) {\n      throw new Error(`VIDEO_FIRSTLASTFRAME_MODEL_UNSUPPORTED: ${model}`)\n    }\n    if (\n      typeof firstLastFramePayload.lastFrameStoryboardId === 'string' &&\n      firstLastFramePayload.lastFrameStoryboardId &&\n      firstLastFramePayload.lastFramePanelIndex !== undefined\n    ) {\n      const lastPanel = await fetchPanelByStoryboardIndex(\n        firstLastFramePayload.lastFrameStoryboardId,\n        Number(firstLastFramePayload.lastFramePanelIndex),\n      )\n      if (lastPanel?.imageUrl) {\n        const lastFrameUrl = toSignedUrlIfCos(lastPanel.imageUrl, 3600)\n        if (lastFrameUrl) {\n          lastFrameImageBase64 = await normalizeToBase64ForGeneration(lastFrameUrl)\n        }\n      }\n    }\n  }\n\n  const generatedVideo = await resolveVideoSourceFromGeneration(job, {\n    userId: job.data.userId,\n    modelId: model,\n    imageUrl: sourceImageBase64,\n    options: {\n      prompt,\n      ...(projectVideoRatio ? { aspectRatio: projectVideoRatio } : {}),\n      ...generationOptions,\n      generationMode,\n      ...(typeof requestedGenerateAudio === 'boolean' ? { generateAudio: requestedGenerateAudio } : {}),\n      ...(lastFrameImageBase64 ? { lastFrameImageUrl: lastFrameImageBase64 } : {}),\n    },\n  })\n\n  let downloadHeaders: Record<string, string> | undefined\n  const videoSource = generatedVideo.url\n  if (generatedVideo.downloadHeaders) {\n    downloadHeaders = generatedVideo.downloadHeaders\n  } else if (typeof videoSource === 'string') {\n    const parsedModel = parseModelKeyStrict(model)\n    const isGoogleDownloadUrl = videoSource.includes('generativelanguage.googleapis.com/')\n      && videoSource.includes('/files/')\n      && videoSource.includes(':download')\n    if (parsedModel?.provider === 'google' && isGoogleDownloadUrl) {\n      const { apiKey } = await getProviderConfig(job.data.userId, 'google')\n      downloadHeaders = { 'x-goog-api-key': apiKey }\n    }\n  }\n\n  const cosKey = await uploadVideoSourceToCos(videoSource, 'panel-video', panel.id, downloadHeaders)\n  return { cosKey, generationMode }\n}\n\nasync function handleVideoPanelTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const projectModels = await getProjectModels(job.data.projectId, job.data.userId)\n\n  const modelId = typeof payload.videoModel === 'string' ? payload.videoModel.trim() : ''\n  if (!modelId) throw new Error('VIDEO_MODEL_REQUIRED: payload.videoModel is required')\n\n  const panel = await getPanelForVideoTask(job)\n\n  const generationOptions = extractGenerationOptions(payload)\n\n  await reportTaskProgress(job, 10, {\n    stage: 'generate_panel_video',\n    panelId: panel.id,\n  })\n\n  const { cosKey, generationMode } = await generateVideoForPanel(\n    job,\n    panel,\n    payload,\n    modelId,\n    projectModels.videoRatio,\n    generationOptions,\n  )\n\n  await assertTaskActive(job, 'persist_panel_video')\n  await prisma.novelPromotionPanel.update({\n    where: { id: panel.id },\n    data: {\n      videoUrl: cosKey,\n      videoGenerationMode: generationMode,\n    },\n  })\n\n  return {\n    panelId: panel.id,\n    videoUrl: cosKey,\n  }\n}\n\nasync function handleLipSyncTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const lipSyncModel = typeof payload.lipSyncModel === 'string' && payload.lipSyncModel.trim()\n    ? payload.lipSyncModel.trim()\n    : undefined\n\n  let panel: PanelRecord | null = null\n  if (job.data.targetType === 'NovelPromotionPanel') {\n    panel = await prisma.novelPromotionPanel.findUnique({ where: { id: job.data.targetId } })\n  }\n\n  if (\n    !panel &&\n    typeof payload.storyboardId === 'string' &&\n    payload.storyboardId &&\n    payload.panelIndex !== undefined\n  ) {\n    panel = await fetchPanelByStoryboardIndex(payload.storyboardId, Number(payload.panelIndex))\n  }\n\n  if (!panel) throw new Error('Lip-sync panel not found')\n  if (!panel.videoUrl) throw new Error('Panel has no base video')\n\n  const voiceLineId = typeof payload.voiceLineId === 'string' ? payload.voiceLineId : null\n  if (!voiceLineId) throw new Error('Lip-sync task missing voiceLineId')\n\n  const voiceLine = await prisma.novelPromotionVoiceLine.findUnique({ where: { id: voiceLineId } })\n  if (!voiceLine || !voiceLine.audioUrl) {\n    throw new Error('Voice line or audioUrl not found')\n  }\n\n  const signedVideoUrl = toSignedUrlIfCos(panel.videoUrl, 7200)\n  const signedAudioUrl = toSignedUrlIfCos(voiceLine.audioUrl, 7200)\n\n  if (!signedVideoUrl || !signedAudioUrl) {\n    throw new Error('Lip-sync input media url invalid')\n  }\n\n  await reportTaskProgress(job, 25, { stage: 'submit_lip_sync' })\n\n  const source = await resolveLipSyncVideoSource(job, {\n    userId: job.data.userId,\n    videoUrl: signedVideoUrl,\n    audioUrl: signedAudioUrl,\n    audioDurationMs: typeof voiceLine.audioDuration === 'number' ? voiceLine.audioDuration : undefined,\n    videoDurationMs: toDurationMs(panel.duration),\n    modelKey: lipSyncModel,\n  })\n\n  await reportTaskProgress(job, 93, { stage: 'persist_lip_sync' })\n\n  const cosKey = await uploadVideoSourceToCos(source, 'lip-sync', panel.id)\n\n  await assertTaskActive(job, 'persist_lip_sync_video')\n  await prisma.novelPromotionPanel.update({\n    where: { id: panel.id },\n    data: {\n      lipSyncVideoUrl: cosKey,\n      lipSyncTaskId: null,\n    },\n  })\n\n  return {\n    panelId: panel.id,\n    voiceLineId,\n    lipSyncVideoUrl: cosKey,\n  }\n}\n\nasync function processVideoTask(job: Job<TaskJobData>) {\n  await reportTaskProgress(job, 5, { stage: 'received' })\n\n  switch (job.data.type) {\n    case TASK_TYPE.VIDEO_PANEL:\n      return await handleVideoPanelTask(job)\n    case TASK_TYPE.LIP_SYNC:\n      return await handleLipSyncTask(job)\n    default:\n      throw new Error(`Unsupported video task type: ${job.data.type}`)\n  }\n}\n\nexport function createVideoWorker() {\n  return new Worker<TaskJobData>(\n    QUEUE_NAME.VIDEO,\n    async (job) => await withTaskLifecycle(job, async (taskJob) => {\n      const workflowConcurrency = await getUserWorkflowConcurrencyConfig(taskJob.data.userId)\n      return await withUserConcurrencyGate({\n        scope: 'video',\n        userId: taskJob.data.userId,\n        limit: workflowConcurrency.video,\n        run: async () => await processVideoTask(taskJob),\n      })\n    }),\n    {\n      connection: queueRedis,\n      concurrency: Number.parseInt(process.env.QUEUE_CONCURRENCY_VIDEO || '4', 10) || 4,\n    },\n  )\n}\n"
  },
  {
    "path": "src/lib/workers/voice.worker.ts",
    "content": "import { Worker, type Job } from 'bullmq'\nimport { queueRedis } from '@/lib/redis'\nimport { generateVoiceLine } from '@/lib/voice/generate-voice-line'\nimport { QUEUE_NAME } from '@/lib/task/queues'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\nimport { reportTaskProgress, withTaskLifecycle } from './shared'\nimport { handleVoiceDesignTask } from './handlers/voice-design'\n\ntype AnyObj = Record<string, unknown>\n\nasync function handleVoiceLineTask(job: Job<TaskJobData>) {\n  const payload = (job.data.payload || {}) as AnyObj\n  const lineId = typeof payload.lineId === 'string' ? payload.lineId : job.data.targetId\n  const episodeId = typeof payload.episodeId === 'string' ? payload.episodeId : job.data.episodeId\n  const audioModel = typeof payload.audioModel === 'string' && payload.audioModel.trim()\n    ? payload.audioModel.trim()\n    : undefined\n  if (!lineId) {\n    throw new Error('VOICE_LINE task missing lineId')\n  }\n  if (!episodeId) {\n    throw new Error('VOICE_LINE task missing episodeId')\n  }\n\n  await reportTaskProgress(job, 20, { stage: 'generate_voice_submit', lineId })\n\n  const generated = await generateVoiceLine({\n    projectId: job.data.projectId,\n    episodeId,\n    lineId,\n    userId: job.data.userId,\n    audioModel,\n  })\n\n  await reportTaskProgress(job, 95, { stage: 'generate_voice_persist', lineId })\n\n  return generated\n}\n\nasync function processVoiceTask(job: Job<TaskJobData>) {\n  await reportTaskProgress(job, 5, { stage: 'received' })\n\n  switch (job.data.type) {\n    case TASK_TYPE.VOICE_LINE:\n      return await handleVoiceLineTask(job)\n    case TASK_TYPE.VOICE_DESIGN:\n    case TASK_TYPE.ASSET_HUB_VOICE_DESIGN:\n      return await handleVoiceDesignTask(job)\n    default:\n      throw new Error(`Unsupported voice task type: ${job.data.type}`)\n  }\n}\n\nexport function createVoiceWorker() {\n  return new Worker<TaskJobData>(\n    QUEUE_NAME.VOICE,\n    async (job) => await withTaskLifecycle(job, processVoiceTask),\n    {\n      connection: queueRedis,\n      concurrency: Number.parseInt(process.env.QUEUE_CONCURRENCY_VOICE || '10', 10) || 10,\n    },\n  )\n}\n"
  },
  {
    "path": "src/lib/workflow-concurrency.ts",
    "content": "export const DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY = 5\nexport const DEFAULT_IMAGE_WORKFLOW_CONCURRENCY = 5\nexport const DEFAULT_VIDEO_WORKFLOW_CONCURRENCY = 5\n\nexport interface WorkflowConcurrencyConfig {\n  analysis: number\n  image: number\n  video: number\n}\n\nfunction toPositiveInt(value: unknown): number | null {\n  if (typeof value !== 'number') return null\n  if (!Number.isFinite(value)) return null\n  const normalized = Math.floor(value)\n  if (normalized <= 0) return null\n  return normalized\n}\n\nexport function normalizeWorkflowConcurrencyValue(value: unknown, fallback: number): number {\n  const normalized = toPositiveInt(value)\n  if (normalized === null) return fallback\n  return normalized\n}\n\nexport function normalizeWorkflowConcurrencyConfig(\n  value: Partial<Record<keyof WorkflowConcurrencyConfig, unknown>> | null | undefined,\n): WorkflowConcurrencyConfig {\n  return {\n    analysis: normalizeWorkflowConcurrencyValue(\n      value?.analysis,\n      DEFAULT_ANALYSIS_WORKFLOW_CONCURRENCY,\n    ),\n    image: normalizeWorkflowConcurrencyValue(\n      value?.image,\n      DEFAULT_IMAGE_WORKFLOW_CONCURRENCY,\n    ),\n    video: normalizeWorkflowConcurrencyValue(\n      value?.video,\n      DEFAULT_VIDEO_WORKFLOW_CONCURRENCY,\n    ),\n  }\n}\n"
  },
  {
    "path": "src/lib/workflow-engine/dependencies.ts",
    "content": "const STORY_TO_SCRIPT_WORKFLOW = 'story_to_script_run'\nconst SCRIPT_TO_STORYBOARD_WORKFLOW = 'script_to_storyboard_run'\n\nfunction uniqueStepKeys(stepKeys: Iterable<string>): string[] {\n  return Array.from(new Set(Array.from(stepKeys).filter((stepKey) => stepKey.trim().length > 0)))\n}\n\nfunction resolveStoryToScriptInvalidation(params: {\n  stepKey: string\n  existingStepKeys: ReadonlySet<string>\n}): string[] {\n  const affected = new Set<string>([params.stepKey])\n  if (params.stepKey === 'analyze_characters' || params.stepKey === 'analyze_locations') {\n    if (params.existingStepKeys.has('split_clips')) {\n      affected.add('split_clips')\n    }\n    for (const stepKey of params.existingStepKeys) {\n      if (stepKey.startsWith('screenplay_')) {\n        affected.add(stepKey)\n      }\n    }\n  } else if (params.stepKey === 'split_clips') {\n    for (const stepKey of params.existingStepKeys) {\n      if (stepKey.startsWith('screenplay_')) {\n        affected.add(stepKey)\n      }\n    }\n  }\n  return uniqueStepKeys(affected)\n}\n\ntype StoryboardPhase = 'phase1' | 'phase2_cinematography' | 'phase2_acting' | 'phase3_detail'\n\nfunction parseStoryboardStepKey(stepKey: string): { clipId: string; phase: StoryboardPhase } | null {\n  const match = /^clip_(.+)_(phase1|phase2_cinematography|phase2_acting|phase3_detail)$/.exec(stepKey.trim())\n  if (!match) return null\n  const clipId = (match[1] || '').trim()\n  const phase = match[2] as StoryboardPhase\n  if (!clipId) return null\n  return { clipId, phase }\n}\n\nfunction resolveScriptToStoryboardInvalidation(params: {\n  stepKey: string\n  existingStepKeys: ReadonlySet<string>\n}): string[] {\n  const affected = new Set<string>([params.stepKey])\n  if (params.stepKey === 'voice_analyze') {\n    return uniqueStepKeys(affected)\n  }\n\n  const parsed = parseStoryboardStepKey(params.stepKey)\n  if (!parsed) {\n    return uniqueStepKeys(affected)\n  }\n\n  const clipPrefix = `clip_${parsed.clipId}_`\n  if (parsed.phase === 'phase1') {\n    affected.add(`${clipPrefix}phase2_cinematography`)\n    affected.add(`${clipPrefix}phase2_acting`)\n    affected.add(`${clipPrefix}phase3_detail`)\n    affected.add('voice_analyze')\n    return uniqueStepKeys(Array.from(affected).filter((stepKey) => params.existingStepKeys.has(stepKey)))\n  }\n\n  if (parsed.phase === 'phase2_cinematography' || parsed.phase === 'phase2_acting') {\n    affected.add(`${clipPrefix}phase3_detail`)\n    affected.add('voice_analyze')\n    return uniqueStepKeys(Array.from(affected).filter((stepKey) => params.existingStepKeys.has(stepKey)))\n  }\n\n  affected.add('voice_analyze')\n  return uniqueStepKeys(Array.from(affected).filter((stepKey) => params.existingStepKeys.has(stepKey)))\n}\n\nexport function resolveRetryInvalidationStepKeys(params: {\n  workflowType: string\n  stepKey: string\n  existingStepKeys: string[]\n}): string[] {\n  const existingStepKeys = new Set(params.existingStepKeys)\n  if (params.workflowType === STORY_TO_SCRIPT_WORKFLOW) {\n    return resolveStoryToScriptInvalidation({\n      stepKey: params.stepKey,\n      existingStepKeys,\n    })\n  }\n  if (params.workflowType === SCRIPT_TO_STORYBOARD_WORKFLOW) {\n    return resolveScriptToStoryboardInvalidation({\n      stepKey: params.stepKey,\n      existingStepKeys,\n    })\n  }\n  return uniqueStepKeys([params.stepKey].filter((stepKey) => existingStepKeys.has(stepKey)))\n}\n"
  },
  {
    "path": "src/lib/workspace/model-setup.ts",
    "content": "interface PreferenceRecord {\n  analysisModel?: string | null\n}\n\ninterface UserPreferencePayload {\n  preference?: PreferenceRecord | null\n}\n\nfunction isNonEmptyString(value: unknown): value is string {\n  return typeof value === 'string' && value.trim().length > 0\n}\n\nfunction isObjectLike(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null\n}\n\nexport function hasConfiguredAnalysisModel(payload: unknown): boolean {\n  return readConfiguredAnalysisModel(payload) !== null\n}\n\nexport function readConfiguredAnalysisModel(payload: unknown): string | null {\n  if (!isObjectLike(payload)) return null\n\n  const preferenceValue = payload.preference\n  if (!isObjectLike(preferenceValue)) return null\n\n  const preference = preferenceValue as PreferenceRecord\n  return isNonEmptyString(preference.analysisModel) ? preference.analysisModel.trim() : null\n}\n\nexport function shouldGuideToModelSetup(payload: unknown): boolean {\n  return !hasConfiguredAnalysisModel(payload)\n}\n\nexport type { UserPreferencePayload }\n"
  },
  {
    "path": "src/middleware.ts",
    "content": "import createMiddleware from 'next-intl/middleware';\nimport { locales, defaultLocale } from '@/i18n';\n\nexport default createMiddleware({\n    // 支持的所有语言\n    locales,\n\n    // 默认语言\n    defaultLocale,\n\n    // URL 路径策略: 始终显示语言前缀\n    localePrefix: 'always',\n\n    // 关闭自动语言检测，避免无前缀跳转触发语言漂移\n    localeDetection: false\n});\n\nexport const config = {\n    // 匹配所有路径，除了 api、_next/static、_next/image、favicon.ico 等\n    matcher: [\n        // 匹配根路径和所有带语言前缀的路径\n        '/',\n        '/(zh|en)/:path*',\n        // 匹配所有其他路径（用于重定向到带语言前缀的路径）\n        '/((?!api|m|_next/static|_next/image|favicon.ico|.*\\\\.png|.*\\\\.jpg|.*\\\\.jpeg|.*\\\\.svg|.*\\\\.gif|.*\\\\.ico).*)'\n    ]\n};\n"
  },
  {
    "path": "src/pages/_document.tsx",
    "content": "import { Head, Html, Main, NextScript } from 'next/document'\n\nexport default function Document() {\n  return (\n    <Html lang=\"en\">\n      <Head />\n      <body>\n        <Main />\n        <NextScript />\n      </body>\n    </Html>\n  )\n}\n"
  },
  {
    "path": "src/styles/animations.css",
    "content": "/* New UI Animation Styles */\n\n@keyframes fadeInDown {\n  from {\n    opacity: 0;\n    transform: translateY(-20px);\n  }\n\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n    transform: scale(0.99);\n  }\n\n  to {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n@keyframes aurora {\n  0% {\n    transform: translate(0, 0) rotate(0deg);\n    opacity: 0.4;\n  }\n\n  50% {\n    transform: translate(-2%, 2%) rotate(2deg);\n    opacity: 0.6;\n  }\n\n  100% {\n    transform: translate(0, 0) rotate(0deg);\n    opacity: 0.4;\n  }\n}\n\n@keyframes blob {\n  0% {\n    transform: translate(0, 0) scale(1);\n  }\n\n  33% {\n    transform: translate(30px, -50px) scale(1.1);\n  }\n\n  66% {\n    transform: translate(-20px, 20px) scale(0.9);\n  }\n\n  100% {\n    transform: translate(0, 0) scale(1);\n  }\n}\n\n.animate-fadeInDown {\n  animation: fadeInDown 0.8s cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.animate-fadeIn {\n  animation: fadeIn 0.15s ease-out;\n}\n\n.animate-aurora {\n  animation: aurora 20s infinite alternate linear;\n}\n\n.animate-blob {\n  animation: blob 10s infinite;\n}\n\n.animation-delay-2000 {\n  animation-delay: 2s;\n}\n\n.animation-delay-4000 {\n  animation-delay: 4s;\n}\n\n/* Custom Scrollbar */\n.custom-scrollbar::-webkit-scrollbar {\n  width: 4px;\n}\n\n.custom-scrollbar::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n.custom-scrollbar::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.1);\n  border-radius: 4px;\n}\n\n.custom-scrollbar::-webkit-scrollbar-thumb:hover {\n  background: rgba(0, 0, 0, 0.2);\n}\n\n/* Slide in from right animation */\n@keyframes slideInRight {\n  from {\n    transform: translateX(100%);\n    opacity: 0;\n  }\n\n  to {\n    transform: translateX(0);\n    opacity: 1;\n  }\n}\n\n.animate-slideInRight {\n  animation: slideInRight 0.3s ease-out;\n}\n\n/* Gradient Flow Animation - 渐变流动效果 */\n@keyframes gradientFlow {\n  0% {\n    background-position: 0% 50%;\n  }\n\n  50% {\n    background-position: 100% 50%;\n  }\n\n  100% {\n    background-position: 0% 50%;\n  }\n}\n\n.animate-gradient-flow {\n  background-size: 200% 200%;\n  animation: gradientFlow 3s ease infinite;\n}\n\n/* Page Transition Animation - 页面切换动画 */\n@keyframes pageSlideIn {\n  from {\n    opacity: 0;\n    transform: translateY(20px);\n  }\n\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes pageSlideOut {\n  from {\n    opacity: 1;\n    transform: translateY(0);\n  }\n\n  to {\n    opacity: 0;\n    transform: translateY(-20px);\n  }\n}\n\n.animate-page-enter {\n  animation: pageSlideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.animate-page-exit {\n  animation: pageSlideOut 0.3s ease-out forwards;\n}\n\n/* Shimmer effect for gradient text */\n@keyframes shimmer {\n  0% {\n    background-position: -200% center;\n  }\n\n  100% {\n    background-position: 200% center;\n  }\n}\n\n.animate-shimmer {\n  background-size: 200% 100%;\n  animation: shimmer 3s linear infinite;\n}"
  },
  {
    "path": "src/styles/ui-semantic-glass.css",
    "content": ".glass-page {\n  background: var(--glass-bg-canvas);\n  color: var(--glass-text-primary);\n}\n\n.glass-surface {\n  background: var(--glass-bg-surface);\n  border: 1px solid var(--glass-stroke-soft);\n  box-shadow: var(--glass-shadow-sm);\n  backdrop-filter: blur(var(--glass-blur-md));\n  -webkit-backdrop-filter: blur(var(--glass-blur-md));\n  border-radius: var(--glass-radius-lg);\n}\n\n.glass-surface-elevated {\n  background: var(--glass-bg-surface-strong);\n  border: 1px solid var(--glass-stroke-soft);\n  box-shadow: var(--glass-shadow-lg);\n  backdrop-filter: blur(var(--glass-blur-lg));\n  -webkit-backdrop-filter: blur(var(--glass-blur-lg));\n  border-radius: var(--glass-radius-lg);\n}\n\n.glass-surface-modal {\n  background: var(--glass-bg-surface-modal);\n  border: 1px solid var(--glass-stroke-soft);\n  box-shadow: var(--glass-shadow-modal);\n  backdrop-filter: blur(var(--glass-blur-lg));\n  -webkit-backdrop-filter: blur(var(--glass-blur-lg));\n  border-radius: var(--glass-radius-xl);\n}\n\n.glass-surface-nav {\n  background: var(--glass-bg-nav);\n  border: 1px solid var(--glass-stroke-soft);\n  box-shadow: var(--glass-shadow-nav);\n  backdrop-filter: blur(var(--glass-blur-nav));\n  -webkit-backdrop-filter: blur(var(--glass-blur-nav));\n  border-radius: 999px;\n}\n\n.glass-surface-soft {\n  background: var(--glass-bg-surface-strong);\n  box-shadow: var(--glass-shadow-sm);\n  backdrop-filter: blur(var(--glass-blur-md));\n  -webkit-backdrop-filter: blur(var(--glass-blur-md));\n  border-radius: var(--glass-radius-lg);\n}\n\n.glass-divider {\n  border-top: 1px solid var(--glass-stroke-base);\n}\n\n.glass-field-label {\n  font-size: 13px;\n  font-weight: 700;\n  color: var(--glass-text-primary);\n  letter-spacing: 0.01em;\n}\n\n.glass-field-hint {\n  font-size: 12px;\n  color: var(--glass-text-secondary);\n}\n\n.glass-input-base,\n.glass-textarea-base,\n.glass-select-base {\n  width: 100%;\n  border-radius: var(--glass-radius-md);\n  border: 1px solid transparent;\n  background: var(--glass-bg-muted);\n  box-shadow: inset 0 0 0 1px var(--glass-stroke-base);\n  color: var(--glass-text-primary);\n  outline: none;\n  transition: box-shadow 0.2s ease, background-color 0.2s ease, transform 0.2s ease;\n}\n\n.glass-input-base::placeholder,\n.glass-textarea-base::placeholder {\n  color: var(--glass-text-tertiary);\n  opacity: 0.85;\n}\n\n.glass-input-base:hover,\n.glass-textarea-base:hover,\n.glass-select-base:hover {\n  box-shadow: inset 0 0 0 1px var(--glass-stroke-strong);\n}\n\n.glass-input-base:focus-visible,\n.glass-textarea-base:focus-visible,\n.glass-select-base:focus-visible {\n  box-shadow:\n    inset 0 0 0 1px var(--glass-stroke-focus),\n    0 0 0 3px var(--glass-focus-ring);\n  background: var(--glass-bg-surface-strong);\n}\n\n.glass-input-base[aria-invalid='true'],\n.glass-textarea-base[aria-invalid='true'] {\n  box-shadow:\n    inset 0 0 0 1px var(--glass-stroke-danger),\n    0 0 0 2px var(--glass-danger-ring);\n}\n\n.glass-input-base:disabled,\n.glass-textarea-base:disabled,\n.glass-select-base:disabled {\n  opacity: 0.55;\n  cursor: not-allowed;\n}\n\n.glass-btn-base {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  cursor: pointer;\n  font-weight: 600;\n  border-radius: var(--glass-radius-md);\n  border: 1px solid transparent;\n  transition: all 0.2s ease;\n  white-space: nowrap;\n}\n\n.glass-btn-base:focus-visible {\n  outline: none;\n  box-shadow: 0 0 0 3px var(--glass-focus-ring-strong);\n}\n\n.glass-btn-base:disabled {\n  opacity: 0.55;\n  cursor: not-allowed;\n}\n\n.glass-btn-primary {\n  background: linear-gradient(140deg, var(--glass-accent-from) 0%, var(--glass-accent-to) 100%);\n  color: var(--glass-text-on-accent);\n  box-shadow: 0 8px 20px var(--glass-accent-shadow-soft);\n}\n\n.glass-btn-primary:hover:not(:disabled) {\n  transform: translateY(-1px);\n  box-shadow: 0 12px 24px var(--glass-accent-shadow-strong);\n}\n\n.glass-btn-secondary {\n  background: var(--glass-bg-surface-strong);\n  color: var(--glass-text-primary);\n  box-shadow: var(--glass-shadow-sm);\n}\n\n.glass-btn-secondary:hover:not(:disabled) {\n  background: var(--glass-bg-surface-strong);\n  box-shadow: var(--glass-shadow-md);\n}\n\n.glass-btn-soft {\n  background: var(--glass-bg-muted);\n  color: var(--glass-text-primary);\n  box-shadow: none;\n}\n\n.glass-btn-soft:hover:not(:disabled) {\n  background: var(--glass-bg-surface-strong);\n  transform: translateY(-1px);\n}\n\n.glass-btn-ghost {\n  background: transparent;\n  color: var(--glass-text-secondary);\n}\n\n.glass-btn-ghost:hover:not(:disabled) {\n  background: var(--glass-ghost-hover-bg);\n}\n\n.glass-btn-danger {\n  background: rgba(246, 119, 119, 0.14);\n  color: var(--glass-tone-danger-fg);\n  box-shadow: inset 0 0 0 1px rgba(246, 119, 119, 0.32);\n}\n\n.glass-btn-danger:hover:not(:disabled) {\n  background: rgba(246, 119, 119, 0.22);\n}\n\n.glass-btn-tone-info {\n  background: var(--glass-tone-info-bg);\n  color: var(--glass-tone-info-fg);\n  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--glass-tone-info-fg) 24%, transparent);\n}\n\n.glass-btn-tone-info:hover:not(:disabled) {\n  background: color-mix(in srgb, var(--glass-tone-info-bg) 85%, white);\n}\n\n.glass-btn-tone-success {\n  background: var(--glass-tone-success-bg);\n  color: var(--glass-tone-success-fg);\n  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--glass-tone-success-fg) 24%, transparent);\n}\n\n.glass-btn-tone-success:hover:not(:disabled) {\n  background: color-mix(in srgb, var(--glass-tone-success-bg) 85%, white);\n}\n\n.glass-btn-tone-warning {\n  background: var(--glass-tone-warning-bg);\n  color: var(--glass-tone-warning-fg);\n  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--glass-tone-warning-fg) 24%, transparent);\n}\n\n.glass-btn-tone-warning:hover:not(:disabled) {\n  background: color-mix(in srgb, var(--glass-tone-warning-bg) 85%, white);\n}\n\n.glass-btn-tone-danger {\n  background: var(--glass-tone-danger-bg);\n  color: var(--glass-tone-danger-fg);\n  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--glass-tone-danger-fg) 24%, transparent);\n}\n\n.glass-btn-tone-danger:hover:not(:disabled) {\n  background: color-mix(in srgb, var(--glass-tone-danger-bg) 85%, white);\n}\n\n.glass-chip {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  padding: 4px 10px;\n  border-radius: 999px;\n  font-size: 12px;\n  font-weight: 600;\n}\n\n.glass-chip-neutral {\n  background: var(--glass-tone-neutral-bg);\n  color: var(--glass-tone-neutral-fg);\n}\n\n.glass-chip-info {\n  background: var(--glass-tone-info-bg);\n  color: var(--glass-tone-info-fg);\n}\n\n.glass-chip-success {\n  background: var(--glass-tone-success-bg);\n  color: var(--glass-tone-success-fg);\n}\n\n.glass-chip-warning {\n  background: var(--glass-tone-warning-bg);\n  color: var(--glass-tone-warning-fg);\n}\n\n.glass-chip-danger {\n  background: var(--glass-tone-danger-bg);\n  color: var(--glass-tone-danger-fg);\n}\n\n.glass-density-compact {\n  --glass-density-scale: var(--glass-density-compact-scale);\n}\n\n.glass-density-default {\n  --glass-density-scale: var(--glass-density-default-scale);\n}\n\n.glass-overlay {\n  background: var(--glass-overlay);\n  backdrop-filter: blur(var(--glass-blur-sm));\n  -webkit-backdrop-filter: blur(var(--glass-blur-sm));\n}\n\n.glass-overlay-soft {\n  background: var(--glass-overlay-soft);\n  backdrop-filter: blur(var(--glass-blur-sm));\n  -webkit-backdrop-filter: blur(var(--glass-blur-sm));\n}\n\n.glass-overlay-strong {\n  background: var(--glass-overlay-strong);\n  backdrop-filter: blur(var(--glass-blur-sm));\n  -webkit-backdrop-filter: blur(var(--glass-blur-sm));\n}\n\n.glass-icon-btn-sm {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  width: 24px;\n  height: 24px;\n  border-radius: var(--glass-radius-xs);\n  color: var(--glass-text-tertiary);\n  transition: all 0.2s ease;\n}\n\n.glass-icon-btn-sm:hover {\n  color: var(--glass-text-secondary);\n  background: var(--glass-bg-muted);\n}\n\n.glass-list-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  padding: 6px 8px;\n  border-radius: var(--glass-radius-xs);\n  border: 1px solid var(--glass-stroke-base);\n  background: var(--glass-bg-surface);\n  transition: all 0.2s ease;\n}\n\n.glass-list-row:hover {\n  border-color: var(--glass-stroke-focus);\n}\n\n.glass-list-row[data-disabled='true'] {\n  opacity: 0.5;\n  background: var(--glass-bg-muted);\n  border-color: transparent;\n}\n\n.glass-segmented {\n  display: inline-flex;\n  align-items: center;\n  overflow: hidden;\n  border-radius: var(--glass-radius-xs);\n  background: var(--glass-bg-muted);\n}\n\n.glass-segmented-item {\n  padding: 2px 6px;\n  font-size: 11px;\n  line-height: 1;\n  font-weight: 600;\n  color: var(--glass-text-secondary);\n  transition: all 0.2s ease;\n}\n\n.glass-segmented-item:hover {\n  color: var(--glass-text-primary);\n}\n\n.glass-segmented-item[data-active='true'] {\n  background: var(--glass-accent-from);\n  color: var(--glass-text-on-accent);\n}\n\n.glass-toggle {\n  width: 28px;\n  height: 16px;\n  border-radius: 999px;\n  display: inline-flex;\n  align-items: center;\n  cursor: pointer;\n  padding: 2px;\n  transition: all 0.2s ease;\n  background: var(--glass-stroke-strong);\n  justify-content: flex-start;\n}\n\n.glass-toggle[data-active='true'] {\n  background: var(--glass-accent-from);\n  justify-content: flex-end;\n}\n\n.glass-toggle-thumb {\n  width: 12px;\n  height: 12px;\n  border-radius: 999px;\n  background: var(--glass-bg-surface);\n  box-shadow: var(--glass-shadow-sm);\n}\n\n.glass-check-mini {\n  display: inline-flex;\n  width: 12px;\n  height: 12px;\n  align-items: center;\n  justify-content: center;\n  border-radius: 4px;\n  border: 1px solid var(--glass-stroke-strong);\n  background: var(--glass-bg-surface);\n  transition: all 0.2s ease;\n}\n\n.glass-check-mini[data-active='true'] {\n  border-color: var(--glass-accent-from);\n  background: var(--glass-accent-from);\n}\n\n.glass-provider-model-scroll {\n  scrollbar-width: thin;\n  scrollbar-color: var(--glass-stroke-strong) transparent;\n}\n\n.glass-provider-model-scroll::-webkit-scrollbar {\n  width: 10px;\n}\n\n.glass-provider-model-scroll::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n.glass-provider-model-scroll::-webkit-scrollbar-thumb {\n  background: var(--glass-stroke-strong);\n  border-radius: 999px;\n  border: 2px solid transparent;\n  background-clip: content-box;\n}\n"
  },
  {
    "path": "src/styles/ui-tokens-glass.css",
    "content": ":root {\n  /* Surface and text */\n  --glass-bg-canvas: #f3f4f6;\n  --glass-bg-surface: rgba(255, 255, 255, 0.88);\n  --glass-bg-surface-strong: rgba(255, 255, 255, 0.94);\n  --glass-bg-surface-modal: rgba(255, 255, 255, 0.97);\n  --glass-bg-muted: rgba(255, 255, 255, 0.86);\n  --glass-bg-nav: rgba(255, 255, 255, 0.96);\n  --glass-text-primary: #0a0a0a;\n  --glass-text-secondary: #111827;\n  --glass-text-tertiary: #4b5563;\n  --glass-text-on-accent: #ffffff;\n\n  /* Border strategy: default weak stroke, focus/error stronger */\n  --glass-stroke-soft: rgba(255, 255, 255, 0.22);\n  --glass-stroke-base: rgba(111, 126, 153, 0.24);\n  --glass-stroke-strong: rgba(93, 109, 138, 0.36);\n  --glass-stroke-focus: rgba(47, 123, 255, 0.64);\n  --glass-stroke-danger: rgba(236, 72, 72, 0.64);\n  --glass-stroke-warning: rgba(234, 149, 0, 0.62);\n  --glass-stroke-success: rgba(18, 176, 109, 0.62);\n\n  /* Elevation + blur */\n  --glass-shadow-sm: 0 2px 10px rgba(22, 35, 64, 0.05);\n  --glass-shadow-md: 0 6px 18px rgba(19, 35, 66, 0.08);\n  --glass-shadow-lg: 0 10px 24px rgba(20, 35, 69, 0.1);\n  --glass-shadow-modal: 0 14px 34px rgba(15, 32, 66, 0.14);\n  --glass-shadow-nav: 0 8px 18px rgba(15, 32, 66, 0.1);\n  --glass-blur-sm: 4px;\n  --glass-blur-md: 8px;\n  --glass-blur-lg: 12px;\n  --glass-blur-nav: 16px;\n\n  /* Radius */\n  --glass-radius-xs: 8px;\n  --glass-radius-sm: 12px;\n  --glass-radius-md: 16px;\n  --glass-radius-lg: 22px;\n  --glass-radius-xl: 28px;\n\n  /* Spacing + density */\n  --glass-space-1: 4px;\n  --glass-space-2: 8px;\n  --glass-space-3: 12px;\n  --glass-space-4: 16px;\n  --glass-space-5: 20px;\n  --glass-space-6: 24px;\n  --glass-space-7: 28px;\n  --glass-space-8: 32px;\n  --glass-density-compact-scale: 0.86;\n  --glass-density-default-scale: 1;\n\n  /* Tone system */\n  --glass-tone-neutral-bg: rgba(100, 116, 139, 0.18);\n  --glass-tone-neutral-fg: #374151;\n  --glass-tone-info-bg: rgba(47, 123, 255, 0.2);\n  --glass-tone-info-fg: #1d63e8;\n  --glass-tone-success-bg: rgba(16, 185, 129, 0.2);\n  --glass-tone-success-fg: #0f9f62;\n  --glass-tone-warning-bg: rgba(245, 158, 11, 0.24);\n  --glass-tone-warning-fg: #b86400;\n  --glass-tone-danger-bg: rgba(239, 68, 68, 0.2);\n  --glass-tone-danger-fg: #cb3a3a;\n\n  --glass-overlay-soft: rgba(10, 16, 30, 0.34);\n  --glass-overlay: rgba(10, 16, 30, 0.46);\n  --glass-overlay-strong: rgba(10, 16, 30, 0.58);\n\n  /* Accent + interactive rings */\n  --glass-accent-from: #2f7bff;\n  --glass-accent-to: #5ca8ff;\n  --glass-accent-shadow-soft: rgba(47, 123, 255, 0.24);\n  --glass-accent-shadow-strong: rgba(47, 123, 255, 0.32);\n  --glass-focus-ring: rgba(47, 123, 255, 0.16);\n  --glass-focus-ring-strong: rgba(47, 123, 255, 0.22);\n  --glass-danger-ring: rgba(239, 68, 68, 0.14);\n  --glass-ghost-hover-bg: rgba(255, 255, 255, 0.5);\n}\n\n/* Low-cost preset for performance-sensitive environments */\n[data-glass-preset='subtle'] {\n  --glass-bg-surface: rgba(255, 255, 255, 0.92);\n  --glass-bg-surface-strong: rgba(255, 255, 255, 0.96);\n  --glass-bg-surface-modal: rgba(255, 255, 255, 0.98);\n  --glass-bg-nav: rgba(255, 255, 255, 0.98);\n  --glass-blur-sm: 2px;\n  --glass-blur-md: 4px;\n  --glass-blur-lg: 8px;\n  --glass-shadow-sm: 0 1px 6px rgba(22, 35, 64, 0.04);\n  --glass-shadow-md: 0 4px 12px rgba(19, 35, 66, 0.06);\n  --glass-shadow-lg: 0 8px 18px rgba(20, 35, 69, 0.08);\n  --glass-shadow-modal: 0 10px 22px rgba(15, 32, 66, 0.1);\n  --glass-shadow-nav: 0 6px 14px rgba(15, 32, 66, 0.08);\n  --glass-blur-nav: 12px;\n}\n"
  },
  {
    "path": "src/types/character-profile.ts",
    "content": "/**\n * 角色档案数据结构\n * 用于两阶段角色生成系统\n */\n\nexport type RoleLevel = 'S' | 'A' | 'B' | 'C' | 'D'\n\nexport type CostumeTier = 1 | 2 | 3 | 4 | 5\n\nexport interface CharacterProfileData {\n    /** 角色重要性层级 */\n    role_level: RoleLevel\n\n    /** 角色原型 (如: 霸道总裁, 心机婊) */\n    archetype: string\n\n    /** 性格标签 */\n    personality_tags: string[]\n\n    /** 时代背景 */\n    era_period: string\n\n    /** 社会阶层 */\n    social_class: string\n\n    /** 职业 (可选) */\n    occupation?: string\n\n    /** 服装华丽度 (1-5) */\n    costume_tier: CostumeTier\n\n    /** 建议色彩 */\n    suggested_colors: string[]\n\n    /** 主要辨识标志 (S/A级角色必须) */\n    primary_identifier?: string\n\n    /** 视觉关键词 */\n    visual_keywords: string[]\n\n    /** 性别 */\n    gender: string\n\n    /** 年龄段描述 */\n    age_range: string\n}\n\n/**\n * 从JSON字符串解析角色档案\n */\nexport function parseProfileData(profileDataJson: string | null): CharacterProfileData | null {\n    if (!profileDataJson) return null\n    try {\n        return JSON.parse(profileDataJson) as CharacterProfileData\n    } catch {\n        return null\n    }\n}\n\n/**\n * 将角色档案序列化为JSON字符串\n */\nexport function stringifyProfileData(profileData: CharacterProfileData): string {\n    return JSON.stringify(profileData)\n}\n\n/**\n * 验证角色档案数据完整性\n */\nexport function validateProfileData(data: unknown): data is CharacterProfileData {\n    if (!data || typeof data !== 'object') return false\n    const candidate = data as Partial<CharacterProfileData>\n    return !!(\n        typeof candidate.role_level === 'string' &&\n        ['S', 'A', 'B', 'C', 'D'].includes(candidate.role_level) &&\n        typeof candidate.archetype === 'string' &&\n        Array.isArray(candidate.personality_tags) &&\n        typeof candidate.era_period === 'string' &&\n        typeof candidate.social_class === 'string' &&\n        typeof candidate.costume_tier === 'number' &&\n        candidate.costume_tier >= 1 &&\n        candidate.costume_tier <= 5 &&\n        Array.isArray(candidate.suggested_colors) &&\n        Array.isArray(candidate.visual_keywords) &&\n        typeof candidate.gender === 'string' &&\n        typeof candidate.age_range === 'string'\n    )\n}\n"
  },
  {
    "path": "src/types/next-auth.d.ts",
    "content": "declare module \"next-auth\" {\n  interface Session {\n    user: {\n      id: string\n      name?: string | null\n      image?: string | null\n    }\n  }\n\n  interface User {\n    id: string\n    name?: string | null\n    image?: string | null\n  }\n}\n\ndeclare module \"next-auth/jwt\" {\n  interface JWT {\n    id: string\n  }\n}\n"
  },
  {
    "path": "src/types/project.ts",
    "content": "import type { CapabilitySelections } from '@/lib/model-config-contract'\n\n// ============================================\n// 项目模式类型\n// ============================================\nexport type ProjectMode = 'novel-promotion'\n\n// ============================================\n// 基础项目类型\n// ============================================\nexport interface BaseProject {\n  id: string\n  name: string\n  description: string | null\n  mode: ProjectMode\n  userId: string\n  createdAt: Date\n  updatedAt: Date\n}\n\n// ============================================\n// 通用资产类型\n// ============================================\n\nexport interface MediaRef {\n  id: string\n  publicId: string\n  url: string\n  mimeType: string | null\n  sizeBytes: number | null\n  width: number | null\n  height: number | null\n  durationMs: number | null\n}\n\n// 角色形象（独立表）\n// 🔥 V6.5: characterId 改为可选以兼容 useProjectAssets 返回的数据\nexport interface CharacterAppearance {\n  id: string\n  characterId?: string            // 可选，API 响应可能不包含\n  appearanceIndex: number           // 形象序号：0, 1, 2...（0 = 主形象）\n  changeReason: string              // \"初始形象\"、\"落水湿身\"\n  description: string | null\n  descriptions: string[] | null     // 3个描述变体\n  imageUrl: string | null           // 选中的图片\n  media?: MediaRef | null\n  imageUrls: string[]               // 候选图片数组\n  imageMedias?: MediaRef[]\n  previousImageUrl: string | null   // 上一次的图片URL（用于撤回）\n  previousMedia?: MediaRef | null\n  previousImageUrls: string[]         // 上一次的图片数组（用于撤回）\n  previousImageMedias?: MediaRef[]\n  previousDescription: string | null  // 上一次的描述（用于撤回）\n  previousDescriptions: string[] | null  // 上一次的描述数组（用于撤回）\n  selectedIndex: number | null      // 用户选中的图片索引\n  // 任务态字段（由 tasks + hook 派生，不再依赖数据库持久化）\n  imageTaskRunning?: boolean\n  imageErrorMessage?: string | null  // 图片生成错误消息\n  lastError?: { code: string; message: string } | null  // 结构化错误（来自 task target state）\n}\n\n// 角色\n// 🔥 V6.5: aliases 改为可选数组以兼容 useProjectAssets\nexport interface Character {\n  id: string\n  name: string\n  aliases?: string[] | null         // 可选，别名数组\n  introduction?: string | null      // 角色介绍（叙述视角、称呼映射等）\n  appearances: CharacterAppearance[]  // 独立表关联\n  // 配音音色设置\n  voiceType?: 'custom' | 'qwen-designed' | 'uploaded' | null  // 音色类型\n  voiceId?: string | null                 // 音色 ID 或业务标识\n  customVoiceUrl?: string | null          // 自定义上传的参考音频URL\n  media?: MediaRef | null\n  // 角色档案（两阶段生成）\n  profileData?: string | null             // JSON格式的角色档案\n  profileConfirmed?: boolean             // 档案是否已确认\n  // 任务态字段（由 tasks + hook 派生，不再依赖数据库持久化）\n  profileConfirmTaskRunning?: boolean     // 档案确认任务是否正在运行\n}\n\n// 场景图片（独立表）\n// 🔥 V6.5: locationId 改为可选以兼容 useProjectAssets\nexport interface LocationImage {\n  id: string\n  locationId?: string               // 可选，API 响应可能不包含\n  imageIndex: number              // 图片索引：0, 1, 2\n  description: string | null\n  imageUrl: string | null\n  media?: MediaRef | null\n  previousImageUrl: string | null // 上一次的图片URL（用于撤回）\n  previousMedia?: MediaRef | null\n  previousDescription: string | null  // 上一次的描述（用于撤回）\n  isSelected: boolean\n  // 任务态字段（由 tasks + hook 派生，不再依赖数据库持久化）\n  imageTaskRunning?: boolean\n  imageErrorMessage?: string | null  // 图片生成错误消息\n  lastError?: { code: string; message: string } | null  // 结构化错误（来自 task target state）\n}\n\n// 场景\nexport interface Location {\n  id: string\n  name: string\n  summary: string | null            // 场景简要描述（用途/人物关联）\n  selectedImageId?: string | null   // 选中的图片ID（单一真源）\n  images: LocationImage[]           // 独立表关联\n}\n\nexport interface AssetLibraryCharacter {\n  id: string\n  name: string\n  description: string\n  imageUrl: string | null\n  media?: MediaRef | null\n}\n\nexport interface AssetLibraryLocation {\n  id: string\n  name: string\n  description: string\n  imageUrl: string | null\n  media?: MediaRef | null\n}\n\n// ============================================\n// 小说推文模式类型\n// ============================================\n\n// 工作流模式\nexport type WorkflowMode = 'srt' | 'agent'\n\n// Clip类型（兼容SRT和Agent两种模式）\nexport interface NovelPromotionClip {\n  id: string\n\n  // SRT模式字段\n  start?: number\n  end?: number\n  duration?: number\n\n  // Agent模式字段\n  startText?: string\n  endText?: string\n  shotCount?: number\n\n  // 共用字段\n  summary: string\n  location: string | null\n  characters: string | null\n  content: string\n  screenplay?: string | null  // 剧本JSON（Phase 0输出）\n}\n\nexport interface NovelPromotionPanel {\n  id: string\n  storyboardId: string\n  panelIndex: number\n  panelNumber: number | null\n  shotType: string | null\n  cameraMove: string | null\n  description: string | null\n  location: string | null\n  characters: string | null\n  srtSegment: string | null\n  srtStart: number | null\n  srtEnd: number | null\n  duration: number | null\n  imagePrompt: string | null\n  imageUrl: string | null\n  candidateImages?: string | null\n  media?: MediaRef | null\n  imageHistory: string | null\n  videoPrompt: string | null\n  firstLastFramePrompt?: string | null\n  videoUrl: string | null\n  videoGenerationMode?: 'normal' | 'firstlastframe' | null\n  videoMedia?: MediaRef | null\n  lipSyncVideoUrl?: string | null\n  lipSyncVideoMedia?: MediaRef | null\n  sketchImageUrl?: string | null\n  sketchImageMedia?: MediaRef | null\n  previousImageUrl?: string | null\n  previousImageMedia?: MediaRef | null\n  photographyRules: string | null  // 单镜头摄影规则JSON\n  actingNotes: string | null        // 演技指导数据JSON\n  // 任务态字段（由 tasks + hook 派生，不再依赖数据库持久化）\n  imageTaskRunning?: boolean\n  videoTaskRunning?: boolean\n  imageErrorMessage?: string | null  // 图片生成错误消息\n}\n\nexport interface NovelPromotionStoryboard {\n  id: string\n  episodeId: string\n  clipId: string\n  storyboardTextJson: string | null\n  panelCount: number\n  storyboardImageUrl: string | null\n  media?: MediaRef | null\n  storyboardTaskRunning?: boolean\n  candidateImages?: string | null\n  lastError?: string | null  // 最后一次生成失败的错误信息\n  photographyPlan?: string | null  // 摄影方案JSON\n  panels?: NovelPromotionPanel[]\n}\n\nexport interface NovelPromotionShot {\n  id: string\n  shotId: string\n  srtStart: number\n  srtEnd: number\n  srtDuration: number\n  sequence: string | null\n  locations: string | null\n  characters: string | null\n  plot: string | null\n  pov: string | null\n  imagePrompt: string | null\n  scale: string | null\n  module: string | null\n  focus: string | null\n  zhSummarize: string | null\n  imageUrl: string | null\n  media?: MediaRef | null\n  videoUrl?: string | null\n  videoMedia?: MediaRef | null\n  // 任务态字段（由 tasks + hook 派生，不再依赖数据库持久化）\n  imageTaskRunning?: boolean\n}\n\nexport interface NovelPromotionProject {\n  id: string\n  projectId: string\n  stage: string\n  globalAssetText: string | null\n  novelText: string | null\n  analysisModel: string\n  imageModel: string\n  characterModel: string\n  locationModel: string\n  storyboardModel: string\n  editModel: string\n  videoModel: string\n  audioModel: string\n  videoRatio: string\n  capabilityOverrides?: CapabilitySelections | string | null\n  ttsRate: string\n  workflowMode: WorkflowMode  // 新增：工作流模式\n  artStyle: string\n  artStylePrompt: string | null\n  audioUrl: string | null\n  media?: MediaRef | null\n  srtContent: string | null\n  characters?: Character[]\n  locations?: Location[]\n  episodes?: Array<{\n    id: string\n    episodeNumber: number\n    name: string\n    description: string | null\n    novelText: string | null\n    audioUrl: string | null\n    srtContent: string | null\n    createdAt: Date\n    updatedAt: Date\n  }>\n  clips?: NovelPromotionClip[]\n  storyboards?: NovelPromotionStoryboard[]\n  shots?: NovelPromotionShot[]\n}\n\n// ============================================\n// 完整项目类型 (包含基础信息和模式数据)\n// ============================================\nexport interface Project extends BaseProject {\n  novelPromotionData?: NovelPromotionProject\n}\n"
  },
  {
    "path": "src/types/storyboard-types.ts",
    "content": "/**\n * 分镜相关的类型守卫和工具类型\n * 解决 (storyboard as any).panels 类型断言问题\n */\n\nimport { NovelPromotionStoryboard, NovelPromotionPanel } from './project'\n\n/**\n * 带有已加载 panels 的 Storyboard 类型\n * 用于数据库查询后包含 panels 的情况\n */\nexport interface StoryboardWithPanels extends NovelPromotionStoryboard {\n    panels: NovelPromotionPanel[]\n}\n\n/**\n * 类型守卫：检查 storyboard 是否包含已加载的 panels\n */\nexport function hasLoadedPanels(\n    storyboard: NovelPromotionStoryboard\n): storyboard is StoryboardWithPanels {\n    return Array.isArray((storyboard as StoryboardWithPanels).panels)\n}\n\n/**\n * 安全获取 panels 数组\n * 如果 panels 不存在则返回空数组\n */\nexport function getPanels(storyboard: NovelPromotionStoryboard): NovelPromotionPanel[] {\n    if (hasLoadedPanels(storyboard)) {\n        return storyboard.panels\n    }\n    return []\n}\n\n/**\n * 获取 panel 的候选图片\n * 处理 candidateImages JSON 字符串解析\n */\nexport function getPanelCandidates(panel: NovelPromotionPanel): string[] {\n    if (!panel.imageHistory) return []\n    try {\n        const parsed = JSON.parse(panel.imageHistory)\n        return Array.isArray(parsed) ? parsed : []\n    } catch {\n        return []\n    }\n}\n"
  },
  {
    "path": "standards/capabilities/catalog.example.json",
    "content": "[\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"example-provider\",\n    \"modelId\": \"example-video-model\",\n    \"capabilities\": {\n      \"video\": {\n        \"durationOptions\": [5, 10, 15],\n        \"fpsOptions\": [24, 30],\n        \"resolutionOptions\": [\"720p\", \"1080p\"],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": true,\n        \"fieldI18n\": {\n          \"duration\": {\n            \"labelKey\": \"video.capability.duration\",\n            \"unitKey\": \"video.unit.second\"\n          },\n          \"fps\": {\n            \"labelKey\": \"video.capability.fps\",\n            \"unitKey\": \"video.unit.fps\"\n          },\n          \"resolution\": {\n            \"labelKey\": \"video.capability.resolution\"\n          }\n        }\n      }\n    }\n  },\n  {\n    \"modelType\": \"image\",\n    \"provider\": \"example-provider\",\n    \"modelId\": \"example-image-model\",\n    \"capabilities\": {\n      \"image\": {\n        \"resolutionOptions\": [\"2K\", \"4K\"],\n        \"fieldI18n\": {\n          \"resolution\": {\n            \"labelKey\": \"image.capability.resolution\"\n          }\n        }\n      }\n    }\n  },\n  {\n    \"modelType\": \"llm\",\n    \"provider\": \"example-provider\",\n    \"modelId\": \"example-llm-model\"\n  }\n]\n"
  },
  {
    "path": "standards/capabilities/image-video.catalog.json",
    "content": "[\n  {\n    \"modelType\": \"image\",\n    \"provider\": \"fal\",\n    \"modelId\": \"banana-2\",\n    \"capabilities\": {\n      \"image\": {\n        \"resolutionOptions\": [\n          \"1K\",\n          \"2K\",\n          \"4K\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-pro-fast-251015\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"durationOptions\": [\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12\n        ],\n        \"resolutionOptions\": [\n          \"480p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"durationOptions\": [\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12\n        ],\n        \"resolutionOptions\": [\n          \"480p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-pro-250528\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12\n        ],\n        \"resolutionOptions\": [\n          \"480p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-pro-250528-batch\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12\n        ],\n        \"resolutionOptions\": [\n          \"480p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-lite-i2v-250428\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12\n        ],\n        \"resolutionOptions\": [\n          \"480p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-lite-i2v-250428-batch\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12\n        ],\n        \"resolutionOptions\": [\n          \"480p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-5-pro-251215\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"generateAudioOptions\": [\n          true,\n          false\n        ],\n        \"durationOptions\": [\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12\n        ],\n        \"resolutionOptions\": [\n          \"480p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": true\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-5-pro-251215-batch\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"generateAudioOptions\": [\n          true,\n          false\n        ],\n        \"durationOptions\": [\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12\n        ],\n        \"resolutionOptions\": [\n          \"480p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": true\n      }\n    }\n  },\n  {\n    \"modelType\": \"llm\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3.1-pro-preview\",\n    \"capabilities\": {\n      \"llm\": {\n        \"reasoningEffortOptions\": [\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"llm\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3.1-flash-lite-preview\",\n    \"capabilities\": {\n      \"llm\": {\n        \"reasoningEffortOptions\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"llm\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3-flash-preview\",\n    \"capabilities\": {\n      \"llm\": {\n        \"reasoningEffortOptions\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"llm\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-1-8-251228\",\n    \"capabilities\": {\n      \"llm\": {\n        \"reasoningEffortOptions\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"llm\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-2-0-pro-260215\",\n    \"capabilities\": {\n      \"llm\": {\n        \"reasoningEffortOptions\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"llm\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-2-0-lite-260215\",\n    \"capabilities\": {\n      \"llm\": {\n        \"reasoningEffortOptions\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"llm\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-2-0-mini-260215\",\n    \"capabilities\": {\n      \"llm\": {\n        \"reasoningEffortOptions\": [\n          \"minimal\",\n          \"low\",\n          \"medium\",\n          \"high\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3-pro-image-preview\",\n    \"capabilities\": {\n      \"image\": {\n        \"resolutionOptions\": [\n          \"2K\",\n          \"4K\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3-pro-image-preview-batch\",\n    \"capabilities\": {\n      \"image\": {\n        \"resolutionOptions\": [\n          \"2K\",\n          \"4K\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3.1-flash-image-preview\",\n    \"capabilities\": {\n      \"image\": {\n        \"resolutionOptions\": [\n          \"0.5K\",\n          \"1K\",\n          \"2K\",\n          \"4K\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-2.5-flash-image\",\n    \"capabilities\": {\n      \"image\": {\n        \"resolutionOptions\": [\n          \"1K\"\n        ]\n      }\n    }\n  },\n  {\n    \"modelType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"imagen-4.0-generate-001\",\n    \"capabilities\": {\n      \"image\": {}\n    }\n  },\n  {\n    \"modelType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"imagen-4.0-fast-generate-001\",\n    \"capabilities\": {\n      \"image\": {}\n    }\n  },\n  {\n    \"modelType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"imagen-4.0-ultra-generate-001\",\n    \"capabilities\": {\n      \"image\": {}\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-3.1-generate-preview\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          4,\n          6,\n          8\n        ],\n        \"resolutionOptions\": [\n          \"720p\",\n          \"1080p\",\n          \"4k\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-3.1-fast-generate-preview\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          4,\n          6,\n          8\n        ],\n        \"resolutionOptions\": [\n          \"720p\",\n          \"1080p\",\n          \"4k\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-3.0-generate-001\",\n    \"capabilities\": {\n      \"video\": {\n        \"durationOptions\": [\n          4,\n          6,\n          8\n        ],\n        \"resolutionOptions\": [\n          \"720p\",\n          \"1080p\",\n          \"4k\"\n        ],\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-3.0-fast-generate-001\",\n    \"capabilities\": {\n      \"video\": {\n        \"durationOptions\": [\n          4,\n          6,\n          8\n        ],\n        \"resolutionOptions\": [\n          \"720p\",\n          \"1080p\",\n          \"4k\"\n        ],\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-2.0-generate-001\",\n    \"capabilities\": {\n      \"video\": {\n        \"durationOptions\": [\n          5,\n          6,\n          8\n        ],\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-ai/kling-video/v2.5-turbo/pro/image-to-video\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"durationOptions\": [\n          5,\n          10\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-ai/kling-video/v3/standard/image-to-video\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"durationOptions\": [\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12,\n          13,\n          14,\n          15\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-ai/kling-video/v3/pro/image-to-video\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"durationOptions\": [\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12,\n          13,\n          14,\n          15\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"bailian\",\n    \"modelId\": \"wan2.6-i2v-flash\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"bailian\",\n    \"modelId\": \"wan2.6-i2v\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"bailian\",\n    \"modelId\": \"wan2.5-i2v-preview\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"bailian\",\n    \"modelId\": \"wan2.2-i2v-plus\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"bailian\",\n    \"modelId\": \"wan2.2-kf2v-flash\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"firstlastframe\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"bailian\",\n    \"modelId\": \"wanx2.1-kf2v-plus\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"firstlastframe\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"minimax-hailuo-2.3\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"durationOptions\": [\n          6,\n          10\n        ],\n        \"resolutionOptions\": [\n          \"768p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"minimax-hailuo-2.3-fast\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"durationOptions\": [\n          6,\n          10\n        ],\n        \"resolutionOptions\": [\n          \"768p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"minimax-hailuo-02\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          6,\n          10\n        ],\n        \"resolutionOptions\": [\n          \"512p\",\n          \"768p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"t2v-01\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"durationOptions\": [\n          6\n        ],\n        \"resolutionOptions\": [\n          \"720p\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"t2v-01-director\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\"\n        ],\n        \"durationOptions\": [\n          6\n        ],\n        \"resolutionOptions\": [\n          \"720p\"\n        ],\n        \"firstlastframe\": false,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq3-pro\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"generateAudioOptions\": [\n          false,\n          true\n        ],\n        \"durationOptions\": [\n          1,\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10,\n          11,\n          12,\n          13,\n          14,\n          15,\n          16\n        ],\n        \"resolutionOptions\": [\n          \"540p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": true\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq2-pro-fast\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"generateAudioOptions\": [\n          false,\n          true\n        ],\n        \"durationOptions\": [\n          1,\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10\n        ],\n        \"resolutionOptions\": [\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": true\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq2-pro\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"generateAudioOptions\": [\n          false,\n          true\n        ],\n        \"durationOptions\": [\n          1,\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10\n        ],\n        \"resolutionOptions\": [\n          \"540p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": true\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq2-turbo\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"generateAudioOptions\": [\n          false,\n          true\n        ],\n        \"durationOptions\": [\n          1,\n          2,\n          3,\n          4,\n          5,\n          6,\n          7,\n          8,\n          9,\n          10\n        ],\n        \"resolutionOptions\": [\n          \"540p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": true\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq1\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          5\n        ],\n        \"resolutionOptions\": [\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq1-classic\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          5\n        ],\n        \"resolutionOptions\": [\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  },\n  {\n    \"modelType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"vidu2.0\",\n    \"capabilities\": {\n      \"video\": {\n        \"generationModeOptions\": [\n          \"normal\",\n          \"firstlastframe\"\n        ],\n        \"durationOptions\": [\n          4,\n          8\n        ],\n        \"resolutionOptions\": [\n          \"360p\",\n          \"720p\",\n          \"1080p\"\n        ],\n        \"firstlastframe\": true,\n        \"supportGenerateAudio\": false\n      }\n    }\n  }\n]"
  },
  {
    "path": "standards/pricing/image-video.pricing.json",
    "content": "[\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"openrouter\",\n    \"modelId\": \"google/gemini-3.1-pro-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 9\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 72\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"openrouter\",\n    \"modelId\": \"google/gemini-3-pro-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 9\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 72\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"openrouter\",\n    \"modelId\": \"google/gemini-3-flash-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 0.54\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 2.16\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"openrouter\",\n    \"modelId\": \"anthropic/claude-sonnet-4.5\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 21.6\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 108\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"openrouter\",\n    \"modelId\": \"anthropic/claude-sonnet-4\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 21.6\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 108\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3.1-pro-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 14.4\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 86.4\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3-pro-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 14.4\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 86.4\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3-flash-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 3.6\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 21.6\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-1-8-251228\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 0.8\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 2\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-2-0-pro-260215\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 3.2\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 16\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-2-0-lite-260215\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 0.6\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 3.6\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-2-0-mini-260215\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 0.2\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 2\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-1-6-251015\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 0.8\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 2\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"text\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seed-1-6-lite-251015\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"tokenType\": \"input\"\n          },\n          \"amount\": 0.3\n        },\n        {\n          \"when\": {\n            \"tokenType\": \"output\"\n          },\n          \"amount\": 0.6\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"fal\",\n    \"modelId\": \"banana\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.9648\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"fal\",\n    \"modelId\": \"banana-2\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"1K\"\n          },\n          \"amount\": 0.576\n        },\n        {\n          \"when\": {\n            \"resolution\": \"2K\"\n          },\n          \"amount\": 0.864\n        },\n        {\n          \"when\": {\n            \"resolution\": \"4K\"\n          },\n          \"amount\": 1.152\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"legacy\",\n    \"modelId\": \"banana-2k\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.9648\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"legacy\",\n    \"modelId\": \"banana-4k\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 1.728\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"legacy\",\n    \"modelId\": \"seedream\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.25\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"legacy\",\n    \"modelId\": \"seedream4\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.2\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedream-4-5-251128\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.25\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedream-4-0-250828\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.2\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3-pro-image-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"2K\"\n          },\n          \"amount\": 0.9648\n        },\n        {\n          \"when\": {\n            \"resolution\": \"4K\"\n          },\n          \"amount\": 1.728\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3-pro-image-preview-batch\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"2K\"\n          },\n          \"amount\": 0.4824\n        },\n        {\n          \"when\": {\n            \"resolution\": \"4K\"\n          },\n          \"amount\": 0.864\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-3.1-flash-image-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"0.5K\"\n          },\n          \"amount\": 0.324\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1K\"\n          },\n          \"amount\": 0.4824\n        },\n        {\n          \"when\": {\n            \"resolution\": \"2K\"\n          },\n          \"amount\": 0.7272\n        },\n        {\n          \"when\": {\n            \"resolution\": \"4K\"\n          },\n          \"amount\": 1.0872\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"gemini-2.5-flash-image\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.2808\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"imagen-4.0-generate-001\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.288\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"imagen-4.0-ultra-generate-001\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.432\n    }\n  },\n  {\n    \"apiType\": \"image\",\n    \"provider\": \"google\",\n    \"modelId\": \"imagen-4.0-fast-generate-001\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.144\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-pro-fast-251015\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"480p\"\n          },\n          \"amount\": 0.2\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\"\n          },\n          \"amount\": 0.43\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\"\n          },\n          \"amount\": 1.03\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-pro-fast-251015-batch\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"480p\"\n          },\n          \"amount\": 0.1\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\"\n          },\n          \"amount\": 0.22\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\"\n          },\n          \"amount\": 0.51\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-5-pro-251215\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"480p\",\n            \"generateAudio\": true\n          },\n          \"amount\": 0.8\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"generateAudio\": true\n          },\n          \"amount\": 1.73\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\",\n            \"generateAudio\": true\n          },\n          \"amount\": 3.89\n        },\n        {\n          \"when\": {\n            \"resolution\": \"480p\",\n            \"generateAudio\": false\n          },\n          \"amount\": 0.4\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"generateAudio\": false\n          },\n          \"amount\": 0.86\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\",\n            \"generateAudio\": false\n          },\n          \"amount\": 1.94\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-5-pro-251215-batch\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"480p\",\n            \"generateAudio\": true\n          },\n          \"amount\": 0.4\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"generateAudio\": true\n          },\n          \"amount\": 0.86\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\",\n            \"generateAudio\": true\n          },\n          \"amount\": 1.94\n        },\n        {\n          \"when\": {\n            \"resolution\": \"480p\",\n            \"generateAudio\": false\n          },\n          \"amount\": 0.2\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"generateAudio\": false\n          },\n          \"amount\": 0.43\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\",\n            \"generateAudio\": false\n          },\n          \"amount\": 0.97\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-pro-250528\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"480p\"\n          },\n          \"amount\": 0.73\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\"\n          },\n          \"amount\": 1.54\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\"\n          },\n          \"amount\": 3.67\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-pro-250528-batch\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"480p\"\n          },\n          \"amount\": 0.36\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\"\n          },\n          \"amount\": 0.77\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\"\n          },\n          \"amount\": 1.84\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-lite-i2v-250428\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"480p\"\n          },\n          \"amount\": 0.49\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\"\n          },\n          \"amount\": 1.03\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\"\n          },\n          \"amount\": 2.45\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"ark\",\n    \"modelId\": \"doubao-seedance-1-0-lite-i2v-250428-batch\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"480p\"\n          },\n          \"amount\": 0.24\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\"\n          },\n          \"amount\": 0.51\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\"\n          },\n          \"amount\": 1.22\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-3.1-generate-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4\n          },\n          \"amount\": 11.52\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6\n          },\n          \"amount\": 17.28\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8\n          },\n          \"amount\": 23.04\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8\n          },\n          \"amount\": 23.04\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"4k\",\n            \"duration\": 8\n          },\n          \"amount\": 34.56\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8\n          },\n          \"amount\": 23.04\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8\n          },\n          \"amount\": 23.04\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"4k\",\n            \"duration\": 8\n          },\n          \"amount\": 34.56\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-3.1-fast-generate-preview\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4\n          },\n          \"amount\": 4.32\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6\n          },\n          \"amount\": 6.48\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8\n          },\n          \"amount\": 8.64\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8\n          },\n          \"amount\": 8.64\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"4k\",\n            \"duration\": 8\n          },\n          \"amount\": 20.16\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8\n          },\n          \"amount\": 8.64\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8\n          },\n          \"amount\": 8.64\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"4k\",\n            \"duration\": 8\n          },\n          \"amount\": 20.16\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-3.0-generate-001\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"duration\": 4\n          },\n          \"amount\": 11.52\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"duration\": 6\n          },\n          \"amount\": 17.28\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"duration\": 8\n          },\n          \"amount\": 23.04\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\",\n            \"duration\": 8\n          },\n          \"amount\": 23.04\n        },\n        {\n          \"when\": {\n            \"resolution\": \"4k\",\n            \"duration\": 8\n          },\n          \"amount\": 23.04\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-3.0-fast-generate-001\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"duration\": 4\n          },\n          \"amount\": 4.32\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"duration\": 6\n          },\n          \"amount\": 6.48\n        },\n        {\n          \"when\": {\n            \"resolution\": \"720p\",\n            \"duration\": 8\n          },\n          \"amount\": 8.64\n        },\n        {\n          \"when\": {\n            \"resolution\": \"1080p\",\n            \"duration\": 8\n          },\n          \"amount\": 8.64\n        },\n        {\n          \"when\": {\n            \"resolution\": \"4k\",\n            \"duration\": 8\n          },\n          \"amount\": 8.64\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"google\",\n    \"modelId\": \"veo-2.0-generate-001\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"duration\": 5\n          },\n          \"amount\": 12.6\n        },\n        {\n          \"when\": {\n            \"duration\": 6\n          },\n          \"amount\": 15.12\n        },\n        {\n          \"when\": {\n            \"duration\": 8\n          },\n          \"amount\": 20.16\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-wan25\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 1.8\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-veo31\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 2.88\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-sora2\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 3.6\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-kling25\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 2.16\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-ai/kling-video/v2.5-turbo/pro/image-to-video\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"duration\": 5\n          },\n          \"amount\": 0.35\n        },\n        {\n          \"when\": {\n            \"duration\": 10\n          },\n          \"amount\": 0.7\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-ai/kling-video/v3/standard/image-to-video\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"duration\": 3\n          },\n          \"amount\": 0.504\n        },\n        {\n          \"when\": {\n            \"duration\": 4\n          },\n          \"amount\": 0.672\n        },\n        {\n          \"when\": {\n            \"duration\": 5\n          },\n          \"amount\": 0.84\n        },\n        {\n          \"when\": {\n            \"duration\": 6\n          },\n          \"amount\": 1.008\n        },\n        {\n          \"when\": {\n            \"duration\": 7\n          },\n          \"amount\": 1.176\n        },\n        {\n          \"when\": {\n            \"duration\": 8\n          },\n          \"amount\": 1.344\n        },\n        {\n          \"when\": {\n            \"duration\": 9\n          },\n          \"amount\": 1.512\n        },\n        {\n          \"when\": {\n            \"duration\": 10\n          },\n          \"amount\": 1.68\n        },\n        {\n          \"when\": {\n            \"duration\": 11\n          },\n          \"amount\": 1.848\n        },\n        {\n          \"when\": {\n            \"duration\": 12\n          },\n          \"amount\": 2.016\n        },\n        {\n          \"when\": {\n            \"duration\": 13\n          },\n          \"amount\": 2.184\n        },\n        {\n          \"when\": {\n            \"duration\": 14\n          },\n          \"amount\": 2.352\n        },\n        {\n          \"when\": {\n            \"duration\": 15\n          },\n          \"amount\": 2.52\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-ai/kling-video/v3/pro/image-to-video\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"duration\": 3\n          },\n          \"amount\": 0.672\n        },\n        {\n          \"when\": {\n            \"duration\": 4\n          },\n          \"amount\": 0.896\n        },\n        {\n          \"when\": {\n            \"duration\": 5\n          },\n          \"amount\": 1.12\n        },\n        {\n          \"when\": {\n            \"duration\": 6\n          },\n          \"amount\": 1.344\n        },\n        {\n          \"when\": {\n            \"duration\": 7\n          },\n          \"amount\": 1.568\n        },\n        {\n          \"when\": {\n            \"duration\": 8\n          },\n          \"amount\": 1.792\n        },\n        {\n          \"when\": {\n            \"duration\": 9\n          },\n          \"amount\": 2.016\n        },\n        {\n          \"when\": {\n            \"duration\": 10\n          },\n          \"amount\": 2.24\n        },\n        {\n          \"when\": {\n            \"duration\": 11\n          },\n          \"amount\": 2.464\n        },\n        {\n          \"when\": {\n            \"duration\": 12\n          },\n          \"amount\": 2.688\n        },\n        {\n          \"when\": {\n            \"duration\": 13\n          },\n          \"amount\": 2.912\n        },\n        {\n          \"when\": {\n            \"duration\": 14\n          },\n          \"amount\": 3.136\n        },\n        {\n          \"when\": {\n            \"duration\": 15\n          },\n          \"amount\": 3.36\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"minimax-hailuo-2.3\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"768p\",\n            \"duration\": 6\n          },\n          \"amount\": 2\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"768p\",\n            \"duration\": 10\n          },\n          \"amount\": 4\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6\n          },\n          \"amount\": 3.5\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"minimax-hailuo-2.3-fast\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"768p\",\n            \"duration\": 6\n          },\n          \"amount\": 1.35\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"768p\",\n            \"duration\": 10\n          },\n          \"amount\": 2.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6\n          },\n          \"amount\": 2.31\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"minimax-hailuo-02\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"512p\",\n            \"duration\": 6\n          },\n          \"amount\": 0.6\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"512p\",\n            \"duration\": 10\n          },\n          \"amount\": 1\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"768p\",\n            \"duration\": 6\n          },\n          \"amount\": 2\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"768p\",\n            \"duration\": 10\n          },\n          \"amount\": 4\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6\n          },\n          \"amount\": 3.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"768p\",\n            \"duration\": 6\n          },\n          \"amount\": 2\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"768p\",\n            \"duration\": 10\n          },\n          \"amount\": 4\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6\n          },\n          \"amount\": 3.5\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"t2v-01\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6\n          },\n          \"amount\": 3\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"minimax\",\n    \"modelId\": \"t2v-01-director\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6\n          },\n          \"amount\": 3\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq3-pro\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 11,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 11,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 12,\n            \"generateAudio\": false\n          },\n          \"amount\": 5.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 12,\n            \"generateAudio\": true\n          },\n          \"amount\": 5.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 13,\n            \"generateAudio\": false\n          },\n          \"amount\": 5.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 13,\n            \"generateAudio\": true\n          },\n          \"amount\": 5.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 14,\n            \"generateAudio\": false\n          },\n          \"amount\": 6.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 14,\n            \"generateAudio\": true\n          },\n          \"amount\": 6.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 15,\n            \"generateAudio\": false\n          },\n          \"amount\": 6.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 15,\n            \"generateAudio\": true\n          },\n          \"amount\": 6.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 16,\n            \"generateAudio\": false\n          },\n          \"amount\": 7\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 16,\n            \"generateAudio\": true\n          },\n          \"amount\": 7\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 5.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 5.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 6.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 6.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 7.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 7.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 8.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 8.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 9.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 9.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 11,\n            \"generateAudio\": false\n          },\n          \"amount\": 10.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 11,\n            \"generateAudio\": true\n          },\n          \"amount\": 10.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 12,\n            \"generateAudio\": false\n          },\n          \"amount\": 11.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 12,\n            \"generateAudio\": true\n          },\n          \"amount\": 11.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 13,\n            \"generateAudio\": false\n          },\n          \"amount\": 12.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 13,\n            \"generateAudio\": true\n          },\n          \"amount\": 12.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 14,\n            \"generateAudio\": false\n          },\n          \"amount\": 13.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 14,\n            \"generateAudio\": true\n          },\n          \"amount\": 13.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 15,\n            \"generateAudio\": false\n          },\n          \"amount\": 14.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 15,\n            \"generateAudio\": true\n          },\n          \"amount\": 14.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 16,\n            \"generateAudio\": false\n          },\n          \"amount\": 15\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 16,\n            \"generateAudio\": true\n          },\n          \"amount\": 15\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 1\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 1\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 2\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 2\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 3\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 3\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 4\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 4\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 6\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 6\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 7\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 7\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 8\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 8\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 9\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 9\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 10\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 10\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 11,\n            \"generateAudio\": false\n          },\n          \"amount\": 11\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 11,\n            \"generateAudio\": true\n          },\n          \"amount\": 11\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 12,\n            \"generateAudio\": false\n          },\n          \"amount\": 12\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 12,\n            \"generateAudio\": true\n          },\n          \"amount\": 12\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 13,\n            \"generateAudio\": false\n          },\n          \"amount\": 13\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 13,\n            \"generateAudio\": true\n          },\n          \"amount\": 13\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 14,\n            \"generateAudio\": false\n          },\n          \"amount\": 14\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 14,\n            \"generateAudio\": true\n          },\n          \"amount\": 14\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 15,\n            \"generateAudio\": false\n          },\n          \"amount\": 15\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 15,\n            \"generateAudio\": true\n          },\n          \"amount\": 15\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 16,\n            \"generateAudio\": false\n          },\n          \"amount\": 16\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 16,\n            \"generateAudio\": true\n          },\n          \"amount\": 16\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 11,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 11,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 12,\n            \"generateAudio\": false\n          },\n          \"amount\": 5.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 12,\n            \"generateAudio\": true\n          },\n          \"amount\": 5.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 13,\n            \"generateAudio\": false\n          },\n          \"amount\": 5.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 13,\n            \"generateAudio\": true\n          },\n          \"amount\": 5.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 14,\n            \"generateAudio\": false\n          },\n          \"amount\": 6.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 14,\n            \"generateAudio\": true\n          },\n          \"amount\": 6.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 15,\n            \"generateAudio\": false\n          },\n          \"amount\": 6.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 15,\n            \"generateAudio\": true\n          },\n          \"amount\": 6.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 16,\n            \"generateAudio\": false\n          },\n          \"amount\": 7\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 16,\n            \"generateAudio\": true\n          },\n          \"amount\": 7\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 5.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 5.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 6.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 6.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 7.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 7.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 8.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 8.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 9.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 9.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 11,\n            \"generateAudio\": false\n          },\n          \"amount\": 10.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 11,\n            \"generateAudio\": true\n          },\n          \"amount\": 10.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 12,\n            \"generateAudio\": false\n          },\n          \"amount\": 11.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 12,\n            \"generateAudio\": true\n          },\n          \"amount\": 11.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 13,\n            \"generateAudio\": false\n          },\n          \"amount\": 12.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 13,\n            \"generateAudio\": true\n          },\n          \"amount\": 12.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 14,\n            \"generateAudio\": false\n          },\n          \"amount\": 13.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 14,\n            \"generateAudio\": true\n          },\n          \"amount\": 13.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 15,\n            \"generateAudio\": false\n          },\n          \"amount\": 14.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 15,\n            \"generateAudio\": true\n          },\n          \"amount\": 14.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 16,\n            \"generateAudio\": false\n          },\n          \"amount\": 15\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 16,\n            \"generateAudio\": true\n          },\n          \"amount\": 15\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 1\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 1\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 2\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 2\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 3\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 3\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 4\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 4\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 6\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 6\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 7\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 7\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 8\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 8\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 9\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 9\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 10\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 10\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 11,\n            \"generateAudio\": false\n          },\n          \"amount\": 11\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 11,\n            \"generateAudio\": true\n          },\n          \"amount\": 11\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 12,\n            \"generateAudio\": false\n          },\n          \"amount\": 12\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 12,\n            \"generateAudio\": true\n          },\n          \"amount\": 12\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 13,\n            \"generateAudio\": false\n          },\n          \"amount\": 13\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 13,\n            \"generateAudio\": true\n          },\n          \"amount\": 13\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 14,\n            \"generateAudio\": false\n          },\n          \"amount\": 14\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 14,\n            \"generateAudio\": true\n          },\n          \"amount\": 14\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 15,\n            \"generateAudio\": false\n          },\n          \"amount\": 15\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 15,\n            \"generateAudio\": true\n          },\n          \"amount\": 15\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 16,\n            \"generateAudio\": false\n          },\n          \"amount\": 16\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 16,\n            \"generateAudio\": true\n          },\n          \"amount\": 16\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq2-pro-fast\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.84375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.90625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.15625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.21875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.28125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.21875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.34375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 1\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.46875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.59375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.84375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.84375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.90625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.15625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.21875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.34375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 1\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.46875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.59375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.84375\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq2-pro\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.46875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.46875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.34375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.28125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.59375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.59375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.53125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.53125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 5.46875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 5.46875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 5.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 5.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 6.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.46875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.46875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.34375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.59375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.59375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.53125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 4.53125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 5.46875\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq2-turbo\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.84375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.90625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.6875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.15625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"540p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.21875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.34375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.28125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.34375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.28125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.75\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.59375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 9,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.0625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.90625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 10,\n            \"generateAudio\": true\n          },\n          \"amount\": 4.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.84375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.90625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"540p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.3125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 0.78125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 0.9375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.34375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.09375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 1,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.5625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.40625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 2,\n            \"generateAudio\": true\n          },\n          \"amount\": 1.875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": false\n          },\n          \"amount\": 1.71875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 3,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.1875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.03125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.34375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5,\n            \"generateAudio\": true\n          },\n          \"amount\": 2.8125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.65625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 6,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": false\n          },\n          \"amount\": 2.96875\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 7,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.4375\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": false\n          },\n          \"amount\": 3.28125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 8,\n            \"generateAudio\": true\n          },\n          \"amount\": 3.75\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq1\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5\n          },\n          \"amount\": 2.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5\n          },\n          \"amount\": 2.5\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"viduq1-classic\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5\n          },\n          \"amount\": 2.5\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 5\n          },\n          \"amount\": 2.5\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"video\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"vidu2.0\",\n    \"pricing\": {\n      \"mode\": \"capability\",\n      \"tiers\": [\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"360p\",\n            \"duration\": 4\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 4\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"normal\",\n            \"resolution\": \"720p\",\n            \"duration\": 8\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"360p\",\n            \"duration\": 4\n          },\n          \"amount\": 0.625\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 4\n          },\n          \"amount\": 1.25\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"1080p\",\n            \"duration\": 4\n          },\n          \"amount\": 3.125\n        },\n        {\n          \"when\": {\n            \"generationMode\": \"firstlastframe\",\n            \"resolution\": \"720p\",\n            \"duration\": 8\n          },\n          \"amount\": 3.125\n        }\n      ]\n    }\n  },\n  {\n    \"apiType\": \"voice\",\n    \"provider\": \"system\",\n    \"modelId\": \"index-tts2\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.0144\n    }\n  },\n  {\n    \"apiType\": \"voice\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-ai/index-tts-2/text-to-speech\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.0144\n    }\n  },\n  {\n    \"apiType\": \"voice-design\",\n    \"provider\": \"system\",\n    \"modelId\": \"bailian-voice-design\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.2\n    }\n  },\n  {\n    \"apiType\": \"voice-design\",\n    \"provider\": \"bailian\",\n    \"modelId\": \"bailian\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.2\n    }\n  },\n  {\n    \"apiType\": \"lip-sync\",\n    \"provider\": \"system\",\n    \"modelId\": \"kling\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.5\n    }\n  },\n  {\n    \"apiType\": \"lip-sync\",\n    \"provider\": \"fal\",\n    \"modelId\": \"fal-ai/kling-video/lipsync/audio-to-video\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.5\n    }\n  },\n  {\n    \"apiType\": \"lip-sync\",\n    \"provider\": \"bailian\",\n    \"modelId\": \"videoretalk\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.08\n    }\n  },\n  {\n    \"apiType\": \"lip-sync\",\n    \"provider\": \"vidu\",\n    \"modelId\": \"vidu-lipsync\",\n    \"pricing\": {\n      \"mode\": \"flat\",\n      \"flatAmount\": 0.5\n    }\n  }\n]\n"
  },
  {
    "path": "standards/prompt-canary/screenplay_conversion.canary.json",
    "content": "{\n  \"clip_id\": \"clip_1\",\n  \"original_text\": \"Lena enters the hall and speaks to Victor.\",\n  \"scenes\": [\n    {\n      \"scene_number\": 1,\n      \"heading\": {\n        \"int_ext\": \"INT\",\n        \"location\": \"grand_hall_night\",\n        \"time\": \"night\"\n      },\n      \"description\": \"A large hall lit by chandeliers with long shadows on the floor.\",\n      \"characters\": [\"Lena\", \"Victor\"],\n      \"content\": [\n        {\n          \"type\": \"action\",\n          \"text\": \"Lena steps forward and places the sealed letter on the table.\"\n        },\n        {\n          \"type\": \"dialogue\",\n          \"character\": \"Lena\",\n          \"parenthetical\": \"firmly\",\n          \"lines\": \"You need to read this now.\"\n        },\n        {\n          \"type\": \"voiceover\",\n          \"character\": \"Narrator\",\n          \"text\": \"The room holds its breath.\"\n        }\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "standards/prompt-canary/story_to_script_clips.canary.json",
    "content": "[\n  {\n    \"start\": \"The morning bell rings across the old town square.\",\n    \"end\": \"She silently folds the letter and walks away.\",\n    \"summary\": \"A tense reunion begins in the square.\",\n    \"location\": \"town_square_day\",\n    \"characters\": [\"Lena\", \"Victor\"]\n  },\n  {\n    \"start\": \"At dusk, the alley lights flicker one by one.\",\n    \"end\": \"The door clicks shut behind them.\",\n    \"summary\": \"They move into a hidden back alley.\",\n    \"location\": \"old_alley_evening\",\n    \"characters\": [\"Lena\", \"Victor\", \"Guard\"]\n  }\n]\n"
  },
  {
    "path": "standards/prompt-canary/storyboard_panels.canary.json",
    "content": "[\n  {\n    \"panel_number\": 1,\n    \"description\": \"Wide shot of Lena entering the hall while Victor stands near the far table.\",\n    \"characters\": [\n      { \"name\": \"Lena\", \"appearance\": \"default\" },\n      { \"name\": \"Victor\", \"appearance\": \"formal\" }\n    ],\n    \"location\": \"grand_hall_night\",\n    \"scene_type\": \"daily\",\n    \"source_text\": \"Lena enters the hall and speaks to Victor.\",\n    \"shot_type\": \"wide shot\",\n    \"camera_move\": \"slow push in\",\n    \"video_prompt\": \"A woman walks into a grand hall and approaches a man at a long table.\",\n    \"duration\": 3\n  },\n  {\n    \"panel_number\": 2,\n    \"description\": \"Medium close-up of Lena placing a sealed letter on the table.\",\n    \"characters\": [\n      { \"name\": \"Lena\", \"appearance\": \"default\" }\n    ],\n    \"location\": \"grand_hall_night\",\n    \"scene_type\": \"emotion\",\n    \"source_text\": \"She places the letter in front of him.\",\n    \"shot_type\": \"medium close-up\",\n    \"camera_move\": \"static\",\n    \"video_prompt\": \"A young woman puts a sealed envelope on a wooden table.\",\n    \"duration\": 2\n  }\n]\n"
  },
  {
    "path": "standards/prompt-canary/voice_analysis.canary.json",
    "content": "[\n  {\n    \"lineIndex\": 1,\n    \"speaker\": \"Lena\",\n    \"content\": \"You need to read this now.\",\n    \"emotionStrength\": 0.28,\n    \"matchedPanel\": {\n      \"storyboardId\": \"sb_1\",\n      \"panelIndex\": 1\n    }\n  },\n  {\n    \"lineIndex\": 2,\n    \"speaker\": \"Victor\",\n    \"content\": \"Where did you get this letter?\",\n    \"emotionStrength\": 0.22,\n    \"matchedPanel\": {\n      \"storyboardId\": \"sb_1\",\n      \"panelIndex\": 2\n    }\n  }\n]\n"
  },
  {
    "path": "tests/concurrency/billing/ledger.concurrency.test.ts",
    "content": "import { beforeEach, describe, expect, it } from 'vitest'\nimport { calcText } from '@/lib/billing/cost'\nimport {\n  confirmChargeWithRecord,\n  freezeBalance,\n  getBalance,\n  rollbackFreeze,\n} from '@/lib/billing/ledger'\nimport { withTextBilling } from '@/lib/billing/service'\nimport { prisma } from '../../helpers/prisma'\nimport { resetBillingState } from '../../helpers/db-reset'\nimport { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'\nimport { expectNoNegativeLedger } from '../../helpers/assertions'\n\ndescribe('billing/concurrency', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n    process.env.BILLING_MODE = 'ENFORCE'\n  })\n\n  it('does not create negative balance during high-concurrency freezes', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 10)\n\n    const attempts = Array.from({ length: 40 }, (_, idx) =>\n      freezeBalance(user.id, 1, { idempotencyKey: `concurrency_freeze_${idx}` }))\n    const freezeIds = await Promise.all(attempts)\n    const successCount = freezeIds.filter(Boolean).length\n\n    const balance = await getBalance(user.id)\n    expect(successCount).toBeLessThanOrEqual(10)\n    expect(balance.balance).toBeCloseTo(10 - successCount, 8)\n    expect(balance.frozenAmount).toBeCloseTo(successCount, 8)\n    await expectNoNegativeLedger(user.id)\n  })\n\n  it('applies idempotency key correctly under concurrent duplicate requests', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 10)\n\n    const attempts = Array.from({ length: 20 }, () =>\n      freezeBalance(user.id, 2, { idempotencyKey: 'same_key_concurrency' }))\n    const freezeIds = await Promise.all(attempts)\n    const uniqueIds = new Set(freezeIds.filter(Boolean))\n\n    expect(uniqueIds.size).toBe(1)\n    const balance = await getBalance(user.id)\n    expect(balance.balance).toBeCloseTo(8, 8)\n    expect(balance.frozenAmount).toBeCloseTo(2, 8)\n    expect(await prisma.balanceFreeze.count()).toBe(1)\n  })\n\n  it('keeps a valid final state when confirm and rollback race', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 10)\n\n    const freezeId = await freezeBalance(user.id, 5, { idempotencyKey: 'race_key' })\n    expect(freezeId).toBeTruthy()\n\n    const [confirmResult, rollbackResult] = await Promise.allSettled([\n      confirmChargeWithRecord(\n        freezeId!,\n        {\n          projectId: project.id,\n          action: 'race_confirm',\n          apiType: 'text',\n          model: 'anthropic/claude-sonnet-4',\n          quantity: 10,\n          unit: 'token',\n        },\n        { chargedAmount: 3 },\n      ),\n      rollbackFreeze(freezeId!),\n    ])\n\n    expect(['fulfilled', 'rejected']).toContain(confirmResult.status)\n    expect(['fulfilled', 'rejected']).toContain(rollbackResult.status)\n    expect(confirmResult.status === 'fulfilled' || rollbackResult.status === 'fulfilled').toBe(true)\n\n    const freeze = await prisma.balanceFreeze.findUnique({ where: { id: freezeId! } })\n    expect(['confirmed', 'rolled_back']).toContain(freeze?.status)\n\n    const balance = await getBalance(user.id)\n    if (freeze?.status === 'confirmed') {\n      expect(balance.balance).toBeCloseTo(7, 8)\n      expect(balance.totalSpent).toBeCloseTo(3, 8)\n    } else {\n      expect(balance.balance).toBeCloseTo(10, 8)\n      expect(balance.totalSpent).toBeCloseTo(0, 8)\n    }\n    expect(balance.frozenAmount).toBeCloseTo(0, 8)\n    await expectNoNegativeLedger(user.id)\n  })\n\n  it('prevents duplicate consumption on retried sync billing with same requestId', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 5)\n\n    const attempt = () =>\n      withTextBilling(\n        user.id,\n        'anthropic/claude-sonnet-4',\n        1000,\n        500,\n        {\n          projectId: project.id,\n          action: 'retry_no_double_charge',\n          requestId: 'fixed_request_id',\n        },\n        async () => ({ ok: true }),\n      )\n\n    const results = await Promise.allSettled([attempt(), attempt(), attempt()])\n    expect(results.some((item) => item.status === 'fulfilled')).toBe(true)\n\n    const balance = await getBalance(user.id)\n    const expected = calcText('anthropic/claude-sonnet-4', 1000, 500)\n    expect(balance.totalSpent).toBeLessThanOrEqual(expected + 1e-8)\n    expect(await prisma.balanceFreeze.count()).toBe(1)\n    expect(await prisma.balanceTransaction.count({ where: { type: 'consume' } })).toBeLessThanOrEqual(1)\n    await expectNoNegativeLedger(user.id)\n  })\n})\n"
  },
  {
    "path": "tests/contracts/behavior-test-standard.md",
    "content": "# Behavior Test Standard\n\n## Scope\n- `tests/integration/api/contract/**/*.test.ts`\n- `tests/integration/provider/**/*.test.ts`\n- `tests/integration/chain/**/*.test.ts`\n- `tests/system/**/*.test.ts`\n- `tests/regression/**/*.test.ts`\n- `tests/unit/worker/**/*.test.ts`\n\n## Must-have\n- Assert observable results: response payload/status, persisted fields, or queue/job payload.\n- Include at least one concrete-value assertion for each key business branch.\n- Cover at least one failure branch for each critical route/handler.\n\n## Forbidden patterns\n- Source-text contract assertions (for example checking route code contains `apiHandler`, `submitTask`, `maybeSubmitLLMTask`).\n- Using only weak call assertions like `toHaveBeenCalled()` as the primary proof.\n- Structural tests that pass without executing route/worker logic.\n\n## Minimum assertion quality\n- Prefer `toHaveBeenCalledWith(...)` with `objectContaining(...)` on critical fields.\n- Validate exact business fields (`description`, `imageUrl`, `referenceImages`, `aspectRatio`, `taskId`, `async`).\n- For async task chains, validate queue selection and job metadata (`jobId`, `priority`, `type`).\n\n## Regression rule\n- One historical bug must map to at least one dedicated regression test case.\n- Bug fix without matching behavior regression test is incomplete.\n- Provider or gateway protocol changes must add a provider contract test or update an existing localhost fake-provider scenario.\n"
  },
  {
    "path": "tests/contracts/requirements-matrix.test.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\nimport { describe, expect, it } from 'vitest'\nimport { REQUIREMENTS_MATRIX } from './requirements-matrix'\n\nfunction fileExists(repoPath: string) {\n  return fs.existsSync(path.resolve(process.cwd(), repoPath))\n}\n\ndescribe('requirements matrix integrity', () => {\n  it('requirement ids are unique', () => {\n    const ids = REQUIREMENTS_MATRIX.map((entry) => entry.id)\n    expect(new Set(ids).size).toBe(ids.length)\n  })\n\n  it('all declared test files exist', () => {\n    for (const entry of REQUIREMENTS_MATRIX) {\n      expect(entry.tests.length, entry.id).toBeGreaterThan(0)\n      for (const testPath of entry.tests) {\n        expect(fileExists(testPath), `${entry.id} -> ${testPath}`).toBe(true)\n      }\n    }\n  })\n})\n"
  },
  {
    "path": "tests/contracts/requirements-matrix.ts",
    "content": "export type RequirementPriority = 'P0' | 'P1' | 'P2'\n\nexport type RequirementCoverageEntry = {\n  id: string\n  feature: string\n  userValue: string\n  risk: string\n  priority: RequirementPriority\n  tests: ReadonlyArray<string>\n}\n\nexport const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [\n  {\n    id: 'REQ-ASSETHUB-CHARACTER-EDIT',\n    feature: 'Asset Hub character edit',\n    userValue: '角色信息编辑后立即可见并正确保存',\n    risk: '字段映射漂移导致保存失败或误写',\n    priority: 'P0',\n    tests: [\n      'tests/integration/api/contract/crud-routes.test.ts',\n      'tests/integration/chain/text.chain.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-ASSETHUB-REFERENCE-TO-CHARACTER',\n    feature: 'Asset Hub reference-to-character',\n    userValue: '上传参考图后生成角色形象且使用参考图',\n    risk: 'referenceImages 丢失或分支走错',\n    priority: 'P0',\n    tests: [\n      'tests/unit/helpers/reference-to-character-helpers.test.ts',\n      'tests/unit/worker/reference-to-character.test.ts',\n      'tests/integration/chain/text.chain.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-NP-GENERATE-IMAGE',\n    feature: 'Novel promotion image generation',\n    userValue: '角色/场景/分镜图可稳定生成并回写',\n    risk: '任务 payload 漂移、worker 写回错误实体',\n    priority: 'P0',\n    tests: [\n      'tests/integration/api/contract/direct-submit-routes.test.ts',\n      'tests/unit/worker/image-task-handlers-core.test.ts',\n      'tests/integration/chain/image.chain.test.ts',\n      'tests/system/generate-image.system.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-NP-GENERATE-VIDEO',\n    feature: 'Novel promotion video generation',\n    userValue: '面板视频可生成并可追踪状态',\n    risk: 'panel 定位错误、model 能力判断错误、状态错乱',\n    priority: 'P0',\n    tests: [\n      'tests/integration/api/contract/direct-submit-routes.test.ts',\n      'tests/unit/worker/video-worker.test.ts',\n      'tests/integration/chain/video.chain.test.ts',\n      'tests/system/generate-video.system.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-NP-INSERT-PANEL-AUTO-ANALYZE',\n    feature: 'Novel promotion insert panel',\n    userValue: 'AI 自动分析插入分镜时不会因空输入失败',\n    risk: 'route 与 worker 契约分叉导致异步任务直接报错',\n    priority: 'P0',\n    tests: [\n      'tests/unit/novel-promotion/insert-panel-user-input.test.ts',\n      'tests/integration/api/contract/direct-submit-routes.test.ts',\n      'tests/system/text-workflow.system.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-NP-PANEL-VARIANT-SAFETY',\n    feature: 'Novel promotion panel variant',\n    userValue: '镜头变体只能插入当前 storyboard，任务失败可回滚，资产开关真实生效',\n    risk: '跨分镜误插入、创建脏 panel、参考图开关失效',\n    priority: 'P0',\n    tests: [\n      'tests/integration/api/specific/panel-variant-route.test.ts',\n      'tests/integration/api/contract/direct-submit-routes.test.ts',\n      'tests/unit/worker/panel-variant-task-handler.test.ts',\n      'tests/regression/panel-variant-cross-storyboard.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-NP-TEXT-ANALYSIS',\n    feature: 'Text analysis and storyboard orchestration',\n    userValue: '文本分析链路稳定并可回放结果',\n    risk: 'step 编排变化导致结果结构损坏',\n    priority: 'P1',\n    tests: [\n      'tests/integration/api/contract/llm-observe-routes.test.ts',\n      'tests/unit/worker/script-to-storyboard.test.ts',\n      'tests/integration/chain/text.chain.test.ts',\n      'tests/system/text-workflow.system.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-TASK-STATE-CONSISTENCY',\n    feature: 'Task state and SSE consistency',\n    userValue: '前端状态与任务真实状态一致',\n    risk: 'target-state 与 SSE 失配导致误提示',\n    priority: 'P0',\n    tests: [\n      'tests/unit/helpers/task-state-service.test.ts',\n      'tests/integration/api/contract/task-infra-routes.test.ts',\n      'tests/integration/task/create-task-dedupe.integration.test.ts',\n      'tests/unit/optimistic/sse-invalidation.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-PROVIDER-PROTOCOL-CONTRACT',\n    feature: 'Provider protocol contract',\n    userValue: '外部 provider 请求格式、轮询状态和错误分类保持稳定',\n    risk: 'provider 协议漂移导致系统链路仅在真实调用时失败',\n    priority: 'P0',\n    tests: [\n      'tests/integration/provider/fal-provider.contract.test.ts',\n      'tests/integration/provider/openai-compat-provider.contract.test.ts',\n      'tests/unit/task/async-poll-external-id.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-TASK-DEDUPE-COMPENSATION',\n    feature: 'Task dedupe and enqueue compensation',\n    userValue: '重复提交不会卡死，队列失败不会留下脏冻结或孤儿任务',\n    risk: '重复任务、孤儿 dedupeKey、enqueue 失败后冻结金额未回滚',\n    priority: 'P0',\n    tests: [\n      'tests/integration/task/create-task-dedupe.integration.test.ts',\n      'tests/integration/billing/submitter.integration.test.ts',\n      'tests/regression/task-dedupe-recovery.test.ts',\n      'tests/regression/task-enqueue-billing-rollback.test.ts',\n      'tests/unit/worker/user-concurrency-gate.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-API-CONFIG-TUTORIAL-PORTAL',\n    feature: 'API config tutorial modal layering',\n    userValue: '开通教程浮层只高亮当前教程，不污染其他 provider card',\n    risk: '弹层挂载在局部层叠上下文内，导致高亮重叠和误覆盖',\n    priority: 'P1',\n    tests: [\n      'tests/unit/api-config/provider-card-tutorial-modal.test.ts',\n    ],\n  },\n  {\n    id: 'REQ-INFRA-PUBLIC-ROUTES',\n    feature: 'Infra and public routes',\n    userValue: '基础公共路由可稳定访问，公开范围明确且有测试兜底',\n    risk: '特殊公开路由缺少约束或回归覆盖，导致泄漏、误拦截或行为漂移',\n    priority: 'P1',\n    tests: [\n      'tests/integration/api/contract/infra-routes.test.ts',\n    ],\n  },\n]\n"
  },
  {
    "path": "tests/contracts/route-behavior-matrix.ts",
    "content": "import { ROUTE_CATALOG, type RouteCatalogEntry } from './route-catalog'\n\nexport type RouteBehaviorMatrixEntry = {\n  routeFile: string\n  contractGroup: RouteCatalogEntry['contractGroup']\n  caseId: string\n  tests: ReadonlyArray<string>\n}\n\nconst CONTRACT_TEST_BY_GROUP: Record<RouteCatalogEntry['contractGroup'], string> = {\n  'llm-observe-routes': 'tests/integration/api/contract/llm-observe-routes.test.ts',\n  'direct-submit-routes': 'tests/integration/api/contract/direct-submit-routes.test.ts',\n  'crud-asset-hub-routes': 'tests/integration/api/contract/crud-routes.test.ts',\n  'crud-novel-promotion-routes': 'tests/integration/api/contract/crud-routes.test.ts',\n  'task-infra-routes': 'tests/integration/api/contract/task-infra-routes.test.ts',\n  'user-project-routes': 'tests/integration/api/contract/crud-routes.test.ts',\n  'auth-routes': 'tests/integration/api/contract/crud-routes.test.ts',\n  'infra-routes': 'tests/integration/api/contract/infra-routes.test.ts',\n}\n\nfunction resolveChainTest(routeFile: string): string {\n  if (routeFile.includes('/generate-video/') || routeFile.includes('/lip-sync/')) {\n    return 'tests/integration/chain/video.chain.test.ts'\n  }\n  if (routeFile.includes('/voice-') || routeFile.includes('/voice/')) {\n    return 'tests/integration/chain/voice.chain.test.ts'\n  }\n  if (\n    routeFile.includes('/analyze')\n    || routeFile.includes('/story-to-script')\n    || routeFile.includes('/script-to-storyboard')\n    || routeFile.includes('/screenplay-conversion')\n    || routeFile.includes('/reference-to-character')\n  ) {\n    return 'tests/integration/chain/text.chain.test.ts'\n  }\n  return 'tests/integration/chain/image.chain.test.ts'\n}\n\nexport const ROUTE_BEHAVIOR_MATRIX: ReadonlyArray<RouteBehaviorMatrixEntry> = ROUTE_CATALOG.map((entry) => ({\n  routeFile: entry.routeFile,\n  contractGroup: entry.contractGroup,\n  caseId: `ROUTE:${entry.routeFile.replace(/^src\\/app\\/api\\//, '').replace(/\\/route\\.ts$/, '')}`,\n  tests: [\n    CONTRACT_TEST_BY_GROUP[entry.contractGroup],\n    resolveChainTest(entry.routeFile),\n  ],\n}))\n\nexport const ROUTE_BEHAVIOR_COUNT = ROUTE_BEHAVIOR_MATRIX.length\n"
  },
  {
    "path": "tests/contracts/route-catalog.ts",
    "content": "export type RouteCategory =\n  | 'asset-hub'\n  | 'novel-promotion'\n  | 'projects'\n  | 'tasks'\n  | 'user'\n  | 'auth'\n  | 'infra'\n  | 'system'\n\nexport type RouteContractGroup =\n  | 'llm-observe-routes'\n  | 'direct-submit-routes'\n  | 'crud-asset-hub-routes'\n  | 'crud-novel-promotion-routes'\n  | 'task-infra-routes'\n  | 'user-project-routes'\n  | 'auth-routes'\n  | 'infra-routes'\n\nexport type RouteCatalogEntry = {\n  routeFile: string\n  category: RouteCategory\n  contractGroup: RouteContractGroup\n}\n\nconst ROUTE_FILES = [\n  'src/app/api/admin/download-logs/route.ts',\n  'src/app/api/asset-hub/ai-design-character/route.ts',\n  'src/app/api/asset-hub/ai-design-location/route.ts',\n  'src/app/api/asset-hub/ai-modify-character/route.ts',\n  'src/app/api/asset-hub/ai-modify-location/route.ts',\n  'src/app/api/asset-hub/appearances/route.ts',\n  'src/app/api/asset-hub/character-voice/route.ts',\n  'src/app/api/asset-hub/characters/[characterId]/appearances/[appearanceIndex]/route.ts',\n  'src/app/api/asset-hub/characters/[characterId]/route.ts',\n  'src/app/api/asset-hub/characters/route.ts',\n  'src/app/api/asset-hub/folders/[folderId]/route.ts',\n  'src/app/api/asset-hub/folders/route.ts',\n  'src/app/api/asset-hub/generate-image/route.ts',\n  'src/app/api/asset-hub/locations/[locationId]/route.ts',\n  'src/app/api/asset-hub/locations/route.ts',\n  'src/app/api/asset-hub/modify-image/route.ts',\n  'src/app/api/asset-hub/picker/route.ts',\n  'src/app/api/asset-hub/reference-to-character/route.ts',\n  'src/app/api/asset-hub/select-image/route.ts',\n  'src/app/api/asset-hub/undo-image/route.ts',\n  'src/app/api/asset-hub/update-asset-label/route.ts',\n  'src/app/api/asset-hub/upload-image/route.ts',\n  'src/app/api/asset-hub/upload-temp/route.ts',\n  'src/app/api/asset-hub/voice-design/route.ts',\n  'src/app/api/asset-hub/voices/[id]/route.ts',\n  'src/app/api/asset-hub/voices/route.ts',\n  'src/app/api/asset-hub/voices/upload/route.ts',\n  'src/app/api/auth/[...nextauth]/route.ts',\n  'src/app/api/auth/register/route.ts',\n  'src/app/api/cos/image/route.ts',\n  'src/app/api/files/[...path]/route.ts',\n  'src/app/api/storage/sign/route.ts',\n  'src/app/api/novel-promotion/[projectId]/ai-create-character/route.ts',\n  'src/app/api/novel-promotion/[projectId]/ai-create-location/route.ts',\n  'src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts',\n  'src/app/api/novel-promotion/[projectId]/ai-modify-location/route.ts',\n  'src/app/api/novel-promotion/[projectId]/ai-modify-shot-prompt/route.ts',\n  'src/app/api/novel-promotion/[projectId]/analyze-global/route.ts',\n  'src/app/api/novel-promotion/[projectId]/analyze-shot-variants/route.ts',\n  'src/app/api/novel-promotion/[projectId]/analyze/route.ts',\n  'src/app/api/novel-promotion/[projectId]/assets/route.ts',\n  'src/app/api/novel-promotion/[projectId]/character-profile/batch-confirm/route.ts',\n  'src/app/api/novel-promotion/[projectId]/character-profile/confirm/route.ts',\n  'src/app/api/novel-promotion/[projectId]/character-voice/route.ts',\n  'src/app/api/novel-promotion/[projectId]/character/appearance/route.ts',\n  'src/app/api/novel-promotion/[projectId]/character/confirm-selection/route.ts',\n  'src/app/api/novel-promotion/[projectId]/character/route.ts',\n  'src/app/api/novel-promotion/[projectId]/cleanup-unselected-images/route.ts',\n  'src/app/api/novel-promotion/[projectId]/clips/[clipId]/route.ts',\n  'src/app/api/novel-promotion/[projectId]/clips/route.ts',\n  'src/app/api/novel-promotion/[projectId]/copy-from-global/route.ts',\n  'src/app/api/novel-promotion/[projectId]/download-images/route.ts',\n  'src/app/api/novel-promotion/[projectId]/download-videos/route.ts',\n  'src/app/api/novel-promotion/[projectId]/download-voices/route.ts',\n  'src/app/api/novel-promotion/[projectId]/editor/route.ts',\n  'src/app/api/novel-promotion/[projectId]/episodes/[episodeId]/route.ts',\n  'src/app/api/novel-promotion/[projectId]/episodes/batch/route.ts',\n  'src/app/api/novel-promotion/[projectId]/episodes/route.ts',\n  'src/app/api/novel-promotion/[projectId]/episodes/split-by-markers/route.ts',\n  'src/app/api/novel-promotion/[projectId]/episodes/split/route.ts',\n  'src/app/api/novel-promotion/[projectId]/generate-character-image/route.ts',\n  'src/app/api/novel-promotion/[projectId]/generate-image/route.ts',\n  'src/app/api/novel-promotion/[projectId]/generate-video/route.ts',\n  'src/app/api/novel-promotion/[projectId]/insert-panel/route.ts',\n  'src/app/api/novel-promotion/[projectId]/lip-sync/route.ts',\n  'src/app/api/novel-promotion/[projectId]/location/confirm-selection/route.ts',\n  'src/app/api/novel-promotion/[projectId]/location/route.ts',\n  'src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts',\n  'src/app/api/novel-promotion/[projectId]/modify-storyboard-image/route.ts',\n  'src/app/api/novel-promotion/[projectId]/panel-link/route.ts',\n  'src/app/api/novel-promotion/[projectId]/panel-variant/route.ts',\n  'src/app/api/novel-promotion/[projectId]/panel/route.ts',\n  'src/app/api/novel-promotion/[projectId]/panel/select-candidate/route.ts',\n  'src/app/api/novel-promotion/[projectId]/photography-plan/route.ts',\n  'src/app/api/novel-promotion/[projectId]/reference-to-character/route.ts',\n  'src/app/api/novel-promotion/[projectId]/regenerate-group/route.ts',\n  'src/app/api/novel-promotion/[projectId]/regenerate-panel-image/route.ts',\n  'src/app/api/novel-promotion/[projectId]/regenerate-single-image/route.ts',\n  'src/app/api/novel-promotion/[projectId]/regenerate-storyboard-text/route.ts',\n  'src/app/api/novel-promotion/[projectId]/route.ts',\n  'src/app/api/novel-promotion/[projectId]/screenplay-conversion/route.ts',\n  'src/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route.ts',\n  'src/app/api/novel-promotion/[projectId]/select-character-image/route.ts',\n  'src/app/api/novel-promotion/[projectId]/select-location-image/route.ts',\n  'src/app/api/novel-promotion/[projectId]/speaker-voice/route.ts',\n  'src/app/api/novel-promotion/[projectId]/story-to-script-stream/route.ts',\n  'src/app/api/novel-promotion/[projectId]/storyboard-group/route.ts',\n  'src/app/api/novel-promotion/[projectId]/storyboards/route.ts',\n  'src/app/api/novel-promotion/[projectId]/undo-regenerate/route.ts',\n  'src/app/api/novel-promotion/[projectId]/update-appearance/route.ts',\n  'src/app/api/novel-promotion/[projectId]/update-asset-label/route.ts',\n  'src/app/api/novel-promotion/[projectId]/update-location/route.ts',\n  'src/app/api/novel-promotion/[projectId]/update-prompt/route.ts',\n  'src/app/api/novel-promotion/[projectId]/upload-asset-image/route.ts',\n  'src/app/api/novel-promotion/[projectId]/video-proxy/route.ts',\n  'src/app/api/novel-promotion/[projectId]/video-urls/route.ts',\n  'src/app/api/novel-promotion/[projectId]/voice-analyze/route.ts',\n  'src/app/api/novel-promotion/[projectId]/voice-design/route.ts',\n  'src/app/api/novel-promotion/[projectId]/voice-generate/route.ts',\n  'src/app/api/novel-promotion/[projectId]/voice-lines/route.ts',\n  'src/app/api/projects/[projectId]/assets/route.ts',\n  'src/app/api/projects/[projectId]/costs/route.ts',\n  'src/app/api/projects/[projectId]/data/route.ts',\n  'src/app/api/projects/[projectId]/route.ts',\n  'src/app/api/projects/route.ts',\n  'src/app/api/runs/[runId]/cancel/route.ts',\n  'src/app/api/runs/[runId]/events/route.ts',\n  'src/app/api/runs/[runId]/route.ts',\n  'src/app/api/runs/[runId]/steps/[stepKey]/retry/route.ts',\n  'src/app/api/runs/route.ts',\n  'src/app/api/sse/route.ts',\n  'src/app/api/system/boot-id/route.ts',\n  'src/app/api/task-target-states/route.ts',\n  'src/app/api/tasks/[taskId]/route.ts',\n  'src/app/api/tasks/dismiss/route.ts',\n  'src/app/api/tasks/route.ts',\n  'src/app/api/user-preference/route.ts',\n  'src/app/api/user/api-config/route.ts',\n  'src/app/api/user/assistant/chat/route.ts',\n  'src/app/api/user/api-config/assistant/validate-media-template/route.ts',\n  'src/app/api/user/api-config/assistant/probe-media-template/route.ts',\n  'src/app/api/user/api-config/probe-model-llm-protocol/route.ts',\n  'src/app/api/user/api-config/test-connection/route.ts',\n  'src/app/api/user/api-config/test-provider/route.ts',\n  'src/app/api/user/balance/route.ts',\n  'src/app/api/user/costs/details/route.ts',\n  'src/app/api/user/costs/route.ts',\n  'src/app/api/user/models/route.ts',\n  'src/app/api/user/transactions/route.ts',\n] as const\n\nfunction resolveCategory(routeFile: string): RouteCategory {\n  if (routeFile.startsWith('src/app/api/asset-hub/')) return 'asset-hub'\n  if (routeFile.startsWith('src/app/api/novel-promotion/')) return 'novel-promotion'\n  if (routeFile.startsWith('src/app/api/projects/')) return 'projects'\n  if (\n    routeFile.startsWith('src/app/api/tasks/')\n    || routeFile.startsWith('src/app/api/runs/')\n    || routeFile === 'src/app/api/task-target-states/route.ts'\n  ) {\n    return 'tasks'\n  }\n  if (routeFile.startsWith('src/app/api/user/') || routeFile === 'src/app/api/user-preference/route.ts') return 'user'\n  if (routeFile.startsWith('src/app/api/auth/')) return 'auth'\n  if (routeFile.startsWith('src/app/api/system/')) return 'system'\n  return 'infra'\n}\n\nfunction resolveContractGroup(routeFile: string): RouteContractGroup {\n  if (\n    routeFile.includes('/ai-')\n    || routeFile.includes('/analyze')\n    || routeFile.includes('/story-to-script-stream/')\n    || routeFile.includes('/script-to-storyboard-stream/')\n    || routeFile.includes('/screenplay-conversion/')\n    || routeFile.includes('/reference-to-character/')\n    || routeFile.includes('/character-profile/')\n    || routeFile.endsWith('/clips/route.ts')\n    || routeFile.endsWith('/episodes/split/route.ts')\n    || routeFile.endsWith('/voice-analyze/route.ts')\n  ) {\n    return 'llm-observe-routes'\n  }\n  if (\n    routeFile.endsWith('/generate-image/route.ts')\n    || routeFile.endsWith('/generate-video/route.ts')\n    || routeFile.endsWith('/modify-image/route.ts')\n    || routeFile.endsWith('/voice-design/route.ts')\n    || routeFile.endsWith('/insert-panel/route.ts')\n    || routeFile.endsWith('/lip-sync/route.ts')\n    || routeFile.endsWith('/modify-asset-image/route.ts')\n    || routeFile.endsWith('/modify-storyboard-image/route.ts')\n    || routeFile.endsWith('/panel-variant/route.ts')\n    || routeFile.endsWith('/regenerate-group/route.ts')\n    || routeFile.endsWith('/regenerate-panel-image/route.ts')\n    || routeFile.endsWith('/regenerate-single-image/route.ts')\n    || routeFile.endsWith('/regenerate-storyboard-text/route.ts')\n    || routeFile.endsWith('/voice-generate/route.ts')\n  ) {\n    return 'direct-submit-routes'\n  }\n  if (routeFile.startsWith('src/app/api/asset-hub/')) return 'crud-asset-hub-routes'\n  if (routeFile.startsWith('src/app/api/novel-promotion/')) return 'crud-novel-promotion-routes'\n  if (\n    routeFile.startsWith('src/app/api/tasks/')\n    || routeFile.startsWith('src/app/api/runs/')\n    || routeFile === 'src/app/api/task-target-states/route.ts'\n    || routeFile === 'src/app/api/sse/route.ts'\n  ) {\n    return 'task-infra-routes'\n  }\n  if (routeFile.startsWith('src/app/api/projects/') || routeFile.startsWith('src/app/api/user/')) {\n    return 'user-project-routes'\n  }\n  if (routeFile.startsWith('src/app/api/auth/')) return 'auth-routes'\n  return 'infra-routes'\n}\n\nexport const ROUTE_CATALOG: ReadonlyArray<RouteCatalogEntry> = ROUTE_FILES.map((routeFile) => ({\n  routeFile,\n  category: resolveCategory(routeFile),\n  contractGroup: resolveContractGroup(routeFile),\n}))\n\nexport const ROUTE_COUNT = ROUTE_CATALOG.length\n"
  },
  {
    "path": "tests/contracts/task-type-catalog.ts",
    "content": "import { TASK_TYPE, type TaskType } from '@/lib/task/types'\n\nexport type TaskTestLayer = 'unit-helper' | 'worker-unit' | 'api-contract' | 'chain'\n\nexport type TaskTypeCoverageEntry = {\n  taskType: TaskType\n  owner: string\n  layers: ReadonlyArray<TaskTestLayer>\n}\n\nconst TASK_TYPE_OWNER_MAP = {\n  [TASK_TYPE.IMAGE_PANEL]: 'tests/unit/worker/panel-image-task-handler.test.ts',\n  [TASK_TYPE.IMAGE_CHARACTER]: 'tests/unit/worker/character-image-task-handler.test.ts',\n  [TASK_TYPE.IMAGE_LOCATION]: 'tests/unit/worker/location-image-task-handler.test.ts',\n  [TASK_TYPE.VIDEO_PANEL]: 'tests/unit/worker/video-worker.test.ts',\n  [TASK_TYPE.LIP_SYNC]: 'tests/unit/worker/video-worker.test.ts',\n  [TASK_TYPE.VOICE_LINE]: 'tests/unit/worker/voice-worker.test.ts',\n  [TASK_TYPE.VOICE_DESIGN]: 'tests/unit/worker/voice-worker.test.ts',\n  [TASK_TYPE.ASSET_HUB_VOICE_DESIGN]: 'tests/unit/worker/voice-worker.test.ts',\n  [TASK_TYPE.REGENERATE_STORYBOARD_TEXT]: 'tests/unit/worker/script-to-storyboard.test.ts',\n  [TASK_TYPE.INSERT_PANEL]: 'tests/unit/worker/script-to-storyboard.test.ts',\n  [TASK_TYPE.PANEL_VARIANT]: 'tests/unit/worker/panel-variant-task-handler.test.ts',\n  [TASK_TYPE.MODIFY_ASSET_IMAGE]: 'tests/unit/worker/image-task-handlers-core.test.ts',\n  [TASK_TYPE.REGENERATE_GROUP]: 'tests/unit/worker/image-task-handlers-core.test.ts',\n  [TASK_TYPE.ASSET_HUB_IMAGE]: 'tests/unit/worker/asset-hub-image-suffix.test.ts',\n  [TASK_TYPE.ASSET_HUB_MODIFY]: 'tests/unit/worker/modify-image-reference-description.test.ts',\n  [TASK_TYPE.ANALYZE_NOVEL]: 'tests/unit/worker/analyze-novel.test.ts',\n  [TASK_TYPE.STORY_TO_SCRIPT_RUN]: 'tests/unit/worker/story-to-script.test.ts',\n  [TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN]: 'tests/unit/worker/script-to-storyboard.test.ts',\n  [TASK_TYPE.CLIPS_BUILD]: 'tests/unit/worker/clips-build.test.ts',\n  [TASK_TYPE.SCREENPLAY_CONVERT]: 'tests/unit/worker/screenplay-convert.test.ts',\n  [TASK_TYPE.VOICE_ANALYZE]: 'tests/unit/worker/voice-analyze.test.ts',\n  [TASK_TYPE.ANALYZE_GLOBAL]: 'tests/unit/worker/analyze-global.test.ts',\n  [TASK_TYPE.AI_MODIFY_APPEARANCE]: 'tests/unit/worker/shot-ai-prompt-appearance.test.ts',\n  [TASK_TYPE.AI_MODIFY_LOCATION]: 'tests/unit/worker/shot-ai-prompt-location.test.ts',\n  [TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'tests/unit/worker/shot-ai-prompt-shot.test.ts',\n  [TASK_TYPE.ANALYZE_SHOT_VARIANTS]: 'tests/unit/worker/shot-ai-variants.test.ts',\n  [TASK_TYPE.AI_CREATE_CHARACTER]: 'tests/unit/worker/shot-ai-tasks.test.ts',\n  [TASK_TYPE.AI_CREATE_LOCATION]: 'tests/unit/worker/shot-ai-tasks.test.ts',\n  [TASK_TYPE.REFERENCE_TO_CHARACTER]: 'tests/unit/worker/reference-to-character.test.ts',\n  [TASK_TYPE.CHARACTER_PROFILE_CONFIRM]: 'tests/unit/worker/character-profile.test.ts',\n  [TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM]: 'tests/unit/worker/character-profile.test.ts',\n  [TASK_TYPE.EPISODE_SPLIT_LLM]: 'tests/unit/worker/episode-split.test.ts',\n  [TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER]: 'tests/unit/worker/asset-hub-ai-design.test.ts',\n  [TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: 'tests/unit/worker/asset-hub-ai-design.test.ts',\n  [TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: 'tests/unit/worker/asset-hub-ai-modify.test.ts',\n  [TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: 'tests/unit/worker/asset-hub-ai-modify.test.ts',\n  [TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: 'tests/unit/worker/reference-to-character.test.ts',\n} as const satisfies Record<TaskType, string>\n\nexport const TASK_TYPE_CATALOG: ReadonlyArray<TaskTypeCoverageEntry> = (Object.values(TASK_TYPE) as TaskType[])\n  .map((taskType) => ({\n    taskType,\n    owner: TASK_TYPE_OWNER_MAP[taskType],\n    layers: ['worker-unit', 'api-contract', 'chain'],\n  }))\n\nexport const TASK_TYPE_COUNT = TASK_TYPE_CATALOG.length\n"
  },
  {
    "path": "tests/contracts/tasktype-behavior-matrix.ts",
    "content": "import { TASK_TYPE_CATALOG } from './task-type-catalog'\nimport type { TaskType } from '@/lib/task/types'\n\nexport type TaskTypeBehaviorMatrixEntry = {\n  taskType: TaskType\n  caseId: string\n  workerTest: string\n  chainTest: string\n  apiContractTest: string\n}\n\nfunction resolveChainTestByTaskType(taskType: TaskType): string {\n  if (taskType === 'video_panel' || taskType === 'lip_sync') {\n    return 'tests/integration/chain/video.chain.test.ts'\n  }\n  if (taskType === 'voice_line' || taskType === 'voice_design' || taskType === 'asset_hub_voice_design') {\n    return 'tests/integration/chain/voice.chain.test.ts'\n  }\n  if (\n    taskType === 'analyze_novel'\n    || taskType === 'story_to_script_run'\n    || taskType === 'script_to_storyboard_run'\n    || taskType === 'clips_build'\n    || taskType === 'screenplay_convert'\n    || taskType === 'voice_analyze'\n    || taskType === 'analyze_global'\n    || taskType === 'ai_modify_appearance'\n    || taskType === 'ai_modify_location'\n    || taskType === 'ai_modify_shot_prompt'\n    || taskType === 'analyze_shot_variants'\n    || taskType === 'ai_create_character'\n    || taskType === 'ai_create_location'\n    || taskType === 'reference_to_character'\n    || taskType === 'character_profile_confirm'\n    || taskType === 'character_profile_batch_confirm'\n    || taskType === 'episode_split_llm'\n    || taskType === 'asset_hub_ai_design_character'\n    || taskType === 'asset_hub_ai_design_location'\n    || taskType === 'asset_hub_ai_modify_character'\n    || taskType === 'asset_hub_ai_modify_location'\n    || taskType === 'asset_hub_reference_to_character'\n  ) {\n    return 'tests/integration/chain/text.chain.test.ts'\n  }\n  return 'tests/integration/chain/image.chain.test.ts'\n}\n\nfunction resolveApiContractByTaskType(taskType: TaskType): string {\n  if (\n    taskType === 'analyze_novel'\n    || taskType === 'story_to_script_run'\n    || taskType === 'script_to_storyboard_run'\n    || taskType === 'clips_build'\n    || taskType === 'screenplay_convert'\n    || taskType === 'voice_analyze'\n    || taskType === 'analyze_global'\n    || taskType === 'ai_modify_appearance'\n    || taskType === 'ai_modify_location'\n    || taskType === 'ai_modify_shot_prompt'\n    || taskType === 'analyze_shot_variants'\n    || taskType === 'ai_create_character'\n    || taskType === 'ai_create_location'\n    || taskType === 'reference_to_character'\n    || taskType === 'character_profile_confirm'\n    || taskType === 'character_profile_batch_confirm'\n    || taskType === 'episode_split_llm'\n    || taskType === 'asset_hub_ai_design_character'\n    || taskType === 'asset_hub_ai_design_location'\n    || taskType === 'asset_hub_ai_modify_character'\n    || taskType === 'asset_hub_ai_modify_location'\n    || taskType === 'asset_hub_reference_to_character'\n  ) {\n    return 'tests/integration/api/contract/llm-observe-routes.test.ts'\n  }\n  if (\n    taskType === 'image_panel'\n    || taskType === 'image_character'\n    || taskType === 'image_location'\n    || taskType === 'video_panel'\n    || taskType === 'lip_sync'\n    || taskType === 'voice_line'\n    || taskType === 'voice_design'\n    || taskType === 'asset_hub_voice_design'\n    || taskType === 'insert_panel'\n    || taskType === 'panel_variant'\n    || taskType === 'modify_asset_image'\n    || taskType === 'regenerate_group'\n    || taskType === 'asset_hub_image'\n    || taskType === 'asset_hub_modify'\n    || taskType === 'regenerate_storyboard_text'\n  ) {\n    return 'tests/integration/api/contract/direct-submit-routes.test.ts'\n  }\n  return 'tests/integration/api/contract/task-infra-routes.test.ts'\n}\n\nexport const TASKTYPE_BEHAVIOR_MATRIX: ReadonlyArray<TaskTypeBehaviorMatrixEntry> = TASK_TYPE_CATALOG.map((entry) => ({\n  taskType: entry.taskType,\n  caseId: `TASKTYPE:${entry.taskType}`,\n  workerTest: entry.owner,\n  chainTest: resolveChainTestByTaskType(entry.taskType),\n  apiContractTest: resolveApiContractByTaskType(entry.taskType),\n}))\n\nexport const TASKTYPE_BEHAVIOR_COUNT = TASKTYPE_BEHAVIOR_MATRIX.length\n"
  },
  {
    "path": "tests/fixtures/billing/cases.json",
    "content": "{\n  \"textModel\": \"anthropic/claude-sonnet-4\",\n  \"imageModel\": \"seedream\",\n  \"videoModel\": \"doubao-seedance-1-0-pro-fast-251015\",\n  \"voiceSeconds\": 5\n}\n"
  },
  {
    "path": "tests/helpers/assertions.ts",
    "content": "import { expect } from 'vitest'\nimport { prisma } from './prisma'\nimport { toMoneyNumber } from '@/lib/billing/money'\n\nexport async function expectBalance(userId: string, params: {\n  balance: number\n  frozenAmount: number\n  totalSpent: number\n}) {\n  const row = await prisma.userBalance.findUnique({ where: { userId } })\n  expect(row).toBeTruthy()\n  expect(toMoneyNumber(row!.balance)).toBeCloseTo(params.balance, 8)\n  expect(toMoneyNumber(row!.frozenAmount)).toBeCloseTo(params.frozenAmount, 8)\n  expect(toMoneyNumber(row!.totalSpent)).toBeCloseTo(params.totalSpent, 8)\n}\n\nexport async function expectNoNegativeLedger(userId: string) {\n  const row = await prisma.userBalance.findUnique({ where: { userId } })\n  expect(row).toBeTruthy()\n  expect(toMoneyNumber(row!.balance)).toBeGreaterThanOrEqual(0)\n  expect(toMoneyNumber(row!.frozenAmount)).toBeGreaterThanOrEqual(0)\n  expect(toMoneyNumber(row!.totalSpent)).toBeGreaterThanOrEqual(0)\n}\n"
  },
  {
    "path": "tests/helpers/auth.ts",
    "content": "import { NextResponse } from 'next/server'\nimport { vi } from 'vitest'\n\ntype SessionUser = {\n  id: string\n  name?: string | null\n  email?: string | null\n}\n\ntype SessionPayload = {\n  user: SessionUser\n}\n\ntype MockAuthState = {\n  session: SessionPayload | null\n  projectAuthMode: 'allow' | 'forbidden' | 'not_found'\n}\n\nconst defaultSession: SessionPayload = {\n  user: {\n    id: 'test-user-id',\n    name: 'test-user',\n    email: 'test@example.com',\n  },\n}\n\nlet state: MockAuthState = {\n  session: defaultSession,\n  projectAuthMode: 'allow',\n}\n\nfunction unauthorizedResponse() {\n  return NextResponse.json(\n    {\n      success: false,\n      error: {\n        code: 'UNAUTHORIZED',\n        message: 'Unauthorized',\n      },\n    },\n    { status: 401 },\n  )\n}\n\nfunction forbiddenResponse() {\n  return NextResponse.json(\n    {\n      success: false,\n      error: {\n        code: 'FORBIDDEN',\n        message: 'Forbidden',\n      },\n    },\n    { status: 403 },\n  )\n}\n\nfunction notFoundResponse() {\n  return NextResponse.json(\n    {\n      success: false,\n      error: {\n        code: 'NOT_FOUND',\n        message: 'Project not found',\n      },\n    },\n    { status: 404 },\n  )\n}\n\nexport function installAuthMocks() {\n  vi.doMock('@/lib/api-auth', () => ({\n    isErrorResponse: (value: unknown) => value instanceof NextResponse,\n    requireUserAuth: async () => {\n      if (!state.session) return unauthorizedResponse()\n      return { session: state.session }\n    },\n    requireProjectAuth: async (projectId: string) => {\n      if (!state.session) return unauthorizedResponse()\n      if (state.projectAuthMode === 'forbidden') return forbiddenResponse()\n      if (state.projectAuthMode === 'not_found') return notFoundResponse()\n      return {\n        session: state.session,\n        project: { id: projectId, userId: state.session.user.id, name: 'project', mode: 'novel-promotion' },\n        novelData: { id: 'novel-data-id' },\n      }\n    },\n    requireProjectAuthLight: async (projectId: string) => {\n      if (!state.session) return unauthorizedResponse()\n      if (state.projectAuthMode === 'forbidden') return forbiddenResponse()\n      if (state.projectAuthMode === 'not_found') return notFoundResponse()\n      return {\n        session: state.session,\n        project: { id: projectId, userId: state.session.user.id, name: 'project' },\n      }\n    },\n  }))\n}\n\nexport function mockAuthenticated(userId: string) {\n  state = {\n    ...state,\n    session: {\n      user: {\n        ...defaultSession.user,\n        id: userId,\n      },\n    },\n  }\n}\n\nexport function mockUnauthenticated() {\n  state = {\n    ...state,\n    session: null,\n  }\n}\n\nexport function mockProjectAuth(mode: 'allow' | 'forbidden' | 'not_found') {\n  state = {\n    ...state,\n    projectAuthMode: mode,\n  }\n}\n\nexport function resetAuthMockState() {\n  state = {\n    session: defaultSession,\n    projectAuthMode: 'allow',\n  }\n  vi.doUnmock('@/lib/api-auth')\n}\n"
  },
  {
    "path": "tests/helpers/billing-fixtures.ts",
    "content": "import { randomUUID } from 'node:crypto'\nimport type { TaskBillingInfo, TaskType } from '@/lib/task/types'\nimport { TASK_STATUS } from '@/lib/task/types'\nimport { Prisma } from '@prisma/client'\nimport { prisma } from './prisma'\n\nexport async function createTestUser() {\n  const suffix = randomUUID().slice(0, 8)\n  return await prisma.user.create({\n    data: {\n      name: `billing_user_${suffix}`,\n      email: `billing_${suffix}@example.com`,\n    },\n  })\n}\n\nexport async function createTestProject(userId: string) {\n  const suffix = randomUUID().slice(0, 8)\n  return await prisma.project.create({\n    data: {\n      name: `Billing Project ${suffix}`,\n      userId,\n    },\n  })\n}\n\nexport async function seedBalance(userId: string, balance: number) {\n  return await prisma.userBalance.upsert({\n    where: { userId },\n    create: {\n      userId,\n      balance,\n      frozenAmount: 0,\n      totalSpent: 0,\n    },\n    update: {\n      balance,\n      frozenAmount: 0,\n      totalSpent: 0,\n    },\n  })\n}\n\nexport async function createQueuedTask(params: {\n  id: string\n  userId: string\n  projectId: string\n  type: TaskType\n  targetType: string\n  targetId: string\n  billingInfo?: TaskBillingInfo | null\n  payload?: Record<string, unknown> | null\n}) {\n  return await prisma.task.create({\n    data: {\n      id: params.id,\n      userId: params.userId,\n      projectId: params.projectId,\n      type: params.type,\n      targetType: params.targetType,\n      targetId: params.targetId,\n      status: TASK_STATUS.QUEUED,\n      billingInfo: (params.billingInfo ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,\n      payload: (params.payload ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,\n      queuedAt: new Date(),\n    },\n  })\n}\n"
  },
  {
    "path": "tests/helpers/db-reset.ts",
    "content": "import { prisma } from './prisma'\n\nexport async function resetBillingState() {\n  await prisma.balanceTransaction.deleteMany()\n  await prisma.balanceFreeze.deleteMany()\n  await prisma.usageCost.deleteMany()\n  await prisma.taskEvent.deleteMany()\n  await prisma.task.deleteMany()\n  await prisma.userBalance.deleteMany()\n  await prisma.project.deleteMany()\n  await prisma.session.deleteMany()\n  await prisma.account.deleteMany()\n  await prisma.userPreference.deleteMany()\n  await prisma.user.deleteMany()\n}\n\nexport async function resetTaskState() {\n  await prisma.taskEvent.deleteMany()\n  await prisma.task.deleteMany()\n}\n\nexport async function resetAssetHubState() {\n  await prisma.globalCharacterAppearance.deleteMany()\n  await prisma.globalCharacter.deleteMany()\n  await prisma.globalLocationImage.deleteMany()\n  await prisma.globalLocation.deleteMany()\n  await prisma.globalVoice.deleteMany()\n  await prisma.globalAssetFolder.deleteMany()\n}\n\nexport async function resetNovelPromotionState() {\n  await prisma.novelPromotionVoiceLine.deleteMany()\n  await prisma.novelPromotionPanel.deleteMany()\n  await prisma.supplementaryPanel.deleteMany()\n  await prisma.novelPromotionStoryboard.deleteMany()\n  await prisma.novelPromotionShot.deleteMany()\n  await prisma.novelPromotionClip.deleteMany()\n  await prisma.characterAppearance.deleteMany()\n  await prisma.locationImage.deleteMany()\n  await prisma.novelPromotionCharacter.deleteMany()\n  await prisma.novelPromotionLocation.deleteMany()\n  await prisma.videoEditorProject.deleteMany()\n  await prisma.novelPromotionEpisode.deleteMany()\n  await prisma.novelPromotionProject.deleteMany()\n}\n\nexport async function resetSystemState() {\n  await resetTaskState()\n  await resetAssetHubState()\n  await resetNovelPromotionState()\n  await prisma.usageCost.deleteMany()\n  await prisma.project.deleteMany()\n  await prisma.userPreference.deleteMany()\n  await prisma.account.deleteMany()\n  await prisma.session.deleteMany()\n  await prisma.userBalance.deleteMany()\n  await prisma.balanceFreeze.deleteMany()\n  await prisma.balanceTransaction.deleteMany()\n  await prisma.user.deleteMany()\n}\n"
  },
  {
    "path": "tests/helpers/fakes/llm.ts",
    "content": "type CompletionResult = {\n  text: string\n  reasoning?: string\n}\n\nconst state: { nextText: string; nextReasoning: string } = {\n  nextText: '{\"ok\":true}',\n  nextReasoning: '',\n}\n\nexport function configureFakeLLM(result: CompletionResult) {\n  state.nextText = result.text\n  state.nextReasoning = result.reasoning || ''\n}\n\nexport function resetFakeLLM() {\n  state.nextText = '{\"ok\":true}'\n  state.nextReasoning = ''\n}\n\nexport async function fakeChatCompletion() {\n  return {\n    output_text: state.nextText,\n    reasoning: state.nextReasoning,\n  }\n}\n"
  },
  {
    "path": "tests/helpers/fakes/media.ts",
    "content": "const state: {\n  nextImageUrl: string\n  nextVideoUrl: string\n  nextAudioUrl: string\n} = {\n  nextImageUrl: 'images/fake-image.jpg',\n  nextVideoUrl: 'video/fake-video.mp4',\n  nextAudioUrl: 'voice/fake-audio.mp3',\n}\n\nexport function configureFakeMedia(params: {\n  imageUrl?: string\n  videoUrl?: string\n  audioUrl?: string\n}) {\n  if (params.imageUrl) state.nextImageUrl = params.imageUrl\n  if (params.videoUrl) state.nextVideoUrl = params.videoUrl\n  if (params.audioUrl) state.nextAudioUrl = params.audioUrl\n}\n\nexport function resetFakeMedia() {\n  state.nextImageUrl = 'images/fake-image.jpg'\n  state.nextVideoUrl = 'video/fake-video.mp4'\n  state.nextAudioUrl = 'voice/fake-audio.mp3'\n}\n\nexport async function fakeGenerateImage() {\n  return { success: true, imageUrl: state.nextImageUrl }\n}\n\nexport async function fakeGenerateVideo() {\n  return { success: true, videoUrl: state.nextVideoUrl }\n}\n\nexport async function fakeGenerateAudio() {\n  return { success: true, audioUrl: state.nextAudioUrl }\n}\n"
  },
  {
    "path": "tests/helpers/fakes/providers.ts",
    "content": "const providerState: {\n  falApiKey: string\n  googleApiKey: string\n  openrouterApiKey: string\n} = {\n  falApiKey: 'fake-fal-key',\n  googleApiKey: 'fake-google-key',\n  openrouterApiKey: 'fake-openrouter-key',\n}\n\nexport function configureFakeProviders(params: {\n  falApiKey?: string\n  googleApiKey?: string\n  openrouterApiKey?: string\n}) {\n  if (params.falApiKey) providerState.falApiKey = params.falApiKey\n  if (params.googleApiKey) providerState.googleApiKey = params.googleApiKey\n  if (params.openrouterApiKey) providerState.openrouterApiKey = params.openrouterApiKey\n}\n\nexport function resetFakeProviders() {\n  providerState.falApiKey = 'fake-fal-key'\n  providerState.googleApiKey = 'fake-google-key'\n  providerState.openrouterApiKey = 'fake-openrouter-key'\n}\n\nexport function getFakeProviderConfig(provider: 'fal' | 'google' | 'openrouter') {\n  if (provider === 'fal') {\n    return { apiKey: providerState.falApiKey }\n  }\n  if (provider === 'google') {\n    return { apiKey: providerState.googleApiKey }\n  }\n  return { apiKey: providerState.openrouterApiKey }\n}\n"
  },
  {
    "path": "tests/helpers/fakes/scenario-server.ts",
    "content": "import http, { type IncomingMessage, type ServerResponse } from 'node:http'\n\nexport type FakeScenarioMode =\n  | 'success'\n  | 'queued_then_success'\n  | 'retryable_error_then_success'\n  | 'fatal_error'\n  | 'malformed_response'\n  | 'timeout'\n\nexport type FakeResponseSpec = {\n  status: number\n  headers?: Record<string, string>\n  body?: string | Buffer | Record<string, unknown> | unknown[] | null\n  delayMs?: number\n}\n\nexport type FakeRequestRecord = {\n  method: string\n  path: string\n  query: string\n  bodyText: string\n  headers: Record<string, string | string[] | undefined>\n}\n\ntype RouteKey = `${Uppercase<string>} ${string}`\n\ntype RouteScenario = {\n  mode: FakeScenarioMode\n  submitResponse?: FakeResponseSpec\n  pollSequence?: FakeResponseSpec[]\n  errorCode?: string\n  delayMs?: number\n}\n\nfunction routeKey(method: string, path: string): RouteKey {\n  return `${method.toUpperCase()} ${path}` as RouteKey\n}\n\nfunction normalizeHeaders(headers: IncomingMessage['headers']): Record<string, string | string[] | undefined> {\n  return Object.fromEntries(Object.entries(headers))\n}\n\nfunction toBodyText(chunks: Buffer[]): string {\n  if (chunks.length === 0) return ''\n  return Buffer.concat(chunks).toString('utf8')\n}\n\nfunction isJsonBody(body: FakeResponseSpec['body']): body is Record<string, unknown> | unknown[] | null {\n  return body === null || Array.isArray(body) || (!!body && typeof body === 'object' && !Buffer.isBuffer(body))\n}\n\nasync function writeResponse(\n  res: ServerResponse,\n  spec: FakeResponseSpec,\n  inheritedDelayMs: number | undefined,\n) {\n  const delayMs = spec.delayMs ?? inheritedDelayMs ?? 0\n  if (delayMs > 0) {\n    await new Promise((resolve) => setTimeout(resolve, delayMs))\n  }\n\n  const headers = { ...(spec.headers || {}) }\n  if (isJsonBody(spec.body) && !headers['content-type']) {\n    headers['content-type'] = 'application/json'\n  }\n  res.writeHead(spec.status, headers)\n\n  if (spec.body === undefined) {\n    res.end()\n    return\n  }\n  if (Buffer.isBuffer(spec.body)) {\n    res.end(spec.body)\n    return\n  }\n  if (isJsonBody(spec.body)) {\n    res.end(JSON.stringify(spec.body))\n    return\n  }\n  res.end(spec.body)\n}\n\nexport async function startScenarioServer() {\n  const requests = new Map<RouteKey, FakeRequestRecord[]>()\n  const routes = new Map<RouteKey, { queue: FakeResponseSpec[]; mode: FakeScenarioMode; delayMs?: number }>()\n\n  const server = http.createServer(async (req, res) => {\n    const url = new URL(req.url || '/', 'http://127.0.0.1')\n    const key = routeKey(req.method || 'GET', url.pathname)\n    const entry = routes.get(key)\n    const chunks: Buffer[] = []\n    for await (const chunk of req) {\n      chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))\n    }\n    const bodyText = toBodyText(chunks)\n    const history = requests.get(key) || []\n    history.push({\n      method: (req.method || 'GET').toUpperCase(),\n      path: url.pathname,\n      query: url.search,\n      bodyText,\n      headers: normalizeHeaders(req.headers),\n    })\n    requests.set(key, history)\n\n    if (!entry) {\n      await writeResponse(res, {\n        status: 404,\n        body: { error: 'SCENARIO_ROUTE_NOT_FOUND', path: url.pathname },\n      }, 0)\n      return\n    }\n\n    const next = entry.queue.length > 1 ? entry.queue.shift() : entry.queue[0]\n    if (!next) {\n      await writeResponse(res, {\n        status: 500,\n        body: { error: 'SCENARIO_DEPLETED', path: url.pathname, mode: entry.mode },\n      }, entry.delayMs)\n      return\n    }\n\n    await writeResponse(res, next, entry.delayMs)\n  })\n\n  await new Promise<void>((resolve, reject) => {\n    server.listen(0, '127.0.0.1', () => resolve())\n    server.once('error', reject)\n  })\n\n  const address = server.address()\n  if (!address || typeof address === 'string') {\n    throw new Error('SCENARIO_SERVER_ADDRESS_INVALID')\n  }\n  const baseUrl = `http://127.0.0.1:${address.port}`\n\n  return {\n    baseUrl,\n    defineScenario(input: {\n      method: string\n      path: string\n      mode: FakeScenarioMode\n      submitResponse?: FakeResponseSpec\n      pollSequence?: FakeResponseSpec[]\n      errorCode?: string\n      delayMs?: number\n    }) {\n      const key = routeKey(input.method, input.path)\n      const queue: FakeResponseSpec[] = []\n      if (input.submitResponse) {\n        queue.push(input.submitResponse)\n      }\n      if (input.pollSequence && input.pollSequence.length > 0) {\n        queue.push(...input.pollSequence)\n      }\n      if (queue.length === 0) {\n        throw new Error(`SCENARIO_EMPTY_QUEUE: ${key}`)\n      }\n      const scenario: RouteScenario = {\n        mode: input.mode,\n        submitResponse: input.submitResponse,\n        pollSequence: input.pollSequence,\n        errorCode: input.errorCode,\n        delayMs: input.delayMs,\n      }\n      routes.set(key, {\n        queue,\n        mode: scenario.mode,\n        delayMs: scenario.delayMs,\n      })\n      requests.delete(key)\n    },\n    getRequests(method: string, path: string): FakeRequestRecord[] {\n      return [...(requests.get(routeKey(method, path)) || [])]\n    },\n    reset() {\n      routes.clear()\n      requests.clear()\n    },\n    async close() {\n      await new Promise<void>((resolve, reject) => {\n        server.close((error) => {\n          if (error) {\n            reject(error)\n            return\n          }\n          resolve()\n        })\n      })\n    },\n  }\n}\n"
  },
  {
    "path": "tests/helpers/fixtures.ts",
    "content": "import { randomUUID } from 'node:crypto'\nimport { prisma } from './prisma'\n\nfunction suffix() {\n  return randomUUID().slice(0, 8)\n}\n\nexport async function createFixtureUser() {\n  const id = suffix()\n  return await prisma.user.create({\n    data: {\n      name: `user_${id}`,\n      email: `user_${id}@example.com`,\n    },\n  })\n}\n\nexport async function createFixtureProject(userId: string, mode: 'novel-promotion' | 'general' = 'novel-promotion') {\n  const id = suffix()\n  return await prisma.project.create({\n    data: {\n      userId,\n      mode,\n      name: `project_${id}`,\n    },\n  })\n}\n\nexport async function createFixtureNovelProject(projectId: string) {\n  return await prisma.novelPromotionProject.create({\n    data: {\n      projectId,\n      analysisModel: 'openrouter::anthropic/claude-sonnet-4',\n      characterModel: 'fal::banana/character',\n      locationModel: 'fal::banana/location',\n      storyboardModel: 'fal::banana/storyboard',\n      editModel: 'fal::banana/edit',\n      videoModel: 'fal::seedance/video',\n      videoRatio: '9:16',\n      imageResolution: '2K',\n    },\n  })\n}\n\nexport async function createFixtureGlobalCharacter(userId: string, folderId: string | null = null) {\n  const id = suffix()\n  return await prisma.globalCharacter.create({\n    data: {\n      userId,\n      name: `character_${id}`,\n      ...(folderId ? { folderId } : {}),\n    },\n  })\n}\n\nexport async function createFixtureGlobalCharacterAppearance(characterId: string, appearanceIndex = 0) {\n  return await prisma.globalCharacterAppearance.create({\n    data: {\n      characterId,\n      appearanceIndex,\n      changeReason: 'default',\n      imageUrls: JSON.stringify(['images/test-0.jpg']),\n      selectedIndex: 0,\n    },\n  })\n}\n\nexport async function createFixtureGlobalLocation(userId: string, folderId: string | null = null) {\n  const id = suffix()\n  return await prisma.globalLocation.create({\n    data: {\n      userId,\n      name: `location_${id}`,\n      ...(folderId ? { folderId } : {}),\n    },\n  })\n}\n\nexport async function createFixtureGlobalLocationImage(locationId: string, imageIndex = 0) {\n  return await prisma.globalLocationImage.create({\n    data: {\n      locationId,\n      imageIndex,\n      imageUrl: `images/location-${suffix()}.jpg`,\n      isSelected: imageIndex === 0,\n    },\n  })\n}\n\nexport async function createFixtureEpisode(novelPromotionProjectId: string, episodeNumber = 1) {\n  return await prisma.novelPromotionEpisode.create({\n    data: {\n      novelPromotionProjectId,\n      episodeNumber,\n      name: `Episode ${episodeNumber}`,\n      novelText: 'test novel text',\n    },\n  })\n}\n"
  },
  {
    "path": "tests/helpers/mock-query-client.ts",
    "content": "import type { QueryKey } from '@tanstack/react-query'\n\ninterface QueryFilter {\n  queryKey: QueryKey\n  exact?: boolean\n}\n\ntype Updater<T> = T | ((previous: T | undefined) => T | undefined)\n\ninterface StoredQueryEntry {\n  queryKey: QueryKey\n  data: unknown\n}\n\nfunction isPrefixQueryKey(target: QueryKey, prefix: QueryKey): boolean {\n  if (prefix.length > target.length) return false\n  return prefix.every((value, index) => Object.is(value, target[index]))\n}\n\nfunction keyOf(queryKey: QueryKey): string {\n  return JSON.stringify(queryKey)\n}\n\nexport class MockQueryClient {\n  private readonly queryMap = new Map<string, StoredQueryEntry>()\n\n  async cancelQueries(filters: QueryFilter): Promise<void> {\n    void filters\n  }\n\n  seedQuery<T>(queryKey: QueryKey, data: T | undefined) {\n    this.queryMap.set(keyOf(queryKey), {\n      queryKey,\n      data,\n    })\n  }\n\n  getQueryData<T>(queryKey: QueryKey): T | undefined {\n    const entry = this.queryMap.get(keyOf(queryKey))\n    return entry?.data as T | undefined\n  }\n\n  setQueryData<T>(queryKey: QueryKey, updater: Updater<T | undefined>) {\n    const previous = this.getQueryData<T>(queryKey)\n    const next = typeof updater === 'function'\n      ? (updater as (prev: T | undefined) => T | undefined)(previous)\n      : updater\n    this.seedQuery(queryKey, next)\n  }\n\n  getQueriesData<T>(filters: QueryFilter): Array<[QueryKey, T | undefined]> {\n    const matched: Array<[QueryKey, T | undefined]> = []\n    for (const { queryKey, data } of this.queryMap.values()) {\n      const isMatch = filters.exact\n        ? keyOf(filters.queryKey) === keyOf(queryKey)\n        : isPrefixQueryKey(queryKey, filters.queryKey)\n      if (!isMatch) continue\n      matched.push([queryKey, data as T | undefined])\n    }\n    return matched\n  }\n\n  setQueriesData<T>(\n    filters: QueryFilter,\n    updater: (previous: T | undefined) => T | undefined,\n  ) {\n    const matches = this.getQueriesData<T>(filters)\n    matches.forEach(([queryKey, previous]) => {\n      this.seedQuery(queryKey, updater(previous))\n    })\n  }\n}\n"
  },
  {
    "path": "tests/helpers/prisma.ts",
    "content": "import { loadTestEnv } from '../setup/env'\nimport { prisma } from '@/lib/prisma'\n\nloadTestEnv()\n\nexport { prisma }\n"
  },
  {
    "path": "tests/helpers/request.ts",
    "content": "import { NextRequest } from 'next/server'\n\ntype HeaderMap = Record<string, string>\ntype QueryMap = Record<string, string | number | boolean>\n\nfunction toJsonBody(body: unknown): string | undefined {\n  if (body === undefined) return undefined\n  return JSON.stringify(body)\n}\n\nfunction appendQuery(url: URL, query?: QueryMap) {\n  if (!query) return\n  for (const [key, value] of Object.entries(query)) {\n    url.searchParams.set(key, String(value))\n  }\n}\n\nexport function buildMockRequest(params: {\n  path: string\n  method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'\n  body?: unknown\n  headers?: HeaderMap\n  query?: QueryMap\n}) {\n  const url = new URL(params.path, 'http://localhost:3000')\n  appendQuery(url, params.query)\n  const jsonBody = toJsonBody(params.body)\n\n  const headers: HeaderMap = {\n    ...(params.headers || {}),\n  }\n  if (jsonBody !== undefined && !headers['content-type']) {\n    headers['content-type'] = 'application/json'\n  }\n\n  return new NextRequest(url, {\n    method: params.method,\n    headers,\n    ...(jsonBody !== undefined ? { body: jsonBody } : {}),\n  })\n}\n\nexport async function callRoute<TContext>(\n  handler: (req: NextRequest, ctx: TContext) => Promise<Response>,\n  params: {\n    path: string\n    method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'\n    body?: unknown\n    headers?: HeaderMap\n    query?: QueryMap\n    context: TContext\n  },\n) {\n  const req = buildMockRequest({\n    path: params.path,\n    method: params.method,\n    body: params.body,\n    headers: params.headers,\n    query: params.query,\n  })\n  return await handler(req, params.context)\n}\n"
  },
  {
    "path": "tests/hidden/README.md",
    "content": "# Hidden Acceptance Reserve\n\nThis directory is intentionally reserved for private acceptance and regression suites that are not exposed to code-writing agents.\n\nPublic repo rules:\n- Do not add executable public tests here.\n- Keep provider/system helper interfaces stable so private CI can mount hidden evals without patching production code.\n- Private hidden suites should target the same route entrypoints and fake-provider hooks used by `tests/system/**`.\n"
  },
  {
    "path": "tests/integration/api/contract/crud-routes.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { ROUTE_CATALOG } from '../../../contracts/route-catalog'\nimport { buildMockRequest } from '../../../helpers/request'\n\ntype RouteMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'\n\ntype AuthState = {\n  authenticated: boolean\n}\n\ntype RouteContext = {\n  params: Promise<Record<string, string>>\n}\n\nconst authState = vi.hoisted<AuthState>(() => ({\n  authenticated: false,\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  globalCharacter: {\n    findUnique: vi.fn(),\n    update: vi.fn(),\n    delete: vi.fn(),\n  },\n  globalAssetFolder: {\n    findUnique: vi.fn(),\n  },\n  characterAppearance: {\n    findUnique: vi.fn(),\n    update: vi.fn(),\n  },\n  novelPromotionLocation: {\n    findUnique: vi.fn(),\n    update: vi.fn(),\n  },\n  locationImage: {\n    updateMany: vi.fn(),\n    update: vi.fn(),\n  },\n  novelPromotionClip: {\n    update: vi.fn(),\n  },\n}))\n\nvi.mock('@/lib/api-auth', () => {\n  const unauthorized = () => new Response(\n    JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),\n    { status: 401, headers: { 'content-type': 'application/json' } },\n  )\n\n  return {\n    isErrorResponse: (value: unknown) => value instanceof Response,\n    requireUserAuth: async () => {\n      if (!authState.authenticated) return unauthorized()\n      return { session: { user: { id: 'user-1' } } }\n    },\n    requireProjectAuth: async (projectId: string) => {\n      if (!authState.authenticated) return unauthorized()\n      return {\n        session: { user: { id: 'user-1' } },\n        project: { id: projectId, userId: 'user-1', mode: 'novel-promotion' },\n      }\n    },\n    requireProjectAuthLight: async (projectId: string) => {\n      if (!authState.authenticated) return unauthorized()\n      return {\n        session: { user: { id: 'user-1' } },\n        project: { id: projectId, userId: 'user-1', mode: 'novel-promotion' },\n      }\n    },\n  }\n})\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nvi.mock('@/lib/storage', () => ({\n  getSignedUrl: vi.fn((key: string) => `https://signed.example/${key}`),\n}))\n\nfunction toModuleImportPath(routeFile: string): string {\n  return `@/${routeFile.replace(/^src\\//, '').replace(/\\.ts$/, '')}`\n}\n\nfunction resolveParamValue(paramName: string): string {\n  const key = paramName.toLowerCase()\n  if (key.includes('project')) return 'project-1'\n  if (key.includes('character')) return 'character-1'\n  if (key.includes('location')) return 'location-1'\n  if (key.includes('appearance')) return '0'\n  if (key.includes('episode')) return 'episode-1'\n  if (key.includes('storyboard')) return 'storyboard-1'\n  if (key.includes('panel')) return 'panel-1'\n  if (key.includes('clip')) return 'clip-1'\n  if (key.includes('folder')) return 'folder-1'\n  if (key === 'id') return 'id-1'\n  return `${paramName}-1`\n}\n\nfunction toApiPath(routeFile: string): { path: string; params: Record<string, string> } {\n  const withoutPrefix = routeFile\n    .replace(/^src\\/app/, '')\n    .replace(/\\/route\\.ts$/, '')\n\n  const params: Record<string, string> = {}\n  const path = withoutPrefix.replace(/\\[([^\\]]+)\\]/g, (_full, paramName: string) => {\n    const value = resolveParamValue(paramName)\n    params[paramName] = value\n    return value\n  })\n  return { path, params }\n}\n\nfunction buildGenericBody() {\n  return {\n    id: 'id-1',\n    name: 'Name',\n    type: 'character',\n    userInstruction: 'instruction',\n    characterId: 'character-1',\n    locationId: 'location-1',\n    appearanceId: 'appearance-1',\n    modifyPrompt: 'modify prompt',\n    storyboardId: 'storyboard-1',\n    panelId: 'panel-1',\n    panelIndex: 0,\n    episodeId: 'episode-1',\n    content: 'x'.repeat(140),\n    voicePrompt: 'voice prompt',\n    previewText: 'preview text',\n    referenceImageUrl: 'https://example.com/ref.png',\n    referenceImageUrls: ['https://example.com/ref.png'],\n    lineId: 'line-1',\n    audioModel: 'fal::audio-model',\n    videoModel: 'fal::video-model',\n    insertAfterPanelId: 'panel-1',\n    sourcePanelId: 'panel-2',\n    variant: { video_prompt: 'variant prompt' },\n    currentDescription: 'description',\n    modifyInstruction: 'instruction',\n    currentPrompt: 'prompt',\n    all: false,\n  }\n}\n\nasync function invokeRouteMethod(\n  routeFile: string,\n  method: RouteMethod,\n): Promise<Response> {\n  const { path, params } = toApiPath(routeFile)\n  const modulePath = toModuleImportPath(routeFile)\n  const mod = await import(modulePath)\n  const handler = mod[method] as ((req: Request, ctx?: RouteContext) => Promise<Response>) | undefined\n  if (!handler) {\n    throw new Error(`Route ${routeFile} missing method ${method}`)\n  }\n  const req = buildMockRequest({\n    path,\n    method,\n    ...(method === 'GET' || method === 'DELETE' ? {} : { body: buildGenericBody() }),\n  })\n  return await handler(req, { params: Promise.resolve(params) })\n}\n\ndescribe('api contract - crud routes (behavior)', () => {\n  const routes = ROUTE_CATALOG.filter(\n    (entry) => entry.contractGroup === 'crud-asset-hub-routes' || entry.contractGroup === 'crud-novel-promotion-routes',\n  )\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n    authState.authenticated = false\n\n    prismaMock.globalCharacter.findUnique.mockResolvedValue({\n      id: 'character-1',\n      userId: 'user-1',\n    })\n    prismaMock.globalAssetFolder.findUnique.mockResolvedValue({\n      id: 'folder-1',\n      userId: 'user-1',\n    })\n    prismaMock.globalCharacter.update.mockResolvedValue({\n      id: 'character-1',\n      name: 'Alice',\n      userId: 'user-1',\n      appearances: [],\n    })\n    prismaMock.globalCharacter.delete.mockResolvedValue({ id: 'character-1' })\n    prismaMock.characterAppearance.findUnique.mockResolvedValue({\n      id: 'appearance-1',\n      characterId: 'character-1',\n      imageUrls: JSON.stringify(['cos/char-0.png', 'cos/char-1.png']),\n      imageUrl: null,\n      selectedIndex: null,\n      character: { id: 'character-1', name: 'Alice' },\n    })\n    prismaMock.characterAppearance.update.mockResolvedValue({\n      id: 'appearance-1',\n      selectedIndex: 1,\n      imageUrl: 'cos/char-1.png',\n    })\n    prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({\n      id: 'location-1',\n      name: 'Old Town',\n      images: [\n        { id: 'img-0', imageIndex: 0, imageUrl: 'cos/loc-0.png' },\n        { id: 'img-1', imageIndex: 1, imageUrl: 'cos/loc-1.png' },\n      ],\n    })\n    prismaMock.locationImage.updateMany.mockResolvedValue({ count: 2 })\n    prismaMock.locationImage.update.mockResolvedValue({\n      id: 'img-1',\n      imageIndex: 1,\n      imageUrl: 'cos/loc-1.png',\n      isSelected: true,\n    })\n    prismaMock.novelPromotionLocation.update.mockResolvedValue({\n      id: 'location-1',\n      selectedImageId: 'img-1',\n    })\n    prismaMock.novelPromotionClip.update.mockResolvedValue({\n      id: 'clip-1',\n      characters: JSON.stringify(['Alice']),\n      location: 'Old Town',\n      content: 'clip content',\n      screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),\n    })\n  })\n\n  it('crud route group exists', () => {\n    expect(routes.length).toBeGreaterThan(0)\n  })\n\n  it('all crud route methods reject unauthenticated requests (no 2xx pass-through)', async () => {\n    const methods: ReadonlyArray<RouteMethod> = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE']\n    let checkedMethodCount = 0\n\n    for (const entry of routes) {\n      const modulePath = toModuleImportPath(entry.routeFile)\n      const mod = await import(modulePath)\n      for (const method of methods) {\n        if (typeof mod[method] !== 'function') continue\n        checkedMethodCount += 1\n        const res = await invokeRouteMethod(entry.routeFile, method)\n        expect(res.status, `${entry.routeFile}#${method} should reject unauthenticated`).toBeGreaterThanOrEqual(400)\n        expect(res.status, `${entry.routeFile}#${method} should not be server-error on auth gate`).toBeLessThan(500)\n      }\n    }\n\n    expect(checkedMethodCount).toBeGreaterThan(0)\n  })\n\n  it('PATCH /asset-hub/characters/[characterId] writes normalized fields to prisma.globalCharacter.update', async () => {\n    authState.authenticated = true\n    const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/characters/character-1',\n      method: 'PATCH',\n      body: {\n        name: '  Alice  ',\n        aliases: ['A'],\n        profileConfirmed: true,\n        folderId: 'folder-1',\n      },\n    })\n\n    const res = await mod.PATCH(req, { params: Promise.resolve({ characterId: 'character-1' }) })\n    expect(res.status).toBe(200)\n    expect(prismaMock.globalCharacter.update).toHaveBeenCalledWith(expect.objectContaining({\n      where: { id: 'character-1' },\n      data: expect.objectContaining({\n        name: 'Alice',\n        aliases: ['A'],\n        profileConfirmed: true,\n        folderId: 'folder-1',\n      }),\n    }))\n  })\n\n  it('DELETE /asset-hub/characters/[characterId] deletes owned character and blocks non-owner', async () => {\n    authState.authenticated = true\n    const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')\n\n    prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({\n      id: 'character-1',\n      userId: 'user-1',\n    })\n    const okReq = buildMockRequest({\n      path: '/api/asset-hub/characters/character-1',\n      method: 'DELETE',\n    })\n    const okRes = await mod.DELETE(okReq, { params: Promise.resolve({ characterId: 'character-1' }) })\n    expect(okRes.status).toBe(200)\n    expect(prismaMock.globalCharacter.delete).toHaveBeenCalledWith({ where: { id: 'character-1' } })\n\n    prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({\n      id: 'character-1',\n      userId: 'other-user',\n    })\n    const forbiddenReq = buildMockRequest({\n      path: '/api/asset-hub/characters/character-1',\n      method: 'DELETE',\n    })\n    const forbiddenRes = await mod.DELETE(forbiddenReq, { params: Promise.resolve({ characterId: 'character-1' }) })\n    expect(forbiddenRes.status).toBe(403)\n  })\n\n  it('POST /novel-promotion/[projectId]/select-character-image writes selectedIndex and imageUrl key', async () => {\n    authState.authenticated = true\n    const mod = await import('@/app/api/novel-promotion/[projectId]/select-character-image/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/select-character-image',\n      method: 'POST',\n      body: {\n        characterId: 'character-1',\n        appearanceId: 'appearance-1',\n        selectedIndex: 1,\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n    expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({\n      where: { id: 'appearance-1' },\n      data: {\n        selectedIndex: 1,\n        imageUrl: 'cos/char-1.png',\n      },\n    })\n\n    const payload = await res.json() as { success: boolean; selectedIndex: number; imageUrl: string }\n    expect(payload).toEqual({\n      success: true,\n      selectedIndex: 1,\n      imageUrl: 'https://signed.example/cos/char-1.png',\n    })\n  })\n\n  it('POST /novel-promotion/[projectId]/select-location-image toggles selected state and selectedImageId', async () => {\n    authState.authenticated = true\n    const mod = await import('@/app/api/novel-promotion/[projectId]/select-location-image/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/select-location-image',\n      method: 'POST',\n      body: {\n        locationId: 'location-1',\n        selectedIndex: 1,\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n    expect(prismaMock.locationImage.updateMany).toHaveBeenCalledWith({\n      where: { locationId: 'location-1' },\n      data: { isSelected: false },\n    })\n    expect(prismaMock.locationImage.update).toHaveBeenCalledWith({\n      where: { locationId_imageIndex: { locationId: 'location-1', imageIndex: 1 } },\n      data: { isSelected: true },\n    })\n    expect(prismaMock.novelPromotionLocation.update).toHaveBeenCalledWith({\n      where: { id: 'location-1' },\n      data: { selectedImageId: 'img-1' },\n    })\n  })\n\n  it('PATCH /novel-promotion/[projectId]/clips/[clipId] writes provided editable fields', async () => {\n    authState.authenticated = true\n    const mod = await import('@/app/api/novel-promotion/[projectId]/clips/[clipId]/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/clips/clip-1',\n      method: 'PATCH',\n      body: {\n        characters: JSON.stringify(['Alice']),\n        location: 'Old Town',\n        content: 'clip content',\n        screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),\n      },\n    })\n\n    const res = await mod.PATCH(req, {\n      params: Promise.resolve({ projectId: 'project-1', clipId: 'clip-1' }),\n    })\n    expect(res.status).toBe(200)\n    expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({\n      where: { id: 'clip-1' },\n      data: {\n        characters: JSON.stringify(['Alice']),\n        location: 'Old Town',\n        content: 'clip content',\n        screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),\n      },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/contract/direct-submit-routes.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskType } from '@/lib/task/types'\nimport { buildMockRequest } from '../../../helpers/request'\n\ntype AuthState = {\n  authenticated: boolean\n  projectMode: 'novel-promotion' | 'other'\n}\n\ntype SubmitResult = {\n  taskId: string\n  async: true\n}\n\ntype RouteContext = {\n  params: Promise<Record<string, string>>\n}\n\ntype DirectRouteCase = {\n  routeFile: string\n  body: Record<string, unknown>\n  params?: Record<string, string>\n  expectedTaskType: TaskType\n  expectedTargetType: string\n  expectedProjectId: string\n  expectedPayloadSubset?: Record<string, unknown>\n}\n\nconst authState = vi.hoisted<AuthState>(() => ({\n  authenticated: true,\n  projectMode: 'novel-promotion',\n}))\n\nconst submitTaskMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<SubmitResult>>())\n\nconst configServiceMock = vi.hoisted(() => ({\n  getUserModelConfig: vi.fn(async () => ({\n    characterModel: 'img::character',\n    locationModel: 'img::location',\n    editModel: 'img::edit',\n  })),\n  buildImageBillingPayloadFromUserConfig: vi.fn((input: { basePayload: Record<string, unknown> }) => ({\n    ...input.basePayload,\n    generationOptions: { resolution: '1024x1024' },\n  })),\n  getProjectModelConfig: vi.fn(async () => ({\n    characterModel: 'img::character',\n    locationModel: 'img::location',\n    editModel: 'img::edit',\n    storyboardModel: 'img::storyboard',\n    analysisModel: 'llm::analysis',\n  })),\n  buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({\n    ...input.basePayload,\n    generationOptions: { resolution: '1024x1024' },\n  })),\n  resolveProjectModelCapabilityGenerationOptions: vi.fn(async () => ({\n    resolution: '1024x1024',\n  })),\n}))\n\nconst hasOutputMock = vi.hoisted(() => ({\n  hasGlobalCharacterOutput: vi.fn(async () => false),\n  hasGlobalLocationOutput: vi.fn(async () => false),\n  hasGlobalCharacterAppearanceOutput: vi.fn(async () => false),\n  hasGlobalLocationImageOutput: vi.fn(async () => false),\n  hasCharacterAppearanceOutput: vi.fn(async () => false),\n  hasLocationImageOutput: vi.fn(async () => false),\n  hasPanelLipSyncOutput: vi.fn(async () => false),\n  hasPanelImageOutput: vi.fn(async () => false),\n  hasPanelVideoOutput: vi.fn(async () => false),\n  hasVoiceLineAudioOutput: vi.fn(async () => false),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  userPreference: {\n    findUnique: vi.fn(async () => ({ lipSyncModel: 'fal::lipsync-model' })),\n  },\n  novelPromotionStoryboard: {\n    findUnique: vi.fn(async () => ({\n      id: 'storyboard-1',\n      episode: {\n        novelPromotionProject: {\n          projectId: 'project-1',\n        },\n      },\n    })),\n    update: vi.fn(async () => ({})),\n  },\n  novelPromotionPanel: {\n    findFirst: vi.fn(async () => ({ id: 'panel-1' })),\n    findMany: vi.fn(async () => []),\n    findUnique: vi.fn(async ({ where }: { where?: { id?: string } }) => {\n      const id = where?.id || 'panel-1'\n      if (id === 'panel-src') {\n        return {\n          id,\n          storyboardId: 'storyboard-1',\n          panelIndex: 1,\n          shotType: 'wide',\n          cameraMove: 'static',\n          description: 'source description',\n          videoPrompt: 'source video prompt',\n          location: 'source location',\n          characters: '[]',\n          srtSegment: '',\n          duration: 3,\n        }\n      }\n      if (id === 'panel-ins') {\n        return {\n          id,\n          storyboardId: 'storyboard-1',\n          panelIndex: 2,\n          shotType: 'medium',\n          cameraMove: 'push',\n          description: 'insert description',\n          videoPrompt: 'insert video prompt',\n          location: 'insert location',\n          characters: '[]',\n          srtSegment: '',\n          duration: 3,\n        }\n      }\n      return {\n        id,\n        storyboardId: 'storyboard-1',\n        panelIndex: 0,\n        shotType: 'medium',\n        cameraMove: 'static',\n        description: 'panel description',\n        videoPrompt: 'panel prompt',\n        location: 'panel location',\n        characters: '[]',\n        srtSegment: '',\n        duration: 3,\n      }\n    }),\n    update: vi.fn(async () => ({})),\n    create: vi.fn(async () => ({ id: 'panel-created', panelIndex: 3 })),\n    findUniqueOrThrow: vi.fn(),\n    delete: vi.fn(async () => ({})),\n    count: vi.fn(async () => 3),\n    updateMany: vi.fn(async () => ({ count: 0 })),\n  },\n  novelPromotionProject: {\n    findUnique: vi.fn(async () => ({\n      id: 'project-data-1',\n      characters: [\n        { name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.mp3' },\n      ],\n    })),\n  },\n  novelPromotionEpisode: {\n    findFirst: vi.fn(async () => ({\n      id: 'episode-1',\n      speakerVoices: '{}',\n    })),\n  },\n  novelPromotionVoiceLine: {\n    findMany: vi.fn(async () => [\n      { id: 'line-1', speaker: 'Narrator', content: 'hello world voice line' },\n    ]),\n    findFirst: vi.fn(async () => ({\n      id: 'line-1',\n      speaker: 'Narrator',\n      content: 'hello world voice line',\n    })),\n  },\n  $transaction: vi.fn(async (fn: (tx: {\n    novelPromotionPanel: {\n      findMany: (args: unknown) => Promise<Array<{ id: string; panelIndex: number }>>\n      update: (args: unknown) => Promise<unknown>\n      create: (args: { data?: { id?: string; panelIndex?: number } }) => Promise<{ id: string; panelIndex: number }>\n      findFirst: (args: unknown) => Promise<{ panelIndex: number } | null>\n      delete: (args: unknown) => Promise<unknown>\n      count: (args: unknown) => Promise<number>\n      updateMany: (args: unknown) => Promise<{ count: number }>\n    }\n    novelPromotionStoryboard: {\n      update: (args: unknown) => Promise<unknown>\n    }\n  }) => Promise<unknown>) => {\n    const tx = {\n      novelPromotionPanel: {\n        findMany: async () => [],\n        update: async () => ({}),\n        create: async (args: { data?: { id?: string; panelIndex?: number } }) => ({\n          id: args.data?.id || 'panel-created',\n          panelIndex: args.data?.panelIndex ?? 3,\n        }),\n        findFirst: async () => ({ panelIndex: 3 }),\n        delete: async () => ({}),\n        count: async () => 3,\n        updateMany: async () => ({ count: 0 }),\n      },\n      novelPromotionStoryboard: {\n        update: async () => ({}),\n      },\n    }\n    return await fn(tx)\n  }),\n}))\n\nvi.mock('@/lib/api-auth', () => {\n  const unauthorized = () => new Response(\n    JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),\n    { status: 401, headers: { 'content-type': 'application/json' } },\n  )\n\n  return {\n    isErrorResponse: (value: unknown) => value instanceof Response,\n    requireUserAuth: async () => {\n      if (!authState.authenticated) return unauthorized()\n      return { session: { user: { id: 'user-1' } } }\n    },\n    requireProjectAuth: async (projectId: string) => {\n      if (!authState.authenticated) return unauthorized()\n      return {\n        session: { user: { id: 'user-1' } },\n        project: { id: projectId, userId: 'user-1', mode: authState.projectMode },\n      }\n    },\n    requireProjectAuthLight: async (projectId: string) => {\n      if (!authState.authenticated) return unauthorized()\n      return {\n        session: { user: { id: 'user-1' } },\n        project: { id: projectId, userId: 'user-1', mode: authState.projectMode },\n      }\n    },\n  }\n})\n\nvi.mock('@/lib/task/submitter', () => ({\n  submitTask: submitTaskMock,\n}))\nvi.mock('@/lib/task/resolve-locale', () => ({\n  resolveRequiredTaskLocale: vi.fn(() => 'zh'),\n}))\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/task/has-output', () => hasOutputMock)\nvi.mock('@/lib/billing', () => ({\n  buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),\n}))\nvi.mock('@/lib/providers/bailian/voice-design', () => ({\n  validateVoicePrompt: vi.fn(() => ({ valid: true })),\n  validatePreviewText: vi.fn(() => ({ valid: true })),\n}))\nvi.mock('@/lib/media/outbound-image', () => ({\n  sanitizeImageInputsForTaskPayload: vi.fn((inputs: unknown[]) => ({\n    normalized: inputs\n      .filter((item): item is string => typeof item === 'string')\n      .map((item) => item.trim())\n      .filter((item) => item.length > 0),\n    issues: [] as Array<{ reason: string }>,\n  })),\n}))\nvi.mock('@/lib/model-capabilities/lookup', () => ({\n  resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),\n}))\nvi.mock('@/lib/model-pricing/lookup', () => ({\n  resolveBuiltinPricing: vi.fn(() => ({ status: 'ok' })),\n}))\nvi.mock('@/lib/api-config', () => ({\n  resolveModelSelection: vi.fn(async () => ({\n    model: 'img::storyboard',\n  })),\n  resolveModelSelectionOrSingle: vi.fn(async (_userId: string, model: string | null | undefined) => {\n    const modelKey = typeof model === 'string' && model.trim().length > 0\n      ? model.trim()\n      : 'fal::audio-model'\n    const separator = modelKey.indexOf('::')\n    const provider = separator === -1 ? modelKey : modelKey.slice(0, separator)\n    const modelId = separator === -1 ? modelKey : modelKey.slice(separator + 2)\n    return {\n      provider,\n      modelId,\n      modelKey,\n      mediaType: 'audio',\n    }\n  }),\n  getProviderKey: vi.fn((providerId: string) => {\n    const marker = providerId.indexOf(':')\n    return marker === -1 ? providerId : providerId.slice(0, marker)\n  }),\n}))\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nfunction toApiPath(routeFile: string): string {\n  return routeFile\n    .replace(/^src\\/app/, '')\n    .replace(/\\/route\\.ts$/, '')\n    .replace('[projectId]', 'project-1')\n}\n\nfunction toModuleImportPath(routeFile: string): string {\n  return `@/${routeFile.replace(/^src\\//, '').replace(/\\.ts$/, '')}`\n}\n\nconst DIRECT_CASES: ReadonlyArray<DirectRouteCase> = [\n  {\n    routeFile: 'src/app/api/asset-hub/generate-image/route.ts',\n    body: { type: 'character', id: 'global-character-1', appearanceIndex: 0, artStyle: 'realistic' },\n    expectedTaskType: TASK_TYPE.ASSET_HUB_IMAGE,\n    expectedTargetType: 'GlobalCharacter',\n    expectedProjectId: 'global-asset-hub',\n  },\n  {\n    routeFile: 'src/app/api/asset-hub/modify-image/route.ts',\n    body: {\n      type: 'character',\n      id: 'global-character-1',\n      modifyPrompt: 'sharpen details',\n      appearanceIndex: 0,\n      imageIndex: 0,\n      extraImageUrls: ['https://example.com/ref-a.png'],\n    },\n    expectedTaskType: TASK_TYPE.ASSET_HUB_MODIFY,\n    expectedTargetType: 'GlobalCharacterAppearance',\n    expectedProjectId: 'global-asset-hub',\n  },\n  {\n    routeFile: 'src/app/api/asset-hub/voice-design/route.ts',\n    body: { voicePrompt: 'female calm narrator', previewText: '你好世界' },\n    expectedTaskType: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,\n    expectedTargetType: 'GlobalAssetHubVoiceDesign',\n    expectedProjectId: 'global-asset-hub',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/generate-image/route.ts',\n    body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,\n    expectedTargetType: 'CharacterAppearance',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/generate-video/route.ts',\n    body: { videoModel: 'fal::video-model', storyboardId: 'storyboard-1', panelIndex: 0 },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.VIDEO_PANEL,\n    expectedTargetType: 'NovelPromotionPanel',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/insert-panel/route.ts',\n    body: { storyboardId: 'storyboard-1', insertAfterPanelId: 'panel-ins' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.INSERT_PANEL,\n    expectedTargetType: 'NovelPromotionStoryboard',\n    expectedProjectId: 'project-1',\n    expectedPayloadSubset: {\n      storyboardId: 'storyboard-1',\n      insertAfterPanelId: 'panel-ins',\n      userInput: '请根据前后镜头自动分析并插入一个自然衔接的新分镜。',\n    },\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/lip-sync/route.ts',\n    body: {\n      storyboardId: 'storyboard-1',\n      panelIndex: 0,\n      voiceLineId: 'line-1',\n      lipSyncModel: 'fal::lip-model',\n    },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.LIP_SYNC,\n    expectedTargetType: 'NovelPromotionPanel',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts',\n    body: {\n      type: 'character',\n      characterId: 'character-1',\n      appearanceId: 'appearance-1',\n      modifyPrompt: 'enhance texture',\n      extraImageUrls: ['https://example.com/ref-b.png'],\n    },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,\n    expectedTargetType: 'CharacterAppearance',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/modify-storyboard-image/route.ts',\n    body: {\n      storyboardId: 'storyboard-1',\n      panelIndex: 0,\n      modifyPrompt: 'increase contrast',\n      extraImageUrls: ['https://example.com/ref-c.png'],\n      selectedAssets: [{ imageUrl: 'https://example.com/ref-d.png' }],\n    },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,\n    expectedTargetType: 'NovelPromotionPanel',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/panel-variant/route.ts',\n    body: {\n      storyboardId: 'storyboard-1',\n      insertAfterPanelId: 'panel-ins',\n      sourcePanelId: 'panel-src',\n      variant: { video_prompt: 'new prompt', description: 'variant desc' },\n    },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.PANEL_VARIANT,\n    expectedTargetType: 'NovelPromotionPanel',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-group/route.ts',\n    body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.REGENERATE_GROUP,\n    expectedTargetType: 'CharacterAppearance',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-panel-image/route.ts',\n    body: { panelId: 'panel-1', count: 1 },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.IMAGE_PANEL,\n    expectedTargetType: 'NovelPromotionPanel',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-single-image/route.ts',\n    body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1', imageIndex: 0 },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,\n    expectedTargetType: 'CharacterAppearance',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-storyboard-text/route.ts',\n    body: { storyboardId: 'storyboard-1' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.REGENERATE_STORYBOARD_TEXT,\n    expectedTargetType: 'NovelPromotionStoryboard',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/voice-design/route.ts',\n    body: { voicePrompt: 'warm female voice', previewText: 'This is preview text' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.VOICE_DESIGN,\n    expectedTargetType: 'NovelPromotionProject',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/voice-generate/route.ts',\n    body: { episodeId: 'episode-1', lineId: 'line-1', audioModel: 'fal::audio-model' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.VOICE_LINE,\n    expectedTargetType: 'NovelPromotionVoiceLine',\n    expectedProjectId: 'project-1',\n  },\n]\n\nasync function invokePostRoute(routeCase: DirectRouteCase): Promise<Response> {\n  const modulePath = toModuleImportPath(routeCase.routeFile)\n  const mod = await import(modulePath)\n  const post = mod.POST as (request: Request, context?: RouteContext) => Promise<Response>\n  const req = buildMockRequest({\n    path: toApiPath(routeCase.routeFile),\n    method: 'POST',\n    body: routeCase.body,\n  })\n  return await post(req, { params: Promise.resolve(routeCase.params || {}) })\n}\n\ndescribe('api contract - direct submit routes (behavior)', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    authState.authenticated = true\n    authState.projectMode = 'novel-promotion'\n    let seq = 0\n    submitTaskMock.mockImplementation(async () => ({\n      taskId: `task-${++seq}`,\n      async: true,\n    }))\n  })\n\n  it('keeps expected coverage size', () => {\n    expect(DIRECT_CASES.length).toBe(16)\n  })\n\n  for (const routeCase of DIRECT_CASES) {\n    it(`${routeCase.routeFile} -> returns 401 when unauthenticated`, async () => {\n      authState.authenticated = false\n      const res = await invokePostRoute(routeCase)\n      expect(res.status).toBe(401)\n      expect(submitTaskMock).not.toHaveBeenCalled()\n    })\n\n    it(`${routeCase.routeFile} -> submits task with expected contract when authenticated`, async () => {\n      const res = await invokePostRoute(routeCase)\n      expect(res.status).toBe(200)\n      expect(submitTaskMock).toHaveBeenCalledWith(expect.objectContaining({\n        type: routeCase.expectedTaskType,\n        targetType: routeCase.expectedTargetType,\n        projectId: routeCase.expectedProjectId,\n        userId: 'user-1',\n      }))\n\n      const submitArg = submitTaskMock.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined\n      expect(submitArg?.type).toBe(routeCase.expectedTaskType)\n      expect(submitArg?.targetType).toBe(routeCase.expectedTargetType)\n      expect(submitArg?.projectId).toBe(routeCase.expectedProjectId)\n      expect(submitArg?.userId).toBe('user-1')\n      if (routeCase.expectedPayloadSubset) {\n        expect(submitArg?.payload).toEqual(expect.objectContaining(routeCase.expectedPayloadSubset))\n      }\n\n      const json = await res.json() as Record<string, unknown>\n      const isVoiceGenerateRoute = routeCase.routeFile.endsWith('/voice-generate/route.ts')\n      if (isVoiceGenerateRoute) {\n        expect(json.success).toBe(true)\n        expect(json.async).toBe(true)\n        expect(typeof json.taskId).toBe('string')\n      } else {\n        expect(json.async).toBe(true)\n        expect(typeof json.taskId).toBe('string')\n      }\n    })\n  }\n})\n"
  },
  {
    "path": "tests/integration/api/contract/infra-routes.test.ts",
    "content": "import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { ROUTE_CATALOG } from '../../../contracts/route-catalog'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authState = vi.hoisted(() => ({\n  authenticated: false,\n}))\n\nconst loggingMock = vi.hoisted(() => ({\n  readAllLogs: vi.fn(async () => 'worker log line 1\\nworker log line 2'),\n}))\n\nconst storageMock = vi.hoisted(() => ({\n  getSignedObjectUrl: vi.fn(async (key: string, ttl: number) => `https://signed.example/${key}?expires=${ttl}`),\n}))\n\nvi.mock('@/lib/api-auth', () => {\n  const unauthorized = () => new Response(\n    JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),\n    { status: 401, headers: { 'content-type': 'application/json' } },\n  )\n\n  return {\n    isErrorResponse: (value: unknown) => value instanceof Response,\n    requireUserAuth: async () => {\n      if (!authState.authenticated) return unauthorized()\n      return { session: { user: { id: 'user-1' } } }\n    },\n  }\n})\n\nvi.mock('@/lib/logging/file-writer', () => loggingMock)\nvi.mock('@/lib/storage', () => storageMock)\n\ndescribe('api contract - infra routes (behavior)', () => {\n  const routes = ROUTE_CATALOG.filter((entry) => entry.contractGroup === 'infra-routes')\n  const originalUploadDir = process.env.UPLOAD_DIR\n  const tempState = {\n    uploadDirAbs: '',\n    uploadDirRel: '',\n  }\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n    authState.authenticated = false\n    vi.resetModules()\n  })\n\n  afterEach(async () => {\n    vi.resetModules()\n    if (tempState.uploadDirAbs) {\n      await fs.rm(tempState.uploadDirAbs, { recursive: true, force: true })\n      tempState.uploadDirAbs = ''\n      tempState.uploadDirRel = ''\n    }\n    if (originalUploadDir === undefined) {\n      delete process.env.UPLOAD_DIR\n    } else {\n      process.env.UPLOAD_DIR = originalUploadDir\n    }\n  })\n\n  async function prepareUploadDir(): Promise<void> {\n    const unique = `test-uploads-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`\n    tempState.uploadDirRel = path.join('.tmp', unique)\n    tempState.uploadDirAbs = path.join(process.cwd(), tempState.uploadDirRel)\n    process.env.UPLOAD_DIR = tempState.uploadDirRel\n    await fs.mkdir(tempState.uploadDirAbs, { recursive: true })\n  }\n\n  it('infra route group exists', () => {\n    expect(routes.map((entry) => entry.routeFile)).toEqual(expect.arrayContaining([\n      'src/app/api/admin/download-logs/route.ts',\n      'src/app/api/cos/image/route.ts',\n      'src/app/api/files/[...path]/route.ts',\n      'src/app/api/storage/sign/route.ts',\n      'src/app/api/system/boot-id/route.ts',\n    ]))\n  })\n\n  it('GET /api/admin/download-logs rejects unauthenticated requests', async () => {\n    const mod = await import('@/app/api/admin/download-logs/route')\n    const req = buildMockRequest({\n      path: '/api/admin/download-logs',\n      method: 'GET',\n    })\n\n    const res = await mod.GET(req, { params: Promise.resolve({}) })\n    expect(res.status).toBe(401)\n    expect(loggingMock.readAllLogs).not.toHaveBeenCalled()\n  })\n\n  it('GET /api/admin/download-logs returns attachment headers when authenticated', async () => {\n    authState.authenticated = true\n    const mod = await import('@/app/api/admin/download-logs/route')\n    const req = buildMockRequest({\n      path: '/api/admin/download-logs',\n      method: 'GET',\n    })\n\n    const res = await mod.GET(req, { params: Promise.resolve({}) })\n    const text = await res.text()\n\n    expect(res.status).toBe(200)\n    expect(text).toContain('worker log line 1')\n    expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8')\n    expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename=\"waoowaoo-logs-/)\n  })\n\n  it('GET /api/cos/image redirects to signed storage route with normalized query', async () => {\n    const mod = await import('@/app/api/cos/image/route')\n    const req = buildMockRequest({\n      path: '/api/cos/image?key=folder/a.png&expires=7200',\n      method: 'GET',\n    })\n\n    const res = await mod.GET(req, { params: Promise.resolve({}) })\n\n    expect(res.status).toBe(307)\n    expect(res.headers.get('location')).toBe('http://localhost:3000/api/storage/sign?key=folder%2Fa.png&expires=7200')\n  })\n\n  it('GET /api/storage/sign redirects to signed object url with default ttl', async () => {\n    const mod = await import('@/app/api/storage/sign/route')\n    const req = buildMockRequest({\n      path: '/api/storage/sign?key=folder/a.png',\n      method: 'GET',\n    })\n\n    const res = await mod.GET(req, { params: Promise.resolve({}) })\n\n    expect(storageMock.getSignedObjectUrl).toHaveBeenCalledWith('folder/a.png', 3600)\n    expect(res.status).toBe(307)\n    expect(res.headers.get('location')).toBe('https://signed.example/folder/a.png?expires=3600')\n  })\n\n  it('GET /api/system/boot-id returns the current server boot id', async () => {\n    const mod = await import('@/app/api/system/boot-id/route')\n    const serverBoot = await import('@/lib/server-boot')\n    const res = await mod.GET()\n    const json = await res.json() as { bootId: string }\n\n    expect(res.status).toBe(200)\n    expect(json.bootId).toBe(serverBoot.SERVER_BOOT_ID)\n    expect(typeof json.bootId).toBe('string')\n    expect(json.bootId.length).toBeGreaterThan(0)\n  })\n\n  it('GET /api/files/[...path] rejects path traversal attempts', async () => {\n    await prepareUploadDir()\n    const mod = await import('@/app/api/files/[...path]/route')\n    const req = buildMockRequest({\n      path: '/api/files/%2E%2E/secret.txt',\n      method: 'GET',\n    })\n\n    const res = await mod.GET(req, {\n      params: Promise.resolve({ path: ['..', 'secret.txt'] }),\n    })\n    const json = await res.json() as { error: string }\n\n    expect(res.status).toBe(403)\n    expect(json.error).toBe('Access denied')\n  })\n\n  it('GET /api/files/[...path] returns 404 when the file is missing', async () => {\n    await prepareUploadDir()\n    const mod = await import('@/app/api/files/[...path]/route')\n    const req = buildMockRequest({\n      path: '/api/files/missing.txt',\n      method: 'GET',\n    })\n\n    const res = await mod.GET(req, {\n      params: Promise.resolve({ path: ['missing.txt'] }),\n    })\n    const json = await res.json() as { error: string }\n\n    expect(res.status).toBe(404)\n    expect(json.error).toBe('File not found')\n  })\n\n  it('GET /api/files/[...path] serves local files from the configured upload dir', async () => {\n    await prepareUploadDir()\n    const nestedDir = path.join(tempState.uploadDirAbs, 'folder')\n    await fs.mkdir(nestedDir, { recursive: true })\n    await fs.writeFile(path.join(nestedDir, 'hello.txt'), 'hello local file', 'utf8')\n\n    const mod = await import('@/app/api/files/[...path]/route')\n    const req = buildMockRequest({\n      path: '/api/files/folder/hello.txt',\n      method: 'GET',\n    })\n\n    const res = await mod.GET(req, {\n      params: Promise.resolve({ path: ['folder', 'hello.txt'] }),\n    })\n    const text = await res.text()\n\n    expect(res.status).toBe(200)\n    expect(text).toBe('hello local file')\n    expect(res.headers.get('content-type')).toBe('text/plain')\n    expect(res.headers.get('cache-control')).toBe('public, max-age=31536000')\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/contract/llm-observe-routes.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { NextResponse } from 'next/server'\nimport { TASK_TYPE, type TaskType } from '@/lib/task/types'\nimport { buildMockRequest } from '../../../helpers/request'\n\ntype AuthState = {\n  authenticated: boolean\n  projectMode: 'novel-promotion' | 'other'\n}\n\ntype LLMRouteCase = {\n  routeFile: string\n  body: Record<string, unknown>\n  params?: Record<string, string>\n  expectedTaskType: TaskType\n  expectedTargetType: string\n  expectedProjectId: string\n}\n\ntype RouteContext = {\n  params: Promise<Record<string, string>>\n}\n\nconst authState = vi.hoisted<AuthState>(() => ({\n  authenticated: true,\n  projectMode: 'novel-promotion',\n}))\n\nconst maybeSubmitLLMTaskMock = vi.hoisted(() =>\n  vi.fn<typeof import('@/lib/llm-observe/route-task').maybeSubmitLLMTask>(async () => NextResponse.json({\n    success: true,\n    async: true,\n    taskId: 'task-1',\n    runId: null,\n    status: 'queued',\n    deduped: false,\n  })),\n)\n\nconst configServiceMock = vi.hoisted(() => ({\n  getUserModelConfig: vi.fn(async () => ({\n    analysisModel: 'llm::analysis',\n  })),\n  getProjectModelConfig: vi.fn(async () => ({\n    analysisModel: 'llm::analysis',\n  })),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  globalCharacter: {\n    findUnique: vi.fn(async () => ({\n      id: 'global-character-1',\n      userId: 'user-1',\n    })),\n  },\n  globalLocation: {\n    findUnique: vi.fn(async () => ({\n      id: 'global-location-1',\n      userId: 'user-1',\n    })),\n  },\n}))\n\nvi.mock('@/lib/api-auth', () => {\n  const unauthorized = () => new Response(\n    JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),\n    { status: 401, headers: { 'content-type': 'application/json' } },\n  )\n\n  return {\n    isErrorResponse: (value: unknown) => value instanceof Response,\n    requireUserAuth: async () => {\n      if (!authState.authenticated) return unauthorized()\n      return { session: { user: { id: 'user-1' } } }\n    },\n    requireProjectAuth: async (projectId: string) => {\n      if (!authState.authenticated) return unauthorized()\n      return {\n        session: { user: { id: 'user-1' } },\n        project: { id: projectId, userId: 'user-1', mode: authState.projectMode },\n      }\n    },\n    requireProjectAuthLight: async (projectId: string) => {\n      if (!authState.authenticated) return unauthorized()\n      return {\n        session: { user: { id: 'user-1' } },\n        project: { id: projectId, userId: 'user-1', mode: authState.projectMode },\n      }\n    },\n  }\n})\n\nvi.mock('@/lib/llm-observe/route-task', () => ({\n  maybeSubmitLLMTask: maybeSubmitLLMTaskMock,\n}))\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nfunction toApiPath(routeFile: string): string {\n  return routeFile\n    .replace(/^src\\/app/, '')\n    .replace(/\\/route\\.ts$/, '')\n    .replace('[projectId]', 'project-1')\n}\n\nfunction toModuleImportPath(routeFile: string): string {\n  return `@/${routeFile.replace(/^src\\//, '').replace(/\\.ts$/, '')}`\n}\n\nconst ROUTE_CASES: ReadonlyArray<LLMRouteCase> = [\n  {\n    routeFile: 'src/app/api/asset-hub/ai-design-character/route.ts',\n    body: { userInstruction: 'design a heroic character' },\n    expectedTaskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER,\n    expectedTargetType: 'GlobalAssetHubCharacterDesign',\n    expectedProjectId: 'global-asset-hub',\n  },\n  {\n    routeFile: 'src/app/api/asset-hub/ai-design-location/route.ts',\n    body: { userInstruction: 'design a noir city location' },\n    expectedTaskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,\n    expectedTargetType: 'GlobalAssetHubLocationDesign',\n    expectedProjectId: 'global-asset-hub',\n  },\n  {\n    routeFile: 'src/app/api/asset-hub/ai-modify-character/route.ts',\n    body: {\n      characterId: 'global-character-1',\n      appearanceIndex: 0,\n      currentDescription: 'old desc',\n      modifyInstruction: 'make the outfit darker',\n    },\n    expectedTaskType: TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER,\n    expectedTargetType: 'GlobalCharacter',\n    expectedProjectId: 'global-asset-hub',\n  },\n  {\n    routeFile: 'src/app/api/asset-hub/ai-modify-location/route.ts',\n    body: {\n      locationId: 'global-location-1',\n      imageIndex: 0,\n      currentDescription: 'old location desc',\n      modifyInstruction: 'add more fog',\n    },\n    expectedTaskType: TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION,\n    expectedTargetType: 'GlobalLocation',\n    expectedProjectId: 'global-asset-hub',\n  },\n  {\n    routeFile: 'src/app/api/asset-hub/reference-to-character/route.ts',\n    body: { referenceImageUrl: 'https://example.com/ref.png' },\n    expectedTaskType: TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,\n    expectedTargetType: 'GlobalCharacter',\n    expectedProjectId: 'global-asset-hub',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/ai-create-character/route.ts',\n    body: { userInstruction: 'create a rebel hero' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.AI_CREATE_CHARACTER,\n    expectedTargetType: 'NovelPromotionCharacterDesign',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/ai-create-location/route.ts',\n    body: { userInstruction: 'create a mountain temple' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.AI_CREATE_LOCATION,\n    expectedTargetType: 'NovelPromotionLocationDesign',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts',\n    body: {\n      characterId: 'character-1',\n      appearanceId: 'appearance-1',\n      currentDescription: 'old appearance',\n      modifyInstruction: 'add armor',\n    },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.AI_MODIFY_APPEARANCE,\n    expectedTargetType: 'CharacterAppearance',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-location/route.ts',\n    body: {\n      locationId: 'location-1',\n      currentDescription: 'old location',\n      modifyInstruction: 'add rain',\n    },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.AI_MODIFY_LOCATION,\n    expectedTargetType: 'NovelPromotionLocation',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-shot-prompt/route.ts',\n    body: {\n      panelId: 'panel-1',\n      currentPrompt: 'old prompt',\n      modifyInstruction: 'more dramatic angle',\n    },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.AI_MODIFY_SHOT_PROMPT,\n    expectedTargetType: 'NovelPromotionPanel',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/analyze-global/route.ts',\n    body: {},\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.ANALYZE_GLOBAL,\n    expectedTargetType: 'NovelPromotionProject',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/analyze-shot-variants/route.ts',\n    body: { panelId: 'panel-1' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.ANALYZE_SHOT_VARIANTS,\n    expectedTargetType: 'NovelPromotionPanel',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/analyze/route.ts',\n    body: { episodeId: 'episode-1', content: 'Analyze this chapter' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.ANALYZE_NOVEL,\n    expectedTargetType: 'NovelPromotionProject',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/character-profile/batch-confirm/route.ts',\n    body: { items: ['character-1', 'character-2'] },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM,\n    expectedTargetType: 'NovelPromotionProject',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/character-profile/confirm/route.ts',\n    body: { characterId: 'character-1' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.CHARACTER_PROFILE_CONFIRM,\n    expectedTargetType: 'NovelPromotionCharacter',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/clips/route.ts',\n    body: { episodeId: 'episode-1' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.CLIPS_BUILD,\n    expectedTargetType: 'NovelPromotionEpisode',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/episodes/split/route.ts',\n    body: { content: 'x'.repeat(120) },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.EPISODE_SPLIT_LLM,\n    expectedTargetType: 'NovelPromotionProject',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/reference-to-character/route.ts',\n    body: { referenceImageUrl: 'https://example.com/ref.png' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.REFERENCE_TO_CHARACTER,\n    expectedTargetType: 'NovelPromotionProject',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/screenplay-conversion/route.ts',\n    body: { episodeId: 'episode-1' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.SCREENPLAY_CONVERT,\n    expectedTargetType: 'NovelPromotionEpisode',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route.ts',\n    body: { episodeId: 'episode-1' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n    expectedTargetType: 'NovelPromotionEpisode',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/story-to-script-stream/route.ts',\n    body: { episodeId: 'episode-1', content: 'story text' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n    expectedTargetType: 'NovelPromotionEpisode',\n    expectedProjectId: 'project-1',\n  },\n  {\n    routeFile: 'src/app/api/novel-promotion/[projectId]/voice-analyze/route.ts',\n    body: { episodeId: 'episode-1' },\n    params: { projectId: 'project-1' },\n    expectedTaskType: TASK_TYPE.VOICE_ANALYZE,\n    expectedTargetType: 'NovelPromotionEpisode',\n    expectedProjectId: 'project-1',\n  },\n]\n\nasync function invokePostRoute(routeCase: LLMRouteCase): Promise<Response> {\n  const modulePath = toModuleImportPath(routeCase.routeFile)\n  const mod = await import(modulePath)\n  const post = mod.POST as (request: Request, context?: RouteContext) => Promise<Response>\n  const req = buildMockRequest({\n    path: toApiPath(routeCase.routeFile),\n    method: 'POST',\n    body: routeCase.body,\n  })\n  return await post(req, { params: Promise.resolve(routeCase.params || {}) })\n}\n\ndescribe('api contract - llm observe routes (behavior)', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    authState.authenticated = true\n    authState.projectMode = 'novel-promotion'\n    maybeSubmitLLMTaskMock.mockResolvedValue(\n      NextResponse.json({\n        success: true,\n        async: true,\n        taskId: 'task-1',\n        runId: null,\n        status: 'queued',\n        deduped: false,\n      }),\n    )\n  })\n\n  it('keeps expected coverage size', () => {\n    expect(ROUTE_CASES.length).toBe(22)\n  })\n\n  for (const routeCase of ROUTE_CASES) {\n    it(`${routeCase.routeFile} -> returns 401 when unauthenticated`, async () => {\n      authState.authenticated = false\n      const res = await invokePostRoute(routeCase)\n      expect(res.status).toBe(401)\n      expect(maybeSubmitLLMTaskMock).not.toHaveBeenCalled()\n    })\n\n    it(`${routeCase.routeFile} -> submits llm task with expected contract when authenticated`, async () => {\n      const res = await invokePostRoute(routeCase)\n      expect(res.status).toBe(200)\n      expect(maybeSubmitLLMTaskMock).toHaveBeenCalledWith(expect.objectContaining({\n        type: routeCase.expectedTaskType,\n        targetType: routeCase.expectedTargetType,\n        projectId: routeCase.expectedProjectId,\n        userId: 'user-1',\n      }))\n\n      const callArg = maybeSubmitLLMTaskMock.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined\n      expect(callArg?.type).toBe(routeCase.expectedTaskType)\n      expect(callArg?.targetType).toBe(routeCase.expectedTargetType)\n      expect(callArg?.projectId).toBe(routeCase.expectedProjectId)\n      expect(callArg?.userId).toBe('user-1')\n\n      const json = await res.json() as Record<string, unknown>\n      expect(json.async).toBe(true)\n      expect(typeof json.taskId).toBe('string')\n    })\n  }\n})\n"
  },
  {
    "path": "tests/integration/api/contract/run-cancel.route.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authState = vi.hoisted(() => ({ authenticated: true }))\nconst getRunByIdMock = vi.hoisted(() => vi.fn())\nconst requestRunCancelMock = vi.hoisted(() => vi.fn())\nconst cancelTaskMock = vi.hoisted(() => vi.fn())\nconst publishRunEventMock = vi.hoisted(() => vi.fn(async () => undefined))\n\nvi.mock('@/lib/api-auth', () => {\n  const unauthorized = () => new Response(\n    JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),\n    { status: 401, headers: { 'content-type': 'application/json' } },\n  )\n\n  return {\n    isErrorResponse: (value: unknown) => value instanceof Response,\n    requireUserAuth: async () => {\n      if (!authState.authenticated) return unauthorized()\n      return { session: { user: { id: 'user-1' } } }\n    },\n  }\n})\n\nvi.mock('@/lib/run-runtime/service', () => ({\n  getRunById: getRunByIdMock,\n  requestRunCancel: requestRunCancelMock,\n}))\n\nvi.mock('@/lib/task/service', () => ({\n  cancelTask: cancelTaskMock,\n}))\n\nvi.mock('@/lib/run-runtime/publisher', () => ({\n  publishRunEvent: publishRunEventMock,\n}))\n\ndescribe('api contract - run cancel route', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    authState.authenticated = true\n    getRunByIdMock.mockResolvedValue({\n      id: 'run-1',\n      userId: 'user-1',\n      projectId: 'project-1',\n      taskId: 'task-1',\n    })\n    requestRunCancelMock.mockResolvedValue({\n      id: 'run-1',\n      userId: 'user-1',\n      projectId: 'project-1',\n      taskId: 'task-1',\n      status: 'canceling',\n    })\n    cancelTaskMock.mockResolvedValue({\n      task: {\n        id: 'task-1',\n        status: 'canceled',\n        errorCode: 'TASK_CANCELLED',\n        errorMessage: 'Run cancelled by user',\n      },\n      cancelled: true,\n    })\n  })\n\n  it('marks the run canceled and mirrors task cancellation without failing the task', async () => {\n    const { POST } = await import('@/app/api/runs/[runId]/cancel/route')\n\n    const req = buildMockRequest({\n      path: '/api/runs/run-1/cancel',\n      method: 'POST',\n    })\n    const res = await POST(req, {\n      params: Promise.resolve({ runId: 'run-1' }),\n    })\n\n    expect(res.status).toBe(200)\n    const payload = await res.json() as {\n      success: boolean\n      run: {\n        id: string\n        status: string\n      }\n    }\n    expect(payload.success).toBe(true)\n    expect(payload.run).toMatchObject({\n      id: 'run-1',\n      status: 'canceling',\n    })\n    expect(cancelTaskMock).toHaveBeenCalledWith('task-1', 'Run cancelled by user')\n    expect(publishRunEventMock).toHaveBeenCalledWith(expect.objectContaining({\n      runId: 'run-1',\n      eventType: 'run.canceled',\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/contract/run-step-retry.route.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\ntype RouteContext = {\n  params: Promise<{ runId: string; stepKey: string }>\n}\n\nconst authState = vi.hoisted(() => ({ authenticated: true }))\nconst getRunByIdMock = vi.hoisted(() => vi.fn())\nconst retryFailedStepMock = vi.hoisted(() => vi.fn())\nconst submitTaskMock = vi.hoisted(() => vi.fn())\nconst resolveRequiredTaskLocaleMock = vi.hoisted(() => vi.fn(() => 'zh'))\n\nvi.mock('@/lib/api-auth', () => {\n  const unauthorized = () => new Response(\n    JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),\n    { status: 401, headers: { 'content-type': 'application/json' } },\n  )\n\n  return {\n    isErrorResponse: (value: unknown) => value instanceof Response,\n    requireUserAuth: async () => {\n      if (!authState.authenticated) return unauthorized()\n      return { session: { user: { id: 'user-1' } } }\n    },\n  }\n})\n\nvi.mock('@/lib/run-runtime/service', () => ({\n  getRunById: getRunByIdMock,\n  retryFailedStep: retryFailedStepMock,\n}))\n\nvi.mock('@/lib/task/submitter', () => ({\n  submitTask: submitTaskMock,\n}))\n\nvi.mock('@/lib/task/resolve-locale', () => ({\n  resolveRequiredTaskLocale: resolveRequiredTaskLocaleMock,\n}))\n\ndescribe('api contract - run step retry route', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    authState.authenticated = true\n\n    getRunByIdMock.mockResolvedValue({\n      id: 'run-1',\n      userId: 'user-1',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      workflowType: 'story_to_script_run',\n      taskType: 'story_to_script_run',\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      input: {\n        episodeId: 'episode-1',\n        content: 'test content',\n        meta: { locale: 'zh' },\n      },\n    })\n    retryFailedStepMock.mockResolvedValue({\n      run: { id: 'run-1' },\n      step: { stepKey: 'screenplay_clip_2' },\n      retryAttempt: 2,\n    })\n    submitTaskMock.mockResolvedValue({\n      success: true,\n      async: true,\n      taskId: 'task-retry-1',\n      runId: 'run-1',\n      status: 'queued',\n      deduped: false,\n    })\n  })\n\n  it('rejects retry when step is not failed', async () => {\n    retryFailedStepMock.mockRejectedValue(new Error('RUN_STEP_NOT_FAILED'))\n    const route = await import('@/app/api/runs/[runId]/steps/[stepKey]/retry/route')\n\n    const req = buildMockRequest({\n      path: '/api/runs/run-1/steps/screenplay_clip_2/retry',\n      method: 'POST',\n      body: { modelOverride: 'openai/gpt-5' },\n    })\n    const res = await route.POST(req, {\n      params: Promise.resolve({ runId: 'run-1', stepKey: 'screenplay_clip_2' }),\n    } as RouteContext)\n\n    expect(res.status).toBe(400)\n    expect(submitTaskMock).not.toHaveBeenCalled()\n  })\n\n  it('submits retry task bound to existing run id', async () => {\n    const route = await import('@/app/api/runs/[runId]/steps/[stepKey]/retry/route')\n\n    const req = buildMockRequest({\n      path: '/api/runs/run-1/steps/screenplay_clip_2/retry',\n      method: 'POST',\n      body: {\n        modelOverride: 'openai/gpt-5',\n        reason: 'manual retry',\n      },\n    })\n    const res = await route.POST(req, {\n      params: Promise.resolve({ runId: 'run-1', stepKey: 'screenplay_clip_2' }),\n    } as RouteContext)\n\n    expect(res.status).toBe(200)\n    const payload = await res.json() as {\n      success: boolean\n      runId: string\n      stepKey: string\n      retryAttempt: number\n      taskId: string\n    }\n    expect(payload.success).toBe(true)\n    expect(payload.runId).toBe('run-1')\n    expect(payload.stepKey).toBe('screenplay_clip_2')\n    expect(payload.retryAttempt).toBe(2)\n    expect(payload.taskId).toBe('task-retry-1')\n\n    expect(submitTaskMock).toHaveBeenCalledWith(expect.objectContaining({\n      projectId: 'project-1',\n      type: 'story_to_script_run',\n      payload: expect.objectContaining({\n        runId: 'run-1',\n        retryStepKey: 'screenplay_clip_2',\n        retryStepAttempt: 2,\n        model: 'openai/gpt-5',\n      }),\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/contract/task-infra-routes.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_STATUS } from '@/lib/task/types'\nimport { buildMockRequest } from '../../../helpers/request'\n\ntype AuthState = {\n  authenticated: boolean\n}\n\ntype RouteContext = {\n  params: Promise<{ taskId: string }>\n}\n\ntype EmptyRouteContext = {\n  params: Promise<Record<string, string>>\n}\n\ntype ReplayEvent = Awaited<ReturnType<typeof import('@/lib/task/publisher').listEventsAfter>>[number]\ntype TaskLifecycleReplayEvent = Awaited<ReturnType<typeof import('@/lib/task/publisher').listTaskLifecycleEvents>>[number]\n\ntype TaskRecord = {\n  id: string\n  userId: string\n  projectId: string\n  type: string\n  targetType: string\n  targetId: string\n  status: string\n  errorCode: string | null\n  errorMessage: string | null\n}\n\nconst authState = vi.hoisted<AuthState>(() => ({\n  authenticated: true,\n}))\n\nconst queryTasksMock = vi.hoisted(() => vi.fn())\nconst dismissFailedTasksMock = vi.hoisted(() => vi.fn())\nconst getTaskByIdMock = vi.hoisted(() => vi.fn())\nconst cancelTaskMock = vi.hoisted(() => vi.fn())\nconst removeTaskJobMock = vi.hoisted(() => vi.fn(async () => true))\nconst publishTaskEventMock = vi.hoisted(() => vi.fn(async () => undefined))\nconst queryTaskTargetStatesMock = vi.hoisted(() => vi.fn())\nconst withPrismaRetryMock = vi.hoisted(() => vi.fn(async <T>(fn: () => Promise<T>) => await fn()))\nconst listEventsAfterMock = vi.hoisted(() =>\n  vi.fn<typeof import('@/lib/task/publisher').listEventsAfter>(async () => []),\n)\nconst listTaskLifecycleEventsMock = vi.hoisted(() =>\n  vi.fn<typeof import('@/lib/task/publisher').listTaskLifecycleEvents>(async () => []),\n)\nconst addChannelListenerMock = vi.hoisted(() =>\n  vi.fn<(channel: string, listener: (message: string) => void) => Promise<() => Promise<void>>>(\n    async () => async () => undefined,\n  ),\n)\nconst subscriberState = vi.hoisted(() => ({\n  listener: null as ((message: string) => void) | null,\n}))\n\nvi.mock('@/lib/api-auth', () => {\n  const unauthorized = () => new Response(\n    JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),\n    { status: 401, headers: { 'content-type': 'application/json' } },\n  )\n\n  return {\n    isErrorResponse: (value: unknown) => value instanceof Response,\n    requireUserAuth: async () => {\n      if (!authState.authenticated) return unauthorized()\n      return { session: { user: { id: 'user-1' } } }\n    },\n    requireProjectAuthLight: async (projectId: string) => {\n      if (!authState.authenticated) return unauthorized()\n      return {\n        session: { user: { id: 'user-1' } },\n        project: { id: projectId, userId: 'user-1' },\n      }\n    },\n  }\n})\n\nvi.mock('@/lib/task/service', () => ({\n  queryTasks: queryTasksMock,\n  dismissFailedTasks: dismissFailedTasksMock,\n  getTaskById: getTaskByIdMock,\n  cancelTask: cancelTaskMock,\n}))\n\nvi.mock('@/lib/task/queues', () => ({\n  removeTaskJob: removeTaskJobMock,\n}))\n\nvi.mock('@/lib/task/publisher', () => ({\n  publishTaskEvent: publishTaskEventMock,\n  getProjectChannel: vi.fn((projectId: string) => `project:${projectId}`),\n  listEventsAfter: listEventsAfterMock,\n  listTaskLifecycleEvents: listTaskLifecycleEventsMock,\n}))\n\nvi.mock('@/lib/task/state-service', () => ({\n  queryTaskTargetStates: queryTaskTargetStatesMock,\n}))\n\nvi.mock('@/lib/prisma-retry', () => ({\n  withPrismaRetry: withPrismaRetryMock,\n}))\n\nvi.mock('@/lib/sse/shared-subscriber', () => ({\n  getSharedSubscriber: vi.fn(() => ({\n    addChannelListener: addChannelListenerMock,\n  })),\n}))\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: {\n    task: {\n      findMany: vi.fn(async () => []),\n    },\n  },\n}))\n\nconst baseTask: TaskRecord = {\n  id: 'task-1',\n  userId: 'user-1',\n  projectId: 'project-1',\n  type: 'IMAGE_CHARACTER',\n  targetType: 'CharacterAppearance',\n  targetId: 'appearance-1',\n  status: TASK_STATUS.FAILED,\n  errorCode: null,\n  errorMessage: null,\n}\n\ndescribe('api contract - task infra routes (behavior)', () => {\n  const emptyRouteContext: EmptyRouteContext = { params: Promise.resolve({}) }\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n    authState.authenticated = true\n    subscriberState.listener = null\n\n    queryTasksMock.mockResolvedValue([baseTask])\n    dismissFailedTasksMock.mockResolvedValue(1)\n    getTaskByIdMock.mockResolvedValue(baseTask)\n    cancelTaskMock.mockResolvedValue({\n      task: {\n        ...baseTask,\n        status: TASK_STATUS.CANCELED,\n        errorCode: 'TASK_CANCELLED',\n        errorMessage: 'Task cancelled by user',\n      },\n      cancelled: true,\n    })\n    queryTaskTargetStatesMock.mockResolvedValue([\n      {\n        targetType: 'CharacterAppearance',\n        targetId: 'appearance-1',\n        active: true,\n        status: TASK_STATUS.PROCESSING,\n        taskId: 'task-1',\n        updatedAt: new Date().toISOString(),\n      },\n    ])\n    addChannelListenerMock.mockImplementation(async (_channel: string, listener: (message: string) => void) => {\n      subscriberState.listener = listener\n      return async () => undefined\n    })\n    listTaskLifecycleEventsMock.mockResolvedValue([])\n  })\n\n  it('GET /api/tasks: unauthenticated -> 401; authenticated -> 200 with caller-owned tasks', async () => {\n    const { GET } = await import('@/app/api/tasks/route')\n\n    authState.authenticated = false\n    const unauthorizedReq = buildMockRequest({\n      path: '/api/tasks',\n      method: 'GET',\n      query: { projectId: 'project-1', limit: 20 },\n    })\n    const unauthorizedRes = await GET(unauthorizedReq, emptyRouteContext)\n    expect(unauthorizedRes.status).toBe(401)\n\n    authState.authenticated = true\n    const req = buildMockRequest({\n      path: '/api/tasks',\n      method: 'GET',\n      query: { projectId: 'project-1', limit: 20, targetId: 'appearance-1' },\n    })\n    const res = await GET(req, emptyRouteContext)\n    expect(res.status).toBe(200)\n\n    const payload = await res.json() as { tasks: TaskRecord[] }\n    expect(payload.tasks).toHaveLength(1)\n    expect(payload.tasks[0]?.id).toBe('task-1')\n    expect(queryTasksMock).toHaveBeenCalledWith(expect.objectContaining({\n      projectId: 'project-1',\n      targetId: 'appearance-1',\n      limit: 20,\n    }))\n  })\n\n  it('POST /api/tasks/dismiss: invalid params -> 400; success -> dismissed count', async () => {\n    const { POST } = await import('@/app/api/tasks/dismiss/route')\n\n    const invalidReq = buildMockRequest({\n      path: '/api/tasks/dismiss',\n      method: 'POST',\n      body: { taskIds: [] },\n    })\n    const invalidRes = await POST(invalidReq, emptyRouteContext)\n    expect(invalidRes.status).toBe(400)\n\n    const req = buildMockRequest({\n      path: '/api/tasks/dismiss',\n      method: 'POST',\n      body: { taskIds: ['task-1', 'task-2'] },\n    })\n    const res = await POST(req, emptyRouteContext)\n    expect(res.status).toBe(200)\n\n    const payload = await res.json() as { success: boolean; dismissed: number }\n    expect(payload.success).toBe(true)\n    expect(payload.dismissed).toBe(1)\n    expect(dismissFailedTasksMock).toHaveBeenCalledWith(['task-1', 'task-2'], 'user-1')\n  })\n\n  it('POST /api/task-target-states: validates payload and returns queried states', async () => {\n    const { POST } = await import('@/app/api/task-target-states/route')\n\n    const invalidReq = buildMockRequest({\n      path: '/api/task-target-states',\n      method: 'POST',\n      body: { projectId: 'project-1' },\n    })\n    const invalidRes = await POST(invalidReq, emptyRouteContext)\n    expect(invalidRes.status).toBe(400)\n\n    const req = buildMockRequest({\n      path: '/api/task-target-states',\n      method: 'POST',\n      body: {\n        projectId: 'project-1',\n        targets: [\n          {\n            targetType: 'CharacterAppearance',\n            targetId: 'appearance-1',\n            types: ['IMAGE_CHARACTER'],\n          },\n        ],\n      },\n    })\n    const res = await POST(req, emptyRouteContext)\n    expect(res.status).toBe(200)\n\n    const payload = await res.json() as { states: Array<Record<string, unknown>> }\n    expect(payload.states).toHaveLength(1)\n    expect(withPrismaRetryMock).toHaveBeenCalledTimes(1)\n    expect(queryTaskTargetStatesMock).toHaveBeenCalledWith({\n      projectId: 'project-1',\n      userId: 'user-1',\n      targets: [\n        {\n          targetType: 'CharacterAppearance',\n          targetId: 'appearance-1',\n          types: ['IMAGE_CHARACTER'],\n        },\n      ],\n    })\n  })\n\n  it('GET /api/tasks/[taskId]: enforces ownership and returns task detail', async () => {\n    const route = await import('@/app/api/tasks/[taskId]/route')\n\n    authState.authenticated = false\n    const unauthorizedReq = buildMockRequest({ path: '/api/tasks/task-1', method: 'GET' })\n    const unauthorizedRes = await route.GET(unauthorizedReq, { params: Promise.resolve({ taskId: 'task-1' }) })\n    expect(unauthorizedRes.status).toBe(401)\n\n    authState.authenticated = true\n    getTaskByIdMock.mockResolvedValueOnce({ ...baseTask, userId: 'other-user' })\n    const notFoundReq = buildMockRequest({ path: '/api/tasks/task-1', method: 'GET' })\n    const notFoundRes = await route.GET(notFoundReq, { params: Promise.resolve({ taskId: 'task-1' }) })\n    expect(notFoundRes.status).toBe(404)\n\n    const req = buildMockRequest({ path: '/api/tasks/task-1', method: 'GET' })\n    const res = await route.GET(req, { params: Promise.resolve({ taskId: 'task-1' }) })\n    expect(res.status).toBe(200)\n\n    const payload = await res.json() as { task: TaskRecord }\n    expect(payload.task.id).toBe('task-1')\n  })\n\n  it('GET /api/tasks/[taskId]?includeEvents=1: returns lifecycle events for refresh replay', async () => {\n    const route = await import('@/app/api/tasks/[taskId]/route')\n    const replayEvents: TaskLifecycleReplayEvent[] = [\n      {\n        id: '11',\n        type: 'task.lifecycle',\n        taskId: 'task-1',\n        projectId: 'project-1',\n        userId: 'user-1',\n        ts: new Date().toISOString(),\n        taskType: 'IMAGE_CHARACTER',\n        targetType: 'CharacterAppearance',\n        targetId: 'appearance-1',\n        episodeId: null,\n        payload: {\n          lifecycleType: 'task.processing',\n          stepId: 'clip_1_phase1',\n          stepTitle: '分镜规划',\n          stepIndex: 1,\n          stepTotal: 3,\n          message: 'running',\n        },\n      },\n    ]\n    listTaskLifecycleEventsMock.mockResolvedValueOnce(replayEvents)\n\n    const req = buildMockRequest({\n      path: '/api/tasks/task-1',\n      method: 'GET',\n      query: { includeEvents: '1', eventsLimit: '1200' },\n    })\n    const res = await route.GET(req, { params: Promise.resolve({ taskId: 'task-1' }) })\n    expect(res.status).toBe(200)\n\n    const payload = await res.json() as { task: TaskRecord; events: Array<Record<string, unknown>> }\n    expect(payload.task.id).toBe('task-1')\n    expect(payload.events).toHaveLength(1)\n    expect(payload.events[0]?.id).toBe('11')\n    expect(listTaskLifecycleEventsMock).toHaveBeenCalledWith('task-1', 1200)\n  })\n\n  it('DELETE /api/tasks/[taskId]: cancellation publishes cancelled event payload', async () => {\n    const { DELETE } = await import('@/app/api/tasks/[taskId]/route')\n\n    const req = buildMockRequest({ path: '/api/tasks/task-1', method: 'DELETE' })\n    const res = await DELETE(req, { params: Promise.resolve({ taskId: 'task-1' }) } as RouteContext)\n    expect(res.status).toBe(200)\n    const payload = await res.json() as { task: TaskRecord; cancelled: boolean }\n\n    expect(removeTaskJobMock).toHaveBeenCalledWith('task-1')\n    expect(payload.cancelled).toBe(true)\n    expect(payload.task.status).toBe(TASK_STATUS.CANCELED)\n    expect(publishTaskEventMock).toHaveBeenCalledWith(expect.objectContaining({\n      taskId: 'task-1',\n      projectId: 'project-1',\n      payload: expect.objectContaining({\n        cancelled: true,\n        stage: 'cancelled',\n      }),\n    }))\n  })\n\n  it('GET /api/sse: missing projectId -> 400; unauthenticated with projectId -> 401', async () => {\n    const { GET } = await import('@/app/api/sse/route')\n\n    const invalidReq = buildMockRequest({ path: '/api/sse', method: 'GET' })\n    const invalidRes = await GET(invalidReq, emptyRouteContext)\n    expect(invalidRes.status).toBe(400)\n\n    authState.authenticated = false\n    const unauthorizedReq = buildMockRequest({\n      path: '/api/sse',\n      method: 'GET',\n      query: { projectId: 'project-1' },\n    })\n    const unauthorizedRes = await GET(unauthorizedReq, emptyRouteContext)\n    expect(unauthorizedRes.status).toBe(401)\n  })\n\n  it('GET /api/sse: authenticated replay request returns SSE stream and replays missed events', async () => {\n    const { GET } = await import('@/app/api/sse/route')\n\n    listEventsAfterMock.mockResolvedValueOnce([\n      {\n        id: '4',\n        type: 'task.lifecycle',\n        taskId: 'task-1',\n        projectId: 'project-1',\n        userId: 'user-1',\n        ts: new Date().toISOString(),\n        taskType: 'IMAGE_CHARACTER',\n        targetType: 'CharacterAppearance',\n        targetId: 'appearance-1',\n        episodeId: null,\n        payload: { lifecycleType: 'task.created' },\n      } satisfies ReplayEvent,\n    ])\n\n    const req = buildMockRequest({\n      path: '/api/sse',\n      method: 'GET',\n      query: { projectId: 'project-1' },\n      headers: { 'last-event-id': '3' },\n    })\n    const res = await GET(req, emptyRouteContext)\n\n    expect(res.status).toBe(200)\n    expect(res.headers.get('content-type')).toContain('text/event-stream')\n    expect(listEventsAfterMock).toHaveBeenCalledWith('project-1', 3, 5000)\n    expect(addChannelListenerMock).toHaveBeenCalledWith('project:project-1', expect.any(Function))\n\n    const reader = res.body?.getReader()\n    expect(reader).toBeTruthy()\n    const firstChunk = await reader!.read()\n    expect(firstChunk.done).toBe(false)\n    const decoded = new TextDecoder().decode(firstChunk.value)\n    expect(decoded).toContain('event:')\n    await reader!.cancel()\n  })\n\n  it('GET /api/sse: channel lifecycle stream includes terminal completed event', async () => {\n    const { GET } = await import('@/app/api/sse/route')\n    listEventsAfterMock.mockResolvedValueOnce([])\n\n    const req = buildMockRequest({\n      path: '/api/sse',\n      method: 'GET',\n      query: { projectId: 'project-1' },\n      headers: { 'last-event-id': '10' },\n    })\n    const res = await GET(req, emptyRouteContext)\n    expect(res.status).toBe(200)\n\n    const listener = subscriberState.listener\n    expect(listener).toBeTruthy()\n\n    listener!(JSON.stringify({\n      id: '11',\n      type: 'task.lifecycle',\n      taskId: 'task-1',\n      projectId: 'project-1',\n      userId: 'user-1',\n      ts: new Date().toISOString(),\n      taskType: 'IMAGE_CHARACTER',\n      targetType: 'CharacterAppearance',\n      targetId: 'appearance-1',\n      episodeId: null,\n      payload: { lifecycleType: 'processing', progress: 60 },\n    }))\n    listener!(JSON.stringify({\n      id: '12',\n      type: 'task.lifecycle',\n      taskId: 'task-1',\n      projectId: 'project-1',\n      userId: 'user-1',\n      ts: new Date().toISOString(),\n      taskType: 'IMAGE_CHARACTER',\n      targetType: 'CharacterAppearance',\n      targetId: 'appearance-1',\n      episodeId: null,\n      payload: { lifecycleType: 'completed', progress: 100 },\n    }))\n\n    const reader = res.body?.getReader()\n    expect(reader).toBeTruthy()\n    const chunk1 = await reader!.read()\n    const chunk2 = await reader!.read()\n    const merged = `${new TextDecoder().decode(chunk1.value)}${new TextDecoder().decode(chunk2.value)}`\n\n    expect(merged).toContain('\"lifecycleType\":\"processing\"')\n    expect(merged).toContain('\"lifecycleType\":\"completed\"')\n    expect(merged).toContain('\"taskId\":\"task-1\"')\n    await reader!.cancel()\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/helpers/call-route.ts",
    "content": "import { NextRequest } from 'next/server'\n\ntype RouteParamValue = string | string[] | undefined\ntype RouteParams = Record<string, RouteParamValue>\ntype HeaderMap = Record<string, string>\n\ntype RouteHandler<TParams extends RouteParams = RouteParams> = (\n  req: NextRequest,\n  ctx: { params: Promise<TParams> },\n) => Promise<Response>\n\nexport async function callRoute<TParams extends RouteParams>(\n  handler: RouteHandler<TParams>,\n  method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',\n  body?: unknown,\n  options?: { headers?: HeaderMap; params?: TParams; query?: Record<string, string> },\n) {\n  const url = new URL('http://localhost:3000/api/test')\n  if (options?.query) {\n    for (const [key, value] of Object.entries(options.query)) {\n      url.searchParams.set(key, value)\n    }\n  }\n\n  const payload = body === undefined ? undefined : JSON.stringify(body)\n  const req = new NextRequest(url, {\n    method,\n    headers: {\n      ...(payload ? { 'content-type': 'application/json' } : {}),\n      ...(options?.headers || {}),\n    },\n    ...(payload ? { body: payload } : {}),\n  })\n  const context = { params: Promise.resolve((options?.params || {}) as TParams) }\n  return await handler(req, context)\n}\n"
  },
  {
    "path": "tests/integration/api/specific/asset-hub-appearances-route.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authState = vi.hoisted(() => ({\n  authenticated: true,\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  globalCharacter: {\n    findFirst: vi.fn(),\n  },\n  globalCharacterAppearance: {\n    create: vi.fn(async () => ({ id: 'appearance-new' })),\n    findFirst: vi.fn(),\n    update: vi.fn(async () => ({ id: 'appearance-1' })),\n    deleteMany: vi.fn(async () => ({ count: 1 })),\n  },\n}))\n\nvi.mock('@/lib/api-auth', () => {\n  const unauthorized = () => new Response(\n    JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),\n    { status: 401, headers: { 'content-type': 'application/json' } },\n  )\n\n  return {\n    isErrorResponse: (value: unknown) => value instanceof Response,\n    requireUserAuth: async () => {\n      if (!authState.authenticated) return unauthorized()\n      return { session: { user: { id: 'user-1' } } }\n    },\n  }\n})\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\ndescribe('api specific - asset hub appearances route', () => {\n  const routeContext = { params: Promise.resolve({}) }\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n    authState.authenticated = true\n\n    prismaMock.globalCharacter.findFirst.mockResolvedValue({\n      id: 'character-1',\n      userId: 'user-1',\n      appearances: [\n        { id: 'appearance-1', appearanceIndex: 0, artStyle: 'realistic' },\n      ],\n    })\n    prismaMock.globalCharacterAppearance.findFirst.mockResolvedValue({\n      id: 'appearance-1',\n      characterId: 'character-1',\n      appearanceIndex: 0,\n      description: 'old description',\n      descriptions: JSON.stringify(['old description', 'variant description']),\n    })\n  })\n\n  it('PATCH preserves description array length instead of rewriting fixed triple entries', async () => {\n    const mod = await import('@/app/api/asset-hub/appearances/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/appearances',\n      method: 'PATCH',\n      body: {\n        characterId: 'character-1',\n        appearanceIndex: 0,\n        description: 'updated description',\n      },\n    })\n\n    const res = await mod.PATCH(req, routeContext)\n\n    expect(res.status).toBe(200)\n    expect(prismaMock.globalCharacterAppearance.update).toHaveBeenCalledWith({\n      where: { id: 'appearance-1' },\n      data: {\n        description: 'updated description',\n        descriptions: JSON.stringify(['updated description', 'variant description']),\n      },\n    })\n  })\n\n  it('POST initializes new appearance with a single description entry', async () => {\n    const mod = await import('@/app/api/asset-hub/appearances/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/appearances',\n      method: 'POST',\n      body: {\n        characterId: 'character-1',\n        changeReason: '新造型',\n        description: 'new description',\n      },\n    })\n\n    const res = await mod.POST(req, routeContext)\n\n    expect(res.status).toBe(200)\n    expect(prismaMock.globalCharacterAppearance.create).toHaveBeenCalledWith(expect.objectContaining({\n      data: expect.objectContaining({\n        description: 'new description',\n        descriptions: JSON.stringify(['new description']),\n      }),\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/asset-hub-generate-image-art-style.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireUserAuth: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst submitTaskMock = vi.hoisted(() => vi.fn<(input: unknown) => Promise<{\n  success: boolean\n  async: boolean\n  taskId: string\n  status: string\n  deduped: boolean\n}>>(async () => ({\n  success: true,\n  async: true,\n  taskId: 'task-1',\n  status: 'queued',\n  deduped: false,\n})))\n\nconst configServiceMock = vi.hoisted(() => ({\n  getUserModelConfig: vi.fn(async () => ({\n    analysisModel: null,\n    characterModel: 'img::character',\n    locationModel: 'img::location',\n    storyboardModel: null,\n    editModel: null,\n    videoModel: null,\n    capabilityDefaults: {},\n  })),\n  buildImageBillingPayloadFromUserConfig: vi.fn((input: { basePayload: Record<string, unknown> }) => ({\n    ...input.basePayload,\n  })),\n}))\n\nconst hasOutputMock = vi.hoisted(() => ({\n  hasGlobalCharacterOutput: vi.fn(async () => false),\n  hasGlobalLocationOutput: vi.fn(async () => false),\n}))\n\nconst billingMock = vi.hoisted(() => ({\n  buildDefaultTaskBillingInfo: vi.fn(() => ({ billable: false })),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  globalCharacterAppearance: {\n    findFirst: vi.fn(),\n  },\n  globalLocation: {\n    findFirst: vi.fn(),\n    findMany: vi.fn(),\n  },\n  globalLocationImage: {\n    findMany: vi.fn(async () => []),\n    createMany: vi.fn(async () => ({})),\n  },\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/task/has-output', () => hasOutputMock)\nvi.mock('@/lib/billing', () => billingMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/task/resolve-locale', () => ({\n  resolveRequiredTaskLocale: vi.fn(() => 'zh'),\n}))\n\ndescribe('api specific - asset hub generate image art style', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('uses persisted appearance artStyle when request payload does not provide one', async () => {\n    prismaMock.globalCharacterAppearance.findFirst.mockResolvedValueOnce({ artStyle: 'realistic' })\n    const mod = await import('@/app/api/asset-hub/generate-image/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/generate-image',\n      method: 'POST',\n      body: {\n        type: 'character',\n        id: 'character-1',\n        appearanceIndex: 0,\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    expect(res.status).toBe(200)\n    expect(prismaMock.globalCharacterAppearance.findFirst).toHaveBeenCalled()\n    const submitArg = submitTaskMock.mock.calls[0]?.[0] as { payload?: Record<string, unknown> } | undefined\n    expect(submitArg?.payload?.artStyle).toBe('realistic')\n  })\n\n  it('uses persisted location artStyle when request payload does not provide one', async () => {\n    prismaMock.globalLocation.findFirst\n      .mockResolvedValueOnce({ artStyle: 'japanese-anime' })\n      .mockResolvedValueOnce({ name: 'Location 1', summary: 'Summary 1' })\n    const mod = await import('@/app/api/asset-hub/generate-image/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/generate-image',\n      method: 'POST',\n      body: {\n        type: 'location',\n        id: 'location-1',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    expect(res.status).toBe(200)\n    expect(prismaMock.globalLocation.findFirst).toHaveBeenCalled()\n    const submitArg = submitTaskMock.mock.calls[0]?.[0] as { payload?: Record<string, unknown> } | undefined\n    expect(submitArg?.payload?.artStyle).toBe('japanese-anime')\n    expect(submitArg?.payload?.count).toBe(3)\n  })\n\n  it('fails with invalid params when persisted artStyle is missing', async () => {\n    prismaMock.globalCharacterAppearance.findFirst.mockResolvedValueOnce({ artStyle: null })\n    const mod = await import('@/app/api/asset-hub/generate-image/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/generate-image',\n      method: 'POST',\n      body: {\n        type: 'character',\n        id: 'character-1',\n        appearanceIndex: 0,\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    const body = await res.json()\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n    expect(submitTaskMock).not.toHaveBeenCalled()\n  })\n\n  it('forwards requested count into asset hub image task payload', async () => {\n    prismaMock.globalCharacterAppearance.findFirst.mockResolvedValueOnce({ artStyle: 'realistic' })\n    const mod = await import('@/app/api/asset-hub/generate-image/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/generate-image',\n      method: 'POST',\n      body: {\n        type: 'character',\n        id: 'character-1',\n        appearanceIndex: 0,\n        count: 5,\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    expect(res.status).toBe(200)\n    const submitArg = submitTaskMock.mock.calls[0]?.[0] as {\n      payload?: Record<string, unknown>\n      dedupeKey?: string\n    } | undefined\n    expect(submitArg?.payload?.count).toBe(5)\n    expect(submitArg?.dedupeKey).toContain(':5')\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/asset-hub-location-create-no-auto-generate.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireUserAuth: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  globalAssetFolder: {\n    findUnique: vi.fn(async () => null),\n  },\n  globalLocation: {\n    create: vi.fn(async () => ({ id: 'location-1' })),\n    findUnique: vi.fn(async () => ({ id: 'location-1', images: [] })),\n  },\n  globalLocationImage: {\n    createMany: vi.fn<(input: { data: Array<{ imageIndex: number }> }) => Promise<{ count: number }>>(\n      async () => ({ count: 0 }),\n    ),\n  },\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\n\ndescribe('api specific - asset hub location create', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('does not auto-generate images after creating location', async () => {\n    const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(\n      async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),\n    )\n    vi.stubGlobal('fetch', fetchMock)\n\n    const mod = await import('@/app/api/asset-hub/locations/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/locations',\n      method: 'POST',\n      body: {\n        name: 'Old Town',\n        summary: '雨夜街道',\n        artStyle: 'realistic',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    expect(res.status).toBe(200)\n    const createManyArg = prismaMock.globalLocationImage.createMany.mock.calls[0]?.[0] as {\n      data?: Array<{ imageIndex: number }>\n    } | undefined\n    expect(createManyArg?.data?.map((item) => item.imageIndex)).toEqual([0])\n    expect(fetchMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/characters-post-reference-forwarding.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { NextResponse } from 'next/server'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireUserAuth: vi.fn<() => Promise<{ session: { user: { id: string } } } | Response>>(async () => ({\n    session: { user: { id: 'user-1' } },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  globalAssetFolder: {\n    findUnique: vi.fn(),\n  },\n  globalCharacter: {\n    create: vi.fn(async () => ({ id: 'character-1', userId: 'user-1' })),\n    findUnique: vi.fn(async () => ({\n      id: 'character-1',\n      userId: 'user-1',\n      name: 'Hero',\n      appearances: [],\n    })),\n  },\n  globalCharacterAppearance: {\n    create: vi.fn(async () => ({ id: 'appearance-1' })),\n  },\n}))\n\nconst mediaAttachMock = vi.hoisted(() => ({\n  attachMediaFieldsToGlobalCharacter: vi.fn(async (value: unknown) => value),\n}))\n\nconst mediaServiceMock = vi.hoisted(() => ({\n  resolveMediaRefFromLegacyValue: vi.fn(async () => null),\n}))\n\nconst envMock = vi.hoisted(() => ({\n  getBaseUrl: vi.fn(() => 'http://localhost:3000'),\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/media/attach', () => mediaAttachMock)\nvi.mock('@/lib/media/service', () => mediaServiceMock)\nvi.mock('@/lib/env', () => envMock)\n\ndescribe('api specific - characters POST forwarding to reference task', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    prismaMock.globalAssetFolder.findUnique.mockResolvedValue(null)\n  })\n\n  it('forwards locale and accept-language into background reference task payload', async () => {\n    const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(\n      async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),\n    )\n    vi.stubGlobal('fetch', fetchMock)\n\n    const mod = await import('@/app/api/asset-hub/characters/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/characters',\n      method: 'POST',\n      headers: {\n        'accept-language': 'zh-CN,zh;q=0.9',\n      },\n      body: {\n        name: 'Hero',\n        artStyle: 'realistic',\n        generateFromReference: true,\n        referenceImageUrl: 'https://example.com/ref.png',\n        customDescription: '冷静，黑发',\n        count: 5,\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    expect(res.status).toBe(200)\n\n    const calledUrl = fetchMock.mock.calls[0]?.[0]\n    const calledInit = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined\n    expect(String(calledUrl)).toContain('/api/asset-hub/reference-to-character')\n    expect((calledInit?.headers as Record<string, string>)['Accept-Language']).toBe('zh-CN,zh;q=0.9')\n\n    const rawBody = calledInit?.body\n    expect(typeof rawBody).toBe('string')\n    const forwarded = JSON.parse(String(rawBody)) as {\n      locale?: string\n      meta?: { locale?: string }\n      customDescription?: string\n      artStyle?: string\n      referenceImageUrls?: string[]\n      appearanceId?: string\n      characterId?: string\n      count?: number\n    }\n\n    expect(forwarded.locale).toBe('zh')\n    expect(forwarded.meta?.locale).toBe('zh')\n    expect(forwarded.customDescription).toBe('冷静，黑发')\n    expect(forwarded.artStyle).toBe('realistic')\n    expect(forwarded.referenceImageUrls).toEqual(['https://example.com/ref.png'])\n    expect(forwarded.characterId).toBe('character-1')\n    expect(forwarded.appearanceId).toBe('appearance-1')\n    expect(forwarded.count).toBe(5)\n  })\n\n  it('returns unauthorized when auth fails', async () => {\n    authMock.requireUserAuth.mockResolvedValueOnce(\n      NextResponse.json({ error: { code: 'UNAUTHORIZED' } }, { status: 401 }),\n    )\n    const mod = await import('@/app/api/asset-hub/characters/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/characters',\n      method: 'POST',\n      body: { name: 'Hero' },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    expect(res.status).toBe(401)\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/characters-post.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\nimport {\n  installAuthMocks,\n  mockAuthenticated,\n  mockUnauthenticated,\n  resetAuthMockState,\n} from '../../../helpers/auth'\n\ndescribe('api specific - characters POST', () => {\n  beforeEach(() => {\n    vi.resetModules()\n    resetAuthMockState()\n  })\n\n  it('returns unauthorized when user is not authenticated', async () => {\n    installAuthMocks()\n    mockUnauthenticated()\n    const mod = await import('@/app/api/asset-hub/characters/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/characters',\n      method: 'POST',\n      body: { name: 'A' },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    expect(res.status).toBe(401)\n  })\n\n  it('returns invalid params when name is missing', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-a')\n    const mod = await import('@/app/api/asset-hub/characters/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/characters',\n      method: 'POST',\n      body: {},\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    const body = await res.json()\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n  })\n\n  it('returns invalid params when artStyle is missing', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-a')\n    const mod = await import('@/app/api/asset-hub/characters/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/characters',\n      method: 'POST',\n      body: { name: 'Hero' },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    const body = await res.json()\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/novel-promotion-character-style-forwarding.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireProjectAuth: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n    novelData: { id: 'novel-data-1' },\n  })),\n  requireProjectAuthLight: vi.fn(),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionCharacter: {\n    create: vi.fn(async () => ({ id: 'character-1' })),\n    findUnique: vi.fn(async () => ({ id: 'character-1', appearances: [] })),\n  },\n  characterAppearance: {\n    create: vi.fn(async () => ({ id: 'appearance-1' })),\n  },\n}))\n\nconst envMock = vi.hoisted(() => ({\n  getBaseUrl: vi.fn(() => 'http://localhost:3000'),\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/env', () => envMock)\nvi.mock('@/lib/task/resolve-locale', () => ({\n  resolveTaskLocale: vi.fn(() => 'zh'),\n}))\n\ndescribe('api specific - novel promotion character style forwarding', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('does not auto-generate images when creating by text prompt', async () => {\n    const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(\n      async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),\n    )\n    vi.stubGlobal('fetch', fetchMock)\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/character/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/character',\n      method: 'POST',\n      headers: {\n        'accept-language': 'zh-CN,zh;q=0.9',\n      },\n      body: {\n        name: 'Hero',\n        description: '主角设定',\n        artStyle: 'realistic',\n        count: 4,\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n    expect(fetchMock).not.toHaveBeenCalled()\n  })\n\n  it('rejects invalid artStyle before creating character', async () => {\n    const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(\n      async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),\n    )\n    vi.stubGlobal('fetch', fetchMock)\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/character/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/character',\n      method: 'POST',\n      body: {\n        name: 'Hero',\n        description: '主角设定',\n        artStyle: 'anime',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    const body = await res.json()\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n    expect(prismaMock.novelPromotionCharacter.create).not.toHaveBeenCalled()\n    expect(fetchMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/novel-promotion-generate-image-art-style.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireProjectAuthLight: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst submitTaskMock = vi.hoisted(() => vi.fn<(input: unknown) => Promise<{\n  success: boolean\n  async: boolean\n  taskId: string\n  status: string\n  deduped: boolean\n}>>(async () => ({\n  success: true,\n  async: true,\n  taskId: 'task-1',\n  status: 'queued',\n  deduped: false,\n})))\n\nconst configServiceMock = vi.hoisted(() => ({\n  getProjectModelConfig: vi.fn(async () => ({\n    analysisModel: null,\n    characterModel: 'img::character',\n    locationModel: 'img::location',\n    storyboardModel: null,\n    editModel: null,\n    videoModel: null,\n    videoRatio: '16:9',\n    artStyle: 'american-comic',\n    capabilityDefaults: {},\n    capabilityOverrides: {},\n  })),\n  buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({\n    ...input.basePayload,\n  })),\n}))\n\nconst hasOutputMock = vi.hoisted(() => ({\n  hasCharacterAppearanceOutput: vi.fn(async () => false),\n  hasLocationImageOutput: vi.fn(async () => false),\n}))\n\nconst billingMock = vi.hoisted(() => ({\n  buildDefaultTaskBillingInfo: vi.fn(() => ({ billable: false })),\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/task/has-output', () => hasOutputMock)\nvi.mock('@/lib/billing', () => billingMock)\nvi.mock('@/lib/task/resolve-locale', () => ({\n  resolveRequiredTaskLocale: vi.fn(() => 'zh'),\n}))\n\ndescribe('api specific - novel promotion generate image art style', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('accepts valid artStyle and forwards it into task payload', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/generate-image',\n      method: 'POST',\n      body: {\n        type: 'character',\n        id: 'character-1',\n        appearanceId: 'appearance-1',\n        artStyle: 'realistic',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n\n    const submitArg = submitTaskMock.mock.calls[0]?.[0] as { payload?: Record<string, unknown> } | undefined\n    expect(submitArg?.payload?.artStyle).toBe('realistic')\n  })\n\n  it('rejects invalid artStyle with invalid params', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/generate-image',\n      method: 'POST',\n      body: {\n        type: 'character',\n        id: 'character-1',\n        appearanceId: 'appearance-1',\n        artStyle: 'anime',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    const body = await res.json()\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n    expect(submitTaskMock).not.toHaveBeenCalled()\n  })\n\n  it('forwards requested count into task payload and dedupe key', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/generate-image',\n      method: 'POST',\n      body: {\n        type: 'character',\n        id: 'character-1',\n        appearanceId: 'appearance-1',\n        count: 6,\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n\n    const submitArg = submitTaskMock.mock.calls[0]?.[0] as {\n      payload?: Record<string, unknown>\n      dedupeKey?: string\n    } | undefined\n    expect(submitArg?.payload?.count).toBe(6)\n    expect(submitArg?.dedupeKey).toBe('image_character:appearance-1:6')\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/novel-promotion-location-style-forwarding.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireProjectAuth: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n    novelData: { id: 'novel-data-1' },\n  })),\n  requireProjectAuthLight: vi.fn(),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionLocation: {\n    create: vi.fn(async () => ({ id: 'location-1' })),\n    findUnique: vi.fn(async () => ({ id: 'location-1', images: [] })),\n  },\n  locationImage: {\n    createMany: vi.fn<(input: { data: Array<{ imageIndex: number }> }) => Promise<{ count: number }>>(\n      async () => ({ count: 0 }),\n    ),\n  },\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/task/resolve-locale', () => ({\n  resolveTaskLocale: vi.fn(() => 'zh'),\n}))\n\ndescribe('api specific - novel promotion location style forwarding', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('does not auto-generate images when creating location', async () => {\n    const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(\n      async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),\n    )\n    vi.stubGlobal('fetch', fetchMock)\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/location/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/location',\n      method: 'POST',\n      headers: {\n        'accept-language': 'zh-CN,zh;q=0.9',\n      },\n      body: {\n        name: 'Old Town',\n        description: '雨夜街道',\n        artStyle: 'realistic',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n    const createManyArg = prismaMock.locationImage.createMany.mock.calls[0]?.[0] as {\n      data?: Array<{ imageIndex: number }>\n    } | undefined\n    expect(createManyArg?.data?.map((item) => item.imageIndex)).toEqual([0])\n    expect(fetchMock).not.toHaveBeenCalled()\n  })\n\n  it('rejects invalid artStyle before creating location', async () => {\n    const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(\n      async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),\n    )\n    vi.stubGlobal('fetch', fetchMock)\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/location/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/location',\n      method: 'POST',\n      body: {\n        name: 'Old Town',\n        description: '雨夜街道',\n        artStyle: 'anime',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    const body = await res.json()\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n    expect(prismaMock.novelPromotionLocation.create).not.toHaveBeenCalled()\n    expect(fetchMock).not.toHaveBeenCalled()\n  })\n\n  it('creates requested number of slots and forwards count', async () => {\n    const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(\n      async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),\n    )\n    vi.stubGlobal('fetch', fetchMock)\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/location/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/location',\n      method: 'POST',\n      body: {\n        name: 'Old Town',\n        description: '雨夜街道',\n        artStyle: 'realistic',\n        count: 5,\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n\n    const createManyArg = prismaMock.locationImage.createMany.mock.calls[0]?.[0] as {\n      data?: Array<{ imageIndex: number }>\n    } | undefined\n    expect(createManyArg?.data?.map((item) => item.imageIndex)).toEqual([0, 1, 2, 3, 4])\n\n    expect(fetchMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/novel-promotion-project-art-style-validation.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireProjectAuthLight: vi.fn(async () => ({\n    session: { user: { id: 'user-1', name: 'User 1' } },\n    project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion', name: 'Project 1' },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionProject: {\n    findUnique: vi.fn(async () => ({\n      analysisModel: 'llm::analysis',\n      characterModel: 'img::character',\n      locationModel: 'img::location',\n      storyboardModel: 'img::storyboard',\n      editModel: 'img::edit',\n      videoModel: 'video::model',\n      audioModel: 'audio::model',\n    })),\n    update: vi.fn(async () => ({\n      id: 'np-1',\n      artStyle: 'realistic',\n    })),\n  },\n  userPreference: {\n    upsert: vi.fn(async () => ({ userId: 'user-1', artStyle: 'realistic' })),\n  },\n}))\n\nconst mediaAttachMock = vi.hoisted(() => ({\n  attachMediaFieldsToProject: vi.fn(async (value: unknown) => value),\n}))\n\nconst logMock = vi.hoisted(() => ({\n  logProjectAction: vi.fn(),\n}))\n\nconst modelConfigContractMock = vi.hoisted(() => ({\n  parseModelKeyStrict: vi.fn(() => ({ provider: 'mock', modelId: 'mock-model' })),\n}))\n\nconst capabilityLookupMock = vi.hoisted(() => ({\n  resolveBuiltinModelContext: vi.fn(() => null),\n  getCapabilityOptionFields: vi.fn(() => ({})),\n  validateCapabilitySelectionsPayload: vi.fn(() => []),\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/media/attach', () => mediaAttachMock)\nvi.mock('@/lib/logging/semantic', () => logMock)\nvi.mock('@/lib/model-config-contract', () => modelConfigContractMock)\nvi.mock('@/lib/model-capabilities/lookup', () => capabilityLookupMock)\n\ndescribe('api specific - novel promotion project art style validation', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('accepts valid artStyle and keeps user preference unchanged', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1',\n      method: 'PATCH',\n      body: {\n        artStyle: '  realistic  ',\n      },\n    })\n\n    const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n    expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith(\n      expect.objectContaining({\n        data: expect.objectContaining({ artStyle: 'realistic' }),\n      }),\n    )\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('rejects invalid artStyle with invalid params', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1',\n      method: 'PATCH',\n      body: {\n        artStyle: 'anime',\n      },\n    })\n\n    const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    const body = await res.json()\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n    expect(prismaMock.novelPromotionProject.update).not.toHaveBeenCalled()\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('accepts audioModel and keeps user preference unchanged', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1',\n      method: 'PATCH',\n      body: {\n        audioModel: 'bailian::qwen3-tts-vd-2026-01-26',\n      },\n    })\n\n    const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n    expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith(\n      expect.objectContaining({\n        data: expect.objectContaining({\n          audioModel: 'bailian::qwen3-tts-vd-2026-01-26',\n        }),\n      }),\n    )\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/panel-variant-route.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\ntype PanelRecord = {\n  id: string\n  storyboardId: string\n  panelIndex: number\n  shotType: string\n  cameraMove: string\n  description: string\n  videoPrompt: string\n  location: string\n  characters: string\n  srtSegment: string\n  duration: number\n}\n\ntype StoryboardRecord = {\n  id: string\n  episode: {\n    novelPromotionProject: {\n      projectId: string\n    }\n  }\n}\n\nconst authMock = vi.hoisted(() => ({\n  requireProjectAuthLight: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n    project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion' },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst submitTaskMock = vi.hoisted(() => vi.fn<typeof import('@/lib/task/submitter').submitTask>(async () => ({\n  success: true,\n  async: true,\n  taskId: 'task-panel-variant',\n  runId: null,\n  status: 'queued',\n  deduped: false,\n})))\n\nconst configServiceMock = vi.hoisted(() => ({\n  getProjectModelConfig: vi.fn(async () => ({\n    storyboardModel: 'img::storyboard',\n  })),\n  buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({\n    ...input.basePayload,\n    generationOptions: { resolution: '1024x1024' },\n  })),\n}))\n\nconst rollbackSpy = vi.hoisted(() => ({\n  delete: vi.fn(async () => ({})),\n  findFirst: vi.fn(async () => ({ panelIndex: 4 })),\n  updateMany: vi.fn(async () => ({ count: 2 })),\n  count: vi.fn(async () => 3),\n  storyboardUpdate: vi.fn(async () => ({})),\n}))\n\nconst createTxSpy = vi.hoisted(() => ({\n  findMany: vi.fn(async () => [\n    { id: 'panel-after-1', panelIndex: 2 },\n    { id: 'panel-after-2', panelIndex: 3 },\n  ]),\n  update: vi.fn(async () => ({})),\n  create: vi.fn(async (args: { data: PanelRecord }) => ({\n    id: args.data.id,\n    panelIndex: args.data.panelIndex,\n  })),\n  count: vi.fn(async () => 4),\n  storyboardUpdate: vi.fn(async () => ({})),\n}))\n\nconst routeState = vi.hoisted(() => ({\n  storyboard: {\n    id: 'storyboard-1',\n    episode: {\n      novelPromotionProject: {\n        projectId: 'project-1',\n      },\n    },\n  } satisfies StoryboardRecord,\n  panels: new Map<string, PanelRecord>(),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionStoryboard: {\n    findUnique: vi.fn(async () => routeState.storyboard),\n  },\n  novelPromotionPanel: {\n    findUnique: vi.fn(async ({ where }: { where: { id: string } }) => routeState.panels.get(where.id) ?? null),\n  },\n  $transaction: vi.fn(async (\n    fn: (tx: {\n      novelPromotionPanel: {\n        findMany: typeof createTxSpy.findMany\n        update: typeof createTxSpy.update\n        create: typeof createTxSpy.create\n        delete: typeof rollbackSpy.delete\n        findFirst: typeof rollbackSpy.findFirst\n        updateMany: typeof rollbackSpy.updateMany\n        count: typeof rollbackSpy.count\n      }\n      novelPromotionStoryboard: {\n        update: typeof createTxSpy.storyboardUpdate\n      }\n    }) => Promise<unknown>,\n  ) => {\n    const invocation = prismaMock.$transaction.mock.calls.length\n    if (invocation > 1) {\n      return await fn({\n        novelPromotionPanel: {\n          findMany: createTxSpy.findMany,\n          update: createTxSpy.update,\n          create: createTxSpy.create,\n          delete: rollbackSpy.delete,\n          findFirst: rollbackSpy.findFirst,\n          updateMany: rollbackSpy.updateMany,\n          count: rollbackSpy.count,\n        },\n        novelPromotionStoryboard: {\n          update: rollbackSpy.storyboardUpdate,\n        },\n      })\n    }\n\n    return await fn({\n      novelPromotionPanel: {\n        findMany: createTxSpy.findMany,\n        update: createTxSpy.update,\n        create: createTxSpy.create,\n        delete: rollbackSpy.delete,\n        findFirst: rollbackSpy.findFirst,\n        updateMany: rollbackSpy.updateMany,\n        count: rollbackSpy.count,\n      },\n      novelPromotionStoryboard: {\n        update: createTxSpy.storyboardUpdate,\n      },\n    })\n  }),\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/billing', () => ({\n  buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),\n}))\nvi.mock('@/lib/task/resolve-locale', () => ({\n  resolveRequiredTaskLocale: vi.fn(() => 'zh'),\n}))\n\nfunction buildPanel(id: string, storyboardId: string, panelIndex: number): PanelRecord {\n  return {\n    id,\n    storyboardId,\n    panelIndex,\n    shotType: 'medium',\n    cameraMove: 'static',\n    description: `description-${id}`,\n    videoPrompt: `prompt-${id}`,\n    location: 'Old Town',\n    characters: '[]',\n    srtSegment: '',\n    duration: 3,\n  }\n}\n\nasync function invokeRoute(body: Record<string, unknown>): Promise<Response> {\n  const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route')\n  const req = buildMockRequest({\n    path: '/api/novel-promotion/project-1/panel-variant',\n    method: 'POST',\n    body,\n  })\n  return await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n}\n\ndescribe('api specific - panel variant route', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    routeState.storyboard = {\n      id: 'storyboard-1',\n      episode: {\n        novelPromotionProject: {\n          projectId: 'project-1',\n        },\n      },\n    }\n    routeState.panels = new Map<string, PanelRecord>([\n      ['panel-src', buildPanel('panel-src', 'storyboard-1', 1)],\n      ['panel-ins', buildPanel('panel-ins', 'storyboard-1', 2)],\n    ])\n  })\n\n  it('returns INVALID_PARAMS when sourcePanelId does not belong to storyboardId', async () => {\n    routeState.panels.set('panel-src', buildPanel('panel-src', 'storyboard-other', 1))\n\n    const res = await invokeRoute({\n      storyboardId: 'storyboard-1',\n      insertAfterPanelId: 'panel-ins',\n      sourcePanelId: 'panel-src',\n      variant: { video_prompt: 'variant prompt', description: 'variant desc' },\n    })\n\n    const json = await res.json() as { error: { code: string } }\n    expect(res.status).toBe(400)\n    expect(json.error.code).toBe('INVALID_PARAMS')\n    expect(createTxSpy.create).not.toHaveBeenCalled()\n    expect(submitTaskMock).not.toHaveBeenCalled()\n  })\n\n  it('returns INVALID_PARAMS when insertAfterPanelId does not belong to storyboardId', async () => {\n    routeState.panels.set('panel-ins', buildPanel('panel-ins', 'storyboard-other', 2))\n\n    const res = await invokeRoute({\n      storyboardId: 'storyboard-1',\n      insertAfterPanelId: 'panel-ins',\n      sourcePanelId: 'panel-src',\n      variant: { video_prompt: 'variant prompt', description: 'variant desc' },\n    })\n\n    const json = await res.json() as { error: { code: string } }\n    expect(res.status).toBe(400)\n    expect(json.error.code).toBe('INVALID_PARAMS')\n    expect(createTxSpy.create).not.toHaveBeenCalled()\n    expect(submitTaskMock).not.toHaveBeenCalled()\n  })\n\n  it('does not create panel when image billing payload validation fails', async () => {\n    configServiceMock.buildImageBillingPayload.mockRejectedValueOnce(new Error('missing capability'))\n\n    const res = await invokeRoute({\n      storyboardId: 'storyboard-1',\n      insertAfterPanelId: 'panel-ins',\n      sourcePanelId: 'panel-src',\n      variant: { video_prompt: 'variant prompt', description: 'variant desc' },\n    })\n\n    const json = await res.json() as { error: { code: string; message: string } }\n    expect(res.status).toBe(400)\n    expect(json.error.code).toBe('INVALID_PARAMS')\n    expect(json.error.message).toBe('missing capability')\n    expect(createTxSpy.create).not.toHaveBeenCalled()\n    expect(submitTaskMock).not.toHaveBeenCalled()\n  })\n\n  it('rolls back the created panel when submitTask fails after insertion', async () => {\n    submitTaskMock.mockRejectedValueOnce(new Error('queue unavailable'))\n\n    const res = await invokeRoute({\n      storyboardId: 'storyboard-1',\n      insertAfterPanelId: 'panel-ins',\n      sourcePanelId: 'panel-src',\n      variant: { video_prompt: 'variant prompt', description: 'variant desc' },\n    })\n\n    const json = await res.json() as { error: { code: string } }\n    expect(res.status).toBe(502)\n    expect(json.error.code).toBe('EXTERNAL_ERROR')\n\n    expect(createTxSpy.create).toHaveBeenCalledTimes(1)\n    const createdPanelId = createTxSpy.create.mock.calls[0]?.[0].data.id\n    expect(createdPanelId).toEqual(expect.any(String))\n    expect(rollbackSpy.delete).toHaveBeenCalledWith({\n      where: { id: createdPanelId },\n    })\n    expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(1, {\n      where: {\n        storyboardId: 'storyboard-1',\n        panelIndex: { gt: 3 },\n      },\n      data: {\n        panelIndex: { increment: 1004 },\n        panelNumber: { increment: 1004 },\n      },\n    })\n    expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(2, {\n      where: {\n        storyboardId: 'storyboard-1',\n        panelIndex: { gt: 1007 },\n      },\n      data: {\n        panelIndex: { decrement: 1005 },\n        panelNumber: { decrement: 1005 },\n      },\n    })\n    expect(rollbackSpy.storyboardUpdate).toHaveBeenCalledWith({\n      where: { id: 'storyboard-1' },\n      data: { panelCount: 3 },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/project-create-default-audio-model.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireUserAuth: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  userPreference: {\n    findUnique: vi.fn(async () => ({\n      analysisModel: 'llm::analysis',\n      characterModel: 'img::character',\n      locationModel: 'img::location',\n      storyboardModel: 'img::storyboard',\n      editModel: 'img::edit',\n      videoModel: 'video::model',\n      audioModel: 'audio::tts',\n      videoRatio: '9:16',\n      artStyle: 'realistic',\n      ttsRate: '+0%',\n    })),\n  },\n  project: {\n    create: vi.fn(async () => ({\n      id: 'project-1',\n      name: 'Test Project',\n      description: null,\n      mode: 'novel-promotion',\n      userId: 'user-1',\n    })),\n  },\n  novelPromotionProject: {\n    create: vi.fn(async () => ({ id: 'np-1', projectId: 'project-1' })),\n  },\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\n\ndescribe('api specific - project create default audio model', () => {\n  const routeContext = { params: Promise.resolve({}) }\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('copies user preference audioModel into the new novel promotion project', async () => {\n    const mod = await import('@/app/api/projects/route')\n    const req = buildMockRequest({\n      path: '/api/projects',\n      method: 'POST',\n      body: {\n        name: 'Test Project',\n        description: '',\n      },\n    })\n\n    const res = await mod.POST(req, routeContext)\n    expect(res.status).toBe(201)\n    expect(prismaMock.novelPromotionProject.create).toHaveBeenCalledWith({\n      data: expect.objectContaining({\n        projectId: 'project-1',\n        audioModel: 'audio::tts',\n      }),\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/reference-to-character-api.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\nimport {\n  installAuthMocks,\n  mockAuthenticated,\n  mockUnauthenticated,\n  resetAuthMockState,\n} from '../../../helpers/auth'\n\ndescribe('api specific - reference to character route', () => {\n  beforeEach(() => {\n    vi.resetModules()\n    resetAuthMockState()\n  })\n\n  it('returns unauthorized when user is not authenticated', async () => {\n    installAuthMocks()\n    mockUnauthenticated()\n    const mod = await import('@/app/api/asset-hub/reference-to-character/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/reference-to-character',\n      method: 'POST',\n      body: {\n        referenceImageUrl: 'https://example.com/ref.png',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    expect(res.status).toBe(401)\n  })\n\n  it('returns invalid params when references are missing', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-a')\n    const mod = await import('@/app/api/asset-hub/reference-to-character/route')\n    const req = buildMockRequest({\n      path: '/api/asset-hub/reference-to-character',\n      method: 'POST',\n      body: {},\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({}) })\n    const body = await res.json()\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/speaker-voice-provider-contract.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireProjectAuthLight: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n    project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion' },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionProject: {\n    findUnique: vi.fn(async () => ({ id: 'np-1' })),\n  },\n  novelPromotionEpisode: {\n    findUnique: vi.fn(async () => ({\n      id: 'episode-1',\n      speakerVoices: '{}',\n    })),\n    findFirst: vi.fn(async () => ({\n      id: 'episode-1',\n      speakerVoices: '{}',\n    })),\n    update: vi.fn<(args: { data?: { speakerVoices?: string } }) => Promise<{ id: string }>>(async () => ({ id: 'episode-1' })),\n  },\n}))\n\nconst resolveStorageKeyFromMediaValueMock = vi.hoisted(() => vi.fn(async (input: string) => {\n  if (input.includes('fal')) return 'voice/storage/fal.wav'\n  if (input.includes('preview')) return 'voice/storage/preview.wav'\n  return null\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/media/service', () => ({\n  resolveStorageKeyFromMediaValue: resolveStorageKeyFromMediaValueMock,\n}))\n\ndescribe('api specific - speaker voice provider contract', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('returns INVALID_PARAMS when provider is missing in PATCH payload', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/speaker-voice/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/speaker-voice',\n      method: 'PATCH',\n      body: {\n        episodeId: 'episode-1',\n        speaker: 'Narrator',\n        voiceType: 'uploaded',\n        audioUrl: '/m/fal-reference',\n      },\n    })\n\n    const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    const body = await res.json()\n\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n    expect(prismaMock.novelPromotionEpisode.update).not.toHaveBeenCalled()\n  })\n\n  it('stores fal speaker voice with explicit provider and normalized audio storage key', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/speaker-voice/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/speaker-voice',\n      method: 'PATCH',\n      body: {\n        episodeId: 'episode-1',\n        speaker: 'Narrator',\n        provider: 'fal',\n        voiceType: 'uploaded',\n        audioUrl: '/m/fal-reference',\n      },\n    })\n\n    const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n\n    const updateCall = prismaMock.novelPromotionEpisode.update.mock.calls[0] as\n      | [{ data?: { speakerVoices?: string } }]\n      | undefined\n    expect(updateCall).toBeTruthy()\n    if (!updateCall) throw new Error('expected update call')\n    const updateArg = updateCall[0]\n    const saved = JSON.parse(updateArg.data?.speakerVoices || '{}') as Record<string, unknown>\n\n    expect(resolveStorageKeyFromMediaValueMock).toHaveBeenCalledWith('/m/fal-reference')\n    expect(saved.Narrator).toEqual({\n      provider: 'fal',\n      voiceType: 'uploaded',\n      audioUrl: 'voice/storage/fal.wav',\n    })\n  })\n\n  it('stores bailian speaker voice with explicit provider and voiceId', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/speaker-voice/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/speaker-voice',\n      method: 'PATCH',\n      body: {\n        episodeId: 'episode-1',\n        speaker: 'Narrator',\n        provider: 'bailian',\n        voiceType: 'qwen-designed',\n        voiceId: 'qwen-tts-vd-001',\n        previewAudioUrl: '/m/preview-audio',\n      },\n    })\n\n    const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n\n    const updateCall = prismaMock.novelPromotionEpisode.update.mock.calls[0] as\n      | [{ data?: { speakerVoices?: string } }]\n      | undefined\n    expect(updateCall).toBeTruthy()\n    if (!updateCall) throw new Error('expected update call')\n    const updateArg = updateCall[0]\n    const saved = JSON.parse(updateArg.data?.speakerVoices || '{}') as Record<string, unknown>\n\n    expect(resolveStorageKeyFromMediaValueMock).toHaveBeenCalledWith('/m/preview-audio')\n    expect(saved.Narrator).toEqual({\n      provider: 'bailian',\n      voiceType: 'qwen-designed',\n      voiceId: 'qwen-tts-vd-001',\n      previewAudioUrl: 'voice/storage/preview.wav',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/user-api-config-probe-model-llm-protocol.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\nimport {\n  installAuthMocks,\n  mockAuthenticated,\n  resetAuthMockState,\n} from '../../../helpers/auth'\n\nconst probeModelLlmProtocolMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    success: true,\n    protocol: 'responses' as const,\n    checkedAt: '2026-03-05T00:00:00.000Z',\n    traces: [],\n  })),\n)\n\nvi.mock('@/lib/user-api/model-llm-protocol-probe', () => ({\n  probeModelLlmProtocol: probeModelLlmProtocolMock,\n}))\n\ndescribe('api specific - user api-config probe model llm protocol', () => {\n  const routeContext = { params: Promise.resolve({}) }\n\n  beforeEach(() => {\n    vi.resetModules()\n    vi.clearAllMocks()\n    resetAuthMockState()\n  })\n\n  it('probes protocol for openai-compatible provider/model', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/probe-model-llm-protocol/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config/probe-model-llm-protocol',\n      method: 'POST',\n      body: {\n        providerId: 'openai-compatible:node-1',\n        modelId: 'gpt-4.1-mini',\n      },\n    })\n\n    const res = await route.POST(req, routeContext)\n    expect(res.status).toBe(200)\n    const body = await res.json() as { success: boolean; protocol?: string }\n    expect(body.success).toBe(true)\n    expect(body.protocol).toBe('responses')\n    expect(probeModelLlmProtocolMock).toHaveBeenCalledWith({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n    })\n  })\n\n  it('rejects non-openai-compatible provider ids', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/probe-model-llm-protocol/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config/probe-model-llm-protocol',\n      method: 'POST',\n      body: {\n        providerId: 'gemini-compatible:node-1',\n        modelId: 'gemini-3-pro-preview',\n      },\n    })\n\n    const res = await route.POST(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(probeModelLlmProtocolMock).not.toHaveBeenCalled()\n  })\n\n  it('rejects invalid body payload', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/probe-model-llm-protocol/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config/probe-model-llm-protocol',\n      method: 'POST',\n      body: {\n        providerId: 'openai-compatible:node-1',\n        modelId: '',\n      },\n    })\n\n    const res = await route.POST(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(probeModelLlmProtocolMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/user-api-config-put.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\nimport {\n  installAuthMocks,\n  mockAuthenticated,\n  resetAuthMockState,\n} from '../../../helpers/auth'\n\ntype UserPreferenceSnapshot = {\n  customProviders: string | null\n  customModels: string | null\n  analysisModel?: string | null\n  characterModel?: string | null\n  locationModel?: string | null\n  storyboardModel?: string | null\n  editModel?: string | null\n  videoModel?: string | null\n  audioModel?: string | null\n  lipSyncModel?: string | null\n  capabilityDefaults?: string | null\n  analysisConcurrency?: number | null\n  imageConcurrency?: number | null\n  videoConcurrency?: number | null\n}\n\ntype SavedProvider = {\n  id: string\n  name: string\n  baseUrl?: string\n  apiKey?: string\n  hidden?: boolean\n  apiMode?: 'gemini-sdk' | 'openai-official'\n  gatewayRoute?: 'official' | 'openai-compat'\n}\n\nconst prismaMock = vi.hoisted(() => ({\n  userPreference: {\n    findUnique: vi.fn<(...args: unknown[]) => Promise<UserPreferenceSnapshot | null>>(),\n    upsert: vi.fn<(...args: unknown[]) => Promise<unknown>>(),\n  },\n}))\n\nconst encryptApiKeyMock = vi.hoisted(() => vi.fn((value: string) => `enc:${value}`))\nconst decryptApiKeyMock = vi.hoisted(() => vi.fn((value: string) => value.replace(/^enc:/, '')))\nconst getBillingModeMock = vi.hoisted(() => vi.fn(async () => 'OFF'))\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nvi.mock('@/lib/crypto-utils', () => ({\n  encryptApiKey: encryptApiKeyMock,\n  decryptApiKey: decryptApiKeyMock,\n}))\n\nvi.mock('@/lib/billing/mode', () => ({\n  getBillingMode: getBillingModeMock,\n}))\n\nconst routeContext = { params: Promise.resolve({}) }\n\nfunction readSavedProvidersFromUpsert(): SavedProvider[] {\n  const firstCall = prismaMock.userPreference.upsert.mock.calls[0]\n  if (!firstCall) {\n    throw new Error('expected prisma.userPreference.upsert to be called at least once')\n  }\n\n  const payload = firstCall[0] as { update?: { customProviders?: unknown } }\n  const rawProviders = payload.update?.customProviders\n  if (typeof rawProviders !== 'string') {\n    throw new Error('expected update.customProviders to be a JSON string')\n  }\n\n  const parsed = JSON.parse(rawProviders) as unknown\n  if (!Array.isArray(parsed)) {\n    throw new Error('expected update.customProviders to parse as an array')\n  }\n  return parsed as SavedProvider[]\n}\n\nfunction readSavedModelsFromUpsert(): Array<Record<string, unknown>> {\n  const firstCall = prismaMock.userPreference.upsert.mock.calls[0]\n  if (!firstCall) {\n    throw new Error('expected prisma.userPreference.upsert to be called at least once')\n  }\n\n  const payload = firstCall[0] as { update?: { customModels?: unknown } }\n  const rawModels = payload.update?.customModels\n  if (typeof rawModels !== 'string') {\n    throw new Error('expected update.customModels to be a JSON string')\n  }\n\n  const parsed = JSON.parse(rawModels) as unknown\n  if (!Array.isArray(parsed)) {\n    throw new Error('expected update.customModels to parse as an array')\n  }\n  return parsed as Array<Record<string, unknown>>\n}\n\ndescribe('api specific - user api-config PUT provider uniqueness', () => {\n  beforeEach(() => {\n    vi.resetModules()\n    vi.clearAllMocks()\n    resetAuthMockState()\n\n    prismaMock.userPreference.findUnique.mockResolvedValue({\n      customProviders: null,\n      customModels: null,\n    })\n    prismaMock.userPreference.upsert.mockResolvedValue({ id: 'pref-1' })\n    getBillingModeMock.mockResolvedValue('OFF')\n  })\n\n  it('allows multiple providers with the same api type when provider ids differ', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:oa-1', name: 'OpenAI A', baseUrl: 'https://oa-a.test', apiKey: 'oa-key-a' },\n          { id: 'openai-compatible:oa-2', name: 'OpenAI B', baseUrl: 'https://oa-b.test', apiKey: 'oa-key-b' },\n          { id: 'gemini-compatible:gm-1', name: 'Gemini A', baseUrl: 'https://gm-a.test', apiKey: 'gm-key-a' },\n          { id: 'gemini-compatible:gm-2', name: 'Gemini B', baseUrl: 'https://gm-b.test', apiKey: 'gm-key-b' },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    expect(prismaMock.userPreference.upsert).toHaveBeenCalledTimes(1)\n\n    const savedProviders = readSavedProvidersFromUpsert()\n    expect(savedProviders.map((provider) => provider.id)).toEqual([\n      'openai-compatible:oa-1',\n      'openai-compatible:oa-2',\n      'gemini-compatible:gm-1',\n      'gemini-compatible:gm-2',\n    ])\n  })\n\n  it('regression: preserves reordered providers array order when persisting', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'google', name: 'Google AI Studio', apiKey: 'google-key' },\n          { id: 'ark', name: 'Volcengine Ark', apiKey: 'ark-key' },\n          { id: 'openai-compatible:oa-2', name: 'OpenAI B', baseUrl: 'https://oa-b.test', apiKey: 'oa-key-b' },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    expect(prismaMock.userPreference.upsert).toHaveBeenCalledTimes(1)\n\n    const savedProviders = readSavedProvidersFromUpsert()\n    expect(savedProviders.map((provider) => provider.id)).toEqual([\n      'google',\n      'ark',\n      'openai-compatible:oa-2',\n    ])\n  })\n\n  it('persists provider hidden flag', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'google', name: 'Google AI Studio', apiKey: 'google-key', hidden: true },\n          { id: 'ark', name: 'Volcengine Ark', apiKey: 'ark-key', hidden: false },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n\n    const savedProviders = readSavedProvidersFromUpsert()\n    const googleProvider = savedProviders.find((provider) => provider.id === 'google')\n    const arkProvider = savedProviders.find((provider) => provider.id === 'ark')\n    expect(googleProvider?.hidden).toBe(true)\n    expect(arkProvider?.hidden).toBe(false)\n  })\n\n  it('rejects non-boolean provider hidden flag', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'google', name: 'Google AI Studio', apiKey: 'google-key', hidden: 'yes' },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('pins minimax provider baseUrl to official endpoint when baseUrl is omitted', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'minimax', name: 'MiniMax Hailuo', apiKey: 'mm-key' },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n\n    const savedProviders = readSavedProvidersFromUpsert()\n    expect(savedProviders).toHaveLength(1)\n    expect(savedProviders[0]).toMatchObject({\n      id: 'minimax',\n      baseUrl: 'https://api.minimaxi.com/v1',\n    })\n  })\n\n  it('rejects minimax provider custom baseUrl', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'minimax', name: 'MiniMax Hailuo', baseUrl: 'https://custom.minimax.proxy/v1', apiKey: 'mm-key' },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('keeps new provider apiKey empty instead of reusing another same-type provider apiKey', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    prismaMock.userPreference.findUnique.mockResolvedValue({\n      customProviders: JSON.stringify([\n        {\n          id: 'openai-compatible:old',\n          name: 'Old',\n          baseUrl: 'https://old.test',\n          apiKey: 'enc:legacy',\n        },\n      ] satisfies SavedProvider[]),\n      customModels: null,\n    })\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:old', name: 'Old', baseUrl: 'https://old.test' },\n          { id: 'openai-compatible:new', name: 'New', baseUrl: 'https://new.test' },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n\n    const savedProviders = readSavedProvidersFromUpsert()\n    const oldProvider = savedProviders.find((provider) => provider.id === 'openai-compatible:old')\n    const newProvider = savedProviders.find((provider) => provider.id === 'openai-compatible:new')\n\n    expect(oldProvider?.apiKey).toBe('enc:legacy')\n    expect(newProvider).toBeDefined()\n    expect(Object.prototype.hasOwnProperty.call(newProvider as object, 'apiKey')).toBe(false)\n  })\n\n  it('rejects duplicated provider ids', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:dup', name: 'Provider A', baseUrl: 'https://a.test', apiKey: 'key-a' },\n          { id: 'openai-compatible:dup', name: 'Provider B', baseUrl: 'https://b.test', apiKey: 'key-b' },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('rejects duplicated provider ids even when only case differs', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'OpenAI-Compatible:CaseDup', name: 'Provider A', baseUrl: 'https://a.test', apiKey: 'key-a' },\n          { id: 'openai-compatible:casedup', name: 'Provider B', baseUrl: 'https://b.test', apiKey: 'key-b' },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('requires explicit provider id on models when multiple same-type providers exist', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:oa-1', name: 'OpenAI A', baseUrl: 'https://oa-a.test', apiKey: 'oa-key-a' },\n          { id: 'openai-compatible:oa-2', name: 'OpenAI B', baseUrl: 'https://oa-b.test', apiKey: 'oa-key-b' },\n        ],\n        models: [\n          {\n            type: 'llm',\n            provider: 'openai-compatible',\n            modelId: 'gpt-4.1',\n            modelKey: 'openai-compatible::gpt-4.1',\n            name: 'GPT 4.1',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('accepts openai-compatible provider image/video models', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          {\n            id: 'openai-compatible:oa-1',\n            name: 'OpenAI Node',\n            baseUrl: 'https://oa.test/v1',\n            apiKey: 'oa-key',\n            apiMode: 'openai-official',\n          },\n        ],\n        models: [\n          {\n            type: 'image',\n            provider: 'openai-compatible:oa-1',\n            modelId: 'gpt-image-1',\n            modelKey: 'openai-compatible:oa-1::gpt-image-1',\n            name: 'Image Model',\n          },\n          {\n            type: 'video',\n            provider: 'openai-compatible:oa-1',\n            modelId: 'sora-2',\n            modelKey: 'openai-compatible:oa-1::sora-2',\n            name: 'Video Model',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    expect(prismaMock.userPreference.upsert).toHaveBeenCalledTimes(1)\n  })\n\n  it('requires llmProtocol when adding a new openai-compatible llm model', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'oa-key' },\n        ],\n        models: [\n          {\n            type: 'llm',\n            provider: 'openai-compatible:oa-1',\n            modelId: 'gpt-4.1-mini',\n            modelKey: 'openai-compatible:oa-1::gpt-4.1-mini',\n            name: 'GPT 4.1 Mini',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('persists llmProtocol for openai-compatible llm models', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'oa-key' },\n        ],\n        models: [\n          {\n            type: 'llm',\n            provider: 'openai-compatible:oa-1',\n            modelId: 'gpt-4.1-mini',\n            modelKey: 'openai-compatible:oa-1::gpt-4.1-mini',\n            name: 'GPT 4.1 Mini',\n            llmProtocol: 'responses',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n\n    const savedModels = readSavedModelsFromUpsert()\n    expect(savedModels).toHaveLength(1)\n    expect(savedModels[0]?.llmProtocol).toBe('responses')\n    expect(typeof savedModels[0]?.llmProtocolCheckedAt).toBe('string')\n  })\n\n  it('rejects llmProtocol on non-openai-compatible or non-llm models', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'gemini-compatible:gm-1', name: 'Gemini Compat', baseUrl: 'https://gm.test', apiKey: 'gm-key' },\n        ],\n        models: [\n          {\n            type: 'llm',\n            provider: 'gemini-compatible:gm-1',\n            modelId: 'gemini-3-pro-preview',\n            modelKey: 'gemini-compatible:gm-1::gemini-3-pro-preview',\n            name: 'Gemini 3 Pro',\n            llmProtocol: 'chat-completions',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('backfills historical openai-compatible llm models missing llmProtocol during PUT', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    prismaMock.userPreference.findUnique.mockResolvedValue({\n      customProviders: JSON.stringify([\n        { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'enc:oa-key' },\n      ]),\n      customModels: JSON.stringify([\n        {\n          type: 'llm',\n          provider: 'openai-compatible:oa-1',\n          modelId: 'gpt-4.1-mini',\n          modelKey: 'openai-compatible:oa-1::gpt-4.1-mini',\n          name: 'GPT 4.1 Mini',\n        },\n      ]),\n    })\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1' },\n        ],\n        models: [\n          {\n            type: 'llm',\n            provider: 'openai-compatible:oa-1',\n            modelId: 'gpt-4.1-mini',\n            modelKey: 'openai-compatible:oa-1::gpt-4.1-mini',\n            name: 'GPT 4.1 Mini',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n\n    const savedModels = readSavedModelsFromUpsert()\n    expect(savedModels).toHaveLength(1)\n    expect(savedModels[0]?.llmProtocol).toBe('chat-completions')\n    expect(typeof savedModels[0]?.llmProtocolCheckedAt).toBe('string')\n  })\n\n  it('rejects invalid custom pricing structure', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:oa-1', name: 'OpenAI Node', baseUrl: 'https://oa.test/v1', apiKey: 'oa-key' },\n        ],\n        models: [\n          {\n            type: 'image',\n            provider: 'openai-compatible:oa-1',\n            modelId: 'gpt-image-1',\n            modelKey: 'openai-compatible:oa-1::gpt-image-1',\n            name: 'Image Model',\n            customPricing: {\n              image: {\n                basePrice: -1,\n              },\n            },\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('rejects custom pricing option mappings with unsupported capability values', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'ark', name: 'Volcengine Ark', apiKey: 'ark-key' },\n        ],\n        models: [\n          {\n            type: 'video',\n            provider: 'ark',\n            modelId: 'doubao-seedance-1-0-pro-fast-251015',\n            modelKey: 'ark::doubao-seedance-1-0-pro-fast-251015',\n            name: 'Ark Video',\n            customPricing: {\n              video: {\n                basePrice: 0.5,\n                optionPrices: {\n                  resolution: {\n                    '2k': 1.2,\n                  },\n                },\n              },\n            },\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('maps legacy customPricing input/output to llm pricing on GET', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    prismaMock.userPreference.findUnique.mockResolvedValue({\n      customProviders: JSON.stringify([\n        { id: 'openai-compatible:oa-1', name: 'OpenAI', baseUrl: 'https://oa.test/v1', apiKey: 'enc:key' },\n      ]),\n      customModels: JSON.stringify([\n        {\n          type: 'llm',\n          provider: 'openai-compatible:oa-1',\n          modelId: 'gpt-4.1-mini',\n          modelKey: 'openai-compatible:oa-1::gpt-4.1-mini',\n          name: 'GPT',\n          customPricing: {\n            input: 2.5,\n            output: 5.5,\n          },\n        },\n      ]),\n    })\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'GET',\n    })\n\n    const res = await route.GET(req, routeContext)\n    expect(res.status).toBe(200)\n    const json = await res.json() as { models?: Array<{ customPricing?: { llm?: { inputPerMillion?: number; outputPerMillion?: number } } }> }\n    const model = Array.isArray(json.models) ? json.models[0] : null\n    expect(model?.customPricing?.llm?.inputPerMillion).toBe(2.5)\n    expect(model?.customPricing?.llm?.outputPerMillion).toBe(5.5)\n  })\n\n  it('defaults gemini-compatible provider to official route when apiMode is gemini-sdk', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          {\n            id: 'gemini-compatible:gm-1',\n            name: 'Gemini Official Mode',\n            baseUrl: 'https://gm.test',\n            apiKey: 'gm-key',\n            apiMode: 'gemini-sdk',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n\n    const savedProviders = readSavedProvidersFromUpsert()\n    expect(savedProviders).toHaveLength(1)\n    expect(savedProviders[0]?.gatewayRoute).toBe('official')\n    expect(savedProviders[0]?.apiMode).toBe('gemini-sdk')\n  })\n\n  it('rejects gemini-compatible provider when apiMode is openai-official', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          {\n            id: 'gemini-compatible:gm-1',\n            name: 'Gemini OpenAI Mode',\n            baseUrl: 'https://gm.test',\n            apiKey: 'gm-key',\n            apiMode: 'openai-official',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('rejects legacy litellm gatewayRoute value', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          {\n            id: 'openai-compatible:oa-1',\n            name: 'OpenAI Node',\n            baseUrl: 'https://oa.test/v1',\n            apiKey: 'oa-key',\n            gatewayRoute: 'litellm',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('forces openai-compatible provider to openai-compat route', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          {\n            id: 'openai-compatible:oa-1',\n            name: 'OpenAI Node',\n            baseUrl: 'https://oa.test/v1',\n            apiKey: 'oa-key',\n            apiMode: 'openai-official',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n\n    const savedProviders = readSavedProvidersFromUpsert()\n    expect(savedProviders).toHaveLength(1)\n    expect(savedProviders[0]?.gatewayRoute).toBe('openai-compat')\n  })\n\n  it('bailian provider always persists gatewayRoute as official', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          {\n            id: 'bailian',\n            name: 'Alibaba Bailian',\n            apiKey: 'bl-key',\n            gatewayRoute: 'official',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    const savedProviders = readSavedProvidersFromUpsert()\n    expect(savedProviders[0]?.gatewayRoute).toBe('official')\n  })\n\n  it('accepts bailian lipsync models and persists them', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          {\n            id: 'bailian',\n            name: 'Alibaba Bailian',\n            apiKey: 'bl-key',\n          },\n        ],\n        models: [\n          {\n            type: 'lipsync',\n            provider: 'bailian',\n            modelId: 'videoretalk',\n            modelKey: 'bailian::videoretalk',\n            name: 'VideoRetalk Lip Sync',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n\n    const savedModels = readSavedModelsFromUpsert()\n    expect(savedModels).toHaveLength(1)\n    expect(savedModels[0]).toMatchObject({\n      type: 'lipsync',\n      provider: 'bailian',\n      modelId: 'videoretalk',\n      modelKey: 'bailian::videoretalk',\n    })\n  })\n\n  it('siliconflow provider rejects litellm gatewayRoute', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          {\n            id: 'siliconflow',\n            name: 'SiliconFlow',\n            apiKey: 'sf-key',\n            gatewayRoute: 'litellm',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('allows bailian default model in ENFORCE mode without built-in pricing entry', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    getBillingModeMock.mockResolvedValue('ENFORCE')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'bl-key' },\n        ],\n        models: [\n          {\n            type: 'llm',\n            provider: 'bailian',\n            modelId: 'qwen3.5-flash',\n            modelKey: 'bailian::qwen3.5-flash',\n            name: 'Qwen 3.5 Flash',\n          },\n        ],\n        defaultModels: {\n          analysisModel: 'bailian::qwen3.5-flash',\n        },\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    const firstCall = prismaMock.userPreference.upsert.mock.calls[0]?.[0] as {\n      update?: { analysisModel?: unknown }\n    }\n    expect(firstCall?.update?.analysisModel).toBe('bailian::qwen3.5-flash')\n  })\n\n  it('allows bailian lipsync model in ENFORCE mode', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    getBillingModeMock.mockResolvedValue('ENFORCE')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'bl-key' },\n        ],\n        models: [\n          {\n            type: 'lipsync',\n            provider: 'bailian',\n            modelId: 'videoretalk',\n            modelKey: 'bailian::videoretalk',\n            name: 'VideoRetalk Lip Sync',\n          },\n        ],\n        defaultModels: {\n          lipSyncModel: 'bailian::videoretalk',\n        },\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    const firstCall = prismaMock.userPreference.upsert.mock.calls[0]?.[0] as {\n      update?: { lipSyncModel?: unknown }\n    }\n    expect(firstCall?.update?.lipSyncModel).toBe('bailian::videoretalk')\n  })\n\n  it('saves default audio model in user preference', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        defaultModels: {\n          audioModel: 'bailian::qwen3-tts-vd-2026-01-26',\n        },\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    const firstCall = prismaMock.userPreference.upsert.mock.calls[0]?.[0] as {\n      update?: { audioModel?: unknown }\n    }\n    expect(firstCall?.update?.audioModel).toBe('bailian::qwen3-tts-vd-2026-01-26')\n  })\n\n  it('keeps bailian model and default model in GET sanitize flow under ENFORCE mode', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    getBillingModeMock.mockResolvedValue('ENFORCE')\n    prismaMock.userPreference.findUnique.mockResolvedValue({\n      customProviders: JSON.stringify([\n        { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'enc:bl-key', gatewayRoute: 'official' },\n      ]),\n      customModels: JSON.stringify([\n        {\n          type: 'llm',\n          provider: 'bailian',\n          modelId: 'qwen3.5-flash',\n          modelKey: 'bailian::qwen3.5-flash',\n          name: 'Qwen 3.5 Flash',\n        },\n      ]),\n      analysisModel: 'bailian::qwen3.5-flash',\n    })\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'GET',\n    })\n\n    const res = await route.GET(req, routeContext)\n    expect(res.status).toBe(200)\n    const json = await res.json() as {\n      defaultModels?: { analysisModel?: string }\n      models?: Array<{ modelKey?: string }>\n    }\n    expect(json.defaultModels?.analysisModel).toBe('bailian::qwen3.5-flash')\n    expect(json.models?.some((model) => model.modelKey === 'bailian::qwen3.5-flash')).toBe(true)\n  })\n\n  it('accepts workflow concurrency payload and returns normalized values on GET', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const putReq = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        workflowConcurrency: {\n          analysis: 3,\n          image: 4,\n          video: 6,\n        },\n      },\n    })\n    const putRes = await route.PUT(putReq, routeContext)\n    expect(putRes.status).toBe(200)\n    expect(prismaMock.userPreference.upsert).toHaveBeenCalledTimes(1)\n    const upsertPayload = prismaMock.userPreference.upsert.mock.calls[0]?.[0] as {\n      update: {\n        analysisConcurrency?: number\n        imageConcurrency?: number\n        videoConcurrency?: number\n      }\n    }\n    expect(upsertPayload.update.analysisConcurrency).toBe(3)\n    expect(upsertPayload.update.imageConcurrency).toBe(4)\n    expect(upsertPayload.update.videoConcurrency).toBe(6)\n\n    prismaMock.userPreference.findUnique.mockResolvedValueOnce({\n      customProviders: null,\n      customModels: null,\n      analysisConcurrency: 5,\n      imageConcurrency: 7,\n      videoConcurrency: 9,\n    })\n    const getReq = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'GET',\n    })\n    const getRes = await route.GET(getReq, routeContext)\n    expect(getRes.status).toBe(200)\n    const payload = await getRes.json() as {\n      workflowConcurrency?: {\n        analysis: number\n        image: number\n        video: number\n      }\n    }\n    expect(payload.workflowConcurrency).toEqual({\n      analysis: 5,\n      image: 7,\n      video: 9,\n    })\n  })\n\n  it('migrated bailian provider id is accepted and qwen is rejected', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const acceptedReq = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'bailian', name: 'Alibaba Bailian', apiKey: 'bl-key' },\n        ],\n      },\n    })\n    const acceptedRes = await route.PUT(acceptedReq, routeContext)\n    expect(acceptedRes.status).toBe(200)\n\n    vi.clearAllMocks()\n    prismaMock.userPreference.findUnique.mockResolvedValue({\n      customProviders: null,\n      customModels: null,\n    })\n\n    const rejectedReq = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'qwen', name: 'Qwen', apiKey: 'old-key' },\n        ],\n      },\n    })\n    const rejectedRes = await route.PUT(rejectedReq, routeContext)\n    expect(rejectedRes.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('rejects compatMediaTemplate on non-openai-compatible media model', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'google', name: 'Google AI Studio', apiKey: 'google-key' },\n        ],\n        models: [\n          {\n            modelId: 'veo-3.1-fast-generate-preview',\n            modelKey: 'google::veo-3.1-fast-generate-preview',\n            name: 'Veo Fast',\n            type: 'video',\n            provider: 'google',\n            compatMediaTemplate: {\n              version: 1,\n              mediaType: 'video',\n              mode: 'sync',\n              create: { method: 'POST', path: '/videos' },\n              response: { outputUrlPath: '$.url' },\n            },\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n\n  it('backfills default compatMediaTemplate for openai-compatible image model when missing', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:oa-1', name: 'OpenAI Compat', baseUrl: 'https://compat.test/v1', apiKey: 'oa-key' },\n        ],\n        models: [\n          {\n            modelId: 'gpt-image-1',\n            modelKey: 'openai-compatible:oa-1::gpt-image-1',\n            name: 'Image One',\n            type: 'image',\n            provider: 'openai-compatible:oa-1',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    const savedModels = readSavedModelsFromUpsert()\n    const savedModel = savedModels.find((item) => item.modelKey === 'openai-compatible:oa-1::gpt-image-1')\n    expect(savedModel?.compatMediaTemplate).toMatchObject({\n      version: 1,\n      mediaType: 'image',\n      mode: 'sync',\n      create: {\n        path: '/images/generations',\n      },\n    })\n    expect(savedModel?.compatMediaTemplateSource).toBe('manual')\n    expect(typeof savedModel?.compatMediaTemplateCheckedAt).toBe('string')\n  })\n\n  it('backfills default compatMediaTemplate for openai-compatible video model when missing', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:oa-1', name: 'OpenAI Compat', baseUrl: 'https://compat.test/v1', apiKey: 'oa-key' },\n        ],\n        models: [\n          {\n            modelId: 'veo-2',\n            modelKey: 'openai-compatible:oa-1::veo-2',\n            name: 'Veo 2',\n            type: 'video',\n            provider: 'openai-compatible:oa-1',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    const savedModels = readSavedModelsFromUpsert()\n    const savedModel = savedModels.find((item) => item.modelKey === 'openai-compatible:oa-1::veo-2')\n    expect(savedModel?.compatMediaTemplate).toMatchObject({\n      version: 1,\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        path: '/videos',\n        contentType: 'multipart/form-data',\n        multipartFileFields: ['input_reference'],\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n          seconds: '{{duration}}',\n          size: '{{size}}',\n          input_reference: '{{image}}',\n        },\n      },\n      status: {\n        path: '/videos/{{task_id}}',\n      },\n      content: {\n        path: '/videos/{{task_id}}/content',\n      },\n      response: {\n        taskIdPath: '$.id',\n        statusPath: '$.status',\n      },\n    })\n    expect(savedModel?.compatMediaTemplateSource).toBe('manual')\n    expect(typeof savedModel?.compatMediaTemplateCheckedAt).toBe('string')\n  })\n\n  it('keeps explicit compatMediaTemplate for openai-compatible video model', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/api-config/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/api-config',\n      method: 'PUT',\n      body: {\n        providers: [\n          { id: 'openai-compatible:oa-1', name: 'OpenAI Compat', baseUrl: 'https://compat.test/v1', apiKey: 'oa-key' },\n        ],\n        models: [\n          {\n            modelId: 'veo3.1',\n            modelKey: 'openai-compatible:oa-1::veo3.1',\n            name: 'Veo 3.1',\n            type: 'video',\n            provider: 'openai-compatible:oa-1',\n            compatMediaTemplate: {\n              version: 1,\n              mediaType: 'video',\n              mode: 'async',\n              create: {\n                method: 'POST',\n                path: '/v2/videos/generations',\n                contentType: 'application/json',\n                bodyTemplate: {\n                  model: '{{model}}',\n                  prompt: '{{prompt}}',\n                },\n              },\n              status: {\n                method: 'GET',\n                path: '/v2/videos/generations/{{task_id}}',\n              },\n              response: {\n                taskIdPath: '$.task_id',\n                statusPath: '$.status',\n                outputUrlPath: '$.video_url',\n              },\n              polling: {\n                intervalMs: 3000,\n                timeoutMs: 180000,\n                doneStates: ['succeeded'],\n                failStates: ['failed'],\n              },\n            },\n            compatMediaTemplateSource: 'ai',\n          },\n        ],\n      },\n    })\n\n    const res = await route.PUT(req, routeContext)\n    expect(res.status).toBe(200)\n    const savedModels = readSavedModelsFromUpsert()\n    const savedModel = savedModels.find((item) => item.modelKey === 'openai-compatible:oa-1::veo3.1')\n    expect(savedModel?.compatMediaTemplate).toMatchObject({\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        path: '/v2/videos/generations',\n      },\n    })\n    expect(savedModel?.compatMediaTemplateSource).toBe('ai')\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/user-assistant-chat-api-config.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\nimport {\n  installAuthMocks,\n  mockAuthenticated,\n  resetAuthMockState,\n} from '../../../helpers/auth'\n\nconst createAssistantChatResponseMock = vi.hoisted(() =>\n  vi.fn(async () => new Response('event: done\\ndata: ok\\n\\n', {\n    status: 200,\n    headers: {\n      'content-type': 'text/event-stream; charset=utf-8',\n    },\n  })),\n)\n\nvi.mock('@/lib/assistant-platform', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/assistant-platform')>('@/lib/assistant-platform')\n  return {\n    ...actual,\n    createAssistantChatResponse: createAssistantChatResponseMock,\n  }\n})\n\ndescribe('api specific - user assistant chat', () => {\n  const routeContext = { params: Promise.resolve({}) }\n\n  beforeEach(() => {\n    vi.resetModules()\n    vi.clearAllMocks()\n    resetAuthMockState()\n  })\n\n  it('accepts api-config-template assistant request and forwards payload', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/assistant/chat/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/assistant/chat',\n      method: 'POST',\n      body: {\n        assistantId: 'api-config-template',\n        context: {\n          providerId: 'openai-compatible:oa-1',\n        },\n        messages: [{\n          id: 'm1',\n          role: 'user',\n          parts: [{ type: 'text', text: '请配置文生视频模板' }],\n        }],\n      },\n    })\n\n    const res = await route.POST(req, routeContext)\n    expect(res.status).toBe(200)\n    expect(createAssistantChatResponseMock).toHaveBeenCalledWith({\n      userId: 'user-1',\n      assistantId: 'api-config-template',\n      context: {\n        providerId: 'openai-compatible:oa-1',\n      },\n      messages: [{\n        id: 'm1',\n        role: 'user',\n        parts: [{ type: 'text', text: '请配置文生视频模板' }],\n      }],\n    })\n  })\n\n  it('rejects invalid assistantId', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const route = await import('@/app/api/user/assistant/chat/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/assistant/chat',\n      method: 'POST',\n      body: {\n        assistantId: 'unknown-assistant',\n        messages: [],\n      },\n    })\n\n    const res = await route.POST(req, routeContext)\n    expect(res.status).toBe(400)\n    expect(createAssistantChatResponseMock).not.toHaveBeenCalled()\n  })\n\n  it('maps assistant platform missing-config error to 400 response', async () => {\n    installAuthMocks()\n    mockAuthenticated('user-1')\n    const { AssistantPlatformError } = await import('@/lib/assistant-platform')\n    createAssistantChatResponseMock.mockRejectedValueOnce(\n      new AssistantPlatformError('ASSISTANT_MODEL_NOT_CONFIGURED', 'analysisModel is required'),\n    )\n    const route = await import('@/app/api/user/assistant/chat/route')\n\n    const req = buildMockRequest({\n      path: '/api/user/assistant/chat',\n      method: 'POST',\n      body: {\n        assistantId: 'api-config-template',\n        context: {\n          providerId: 'openai-compatible:oa-1',\n        },\n        messages: [{\n          id: 'm1',\n          role: 'user',\n          parts: [{ type: 'text', text: 'hello' }],\n        }],\n      },\n    })\n\n    const res = await route.POST(req, routeContext)\n    expect(res.status).toBe(400)\n    const payload = await res.json() as { code?: string; error?: { code?: string; details?: { code?: string } } }\n    expect(payload.error?.code).toBe('MISSING_CONFIG')\n    expect(payload.code).toBe('ASSISTANT_MODEL_NOT_CONFIGURED')\n    expect(payload.error?.details?.code).toBe('ASSISTANT_MODEL_NOT_CONFIGURED')\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/user-models-audio-filter.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireUserAuth: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  userPreference: {\n    findUnique: vi.fn(async () => ({\n      customModels: JSON.stringify([\n        {\n          modelId: 'qwen3-tts-vd-2026-01-26',\n          modelKey: 'bailian::qwen3-tts-vd-2026-01-26',\n          name: 'Qwen3 TTS',\n          type: 'audio',\n          provider: 'bailian',\n        },\n        {\n          modelId: 'qwen-voice-design',\n          modelKey: 'bailian::qwen-voice-design',\n          name: 'Qwen Voice Design',\n          type: 'audio',\n          provider: 'bailian',\n        },\n      ]),\n      customProviders: JSON.stringify([\n        {\n          id: 'bailian',\n          name: 'Alibaba Bailian',\n          apiKey: 'k-bailian',\n        },\n      ]),\n    })),\n  },\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/model-capabilities/catalog', () => ({\n  findBuiltinCapabilities: vi.fn(() => undefined),\n}))\nvi.mock('@/lib/model-pricing/catalog', () => ({\n  findBuiltinPricingCatalogEntry: vi.fn(() => undefined),\n}))\n\ndescribe('api specific - user models audio filter', () => {\n  const routeContext = { params: Promise.resolve({}) }\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('excludes voice design models from the audio model list', async () => {\n    const mod = await import('@/app/api/user/models/route')\n    const req = buildMockRequest({\n      path: '/api/user/models',\n      method: 'GET',\n    })\n    const res = await mod.GET(req, routeContext)\n\n    expect(res.status).toBe(200)\n    const body = await res.json() as { audio: Array<{ value: string }> }\n    expect(body.audio.map((item) => item.value)).toEqual([\n      'bailian::qwen3-tts-vd-2026-01-26',\n    ])\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/user-preference-art-style-validation.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireUserAuth: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  userPreference: {\n    upsert: vi.fn(async () => ({\n      userId: 'user-1',\n      artStyle: 'realistic',\n    })),\n  },\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\n\ndescribe('api specific - user preference art style validation', () => {\n  const routeContext = { params: Promise.resolve({}) }\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('accepts valid artStyle and persists normalized value', async () => {\n    const mod = await import('@/app/api/user-preference/route')\n    const req = buildMockRequest({\n      path: '/api/user-preference',\n      method: 'PATCH',\n      body: { artStyle: '  realistic  ' },\n    })\n\n    const res = await mod.PATCH(req, routeContext)\n    expect(res.status).toBe(200)\n    expect(prismaMock.userPreference.upsert).toHaveBeenCalledWith(\n      expect.objectContaining({\n        update: expect.objectContaining({ artStyle: 'realistic' }),\n      }),\n    )\n  })\n\n  it('rejects invalid artStyle with invalid params', async () => {\n    const mod = await import('@/app/api/user-preference/route')\n    const req = buildMockRequest({\n      path: '/api/user-preference',\n      method: 'PATCH',\n      body: { artStyle: 'anime' },\n    })\n\n    const res = await mod.PATCH(req, routeContext)\n    const body = await res.json()\n    expect(res.status).toBe(400)\n    expect(body.error.code).toBe('INVALID_PARAMS')\n    expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/integration/api/specific/voice-generate-default-audio-model.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { buildMockRequest } from '../../../helpers/request'\n\nconst authMock = vi.hoisted(() => ({\n  requireProjectAuthLight: vi.fn(async () => ({\n    session: { user: { id: 'user-1' } },\n    project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion' },\n  })),\n  isErrorResponse: vi.fn((value: unknown) => value instanceof Response),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  userPreference: {\n    findUnique: vi.fn(async () => ({ audioModel: 'fal::fal-ai/index-tts-2/text-to-speech' })),\n  },\n  novelPromotionProject: {\n    findUnique: vi.fn<() => Promise<{\n      id: string\n      audioModel: string | null\n      characters: Array<{ name: string; customVoiceUrl: string; voiceId: string | null }>\n    } | null>>(async () => ({\n      id: 'np-1',\n      audioModel: 'fal::project-tts-model',\n      characters: [\n        { name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.wav', voiceId: null },\n      ],\n    })),\n  },\n  novelPromotionEpisode: {\n    findFirst: vi.fn(async () => ({\n      id: 'episode-1',\n      speakerVoices: '{}',\n    })),\n  },\n  novelPromotionVoiceLine: {\n    findFirst: vi.fn(async () => ({\n      id: 'line-1',\n      speaker: 'Narrator',\n      content: 'hello world',\n    })),\n    findMany: vi.fn(async () => []),\n  },\n}))\n\nconst submitTaskMock = vi.hoisted(() => vi.fn<typeof import('@/lib/task/submitter').submitTask>(async () => ({\n  success: true,\n  async: true,\n  taskId: 'task-1',\n  runId: null,\n  status: 'queued',\n  deduped: false,\n})))\n\nconst apiConfigMock = vi.hoisted(() => ({\n  resolveModelSelectionOrSingle: vi.fn(async (_userId: string, model: string | null | undefined) => ({\n    provider: 'fal',\n    modelId: 'fal-ai/index-tts-2/text-to-speech',\n    modelKey: model || 'fal::fal-ai/index-tts-2/text-to-speech',\n    mediaType: 'audio',\n  })),\n  getProviderKey: vi.fn((providerId: string) => providerId),\n}))\n\nvi.mock('@/lib/api-auth', () => authMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))\nvi.mock('@/lib/api-config', () => apiConfigMock)\nvi.mock('@/lib/task/resolve-locale', () => ({\n  resolveRequiredTaskLocale: vi.fn(() => 'zh'),\n}))\nvi.mock('@/lib/billing', () => ({\n  buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),\n}))\nvi.mock('@/lib/task/has-output', () => ({\n  hasVoiceLineAudioOutput: vi.fn(async () => false),\n}))\n\ndescribe('api specific - voice generate default audio model', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('uses project audioModel when request does not provide one', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/voice-generate',\n      method: 'POST',\n      body: {\n        episodeId: 'episode-1',\n        lineId: 'line-1',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n    expect(apiConfigMock.resolveModelSelectionOrSingle).toHaveBeenCalledWith(\n      'user-1',\n      'fal::project-tts-model',\n      'audio',\n    )\n\n    const submitCall = submitTaskMock.mock.calls[0] as [{ payload?: Record<string, unknown> }] | undefined\n    const submitArg = submitCall?.[0]\n    expect(submitArg?.payload?.audioModel).toBe('fal::project-tts-model')\n  })\n\n  it('request audioModel overrides user preference audioModel', async () => {\n    const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/voice-generate',\n      method: 'POST',\n      body: {\n        episodeId: 'episode-1',\n        lineId: 'line-1',\n        audioModel: 'fal::custom-tts',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n    expect(apiConfigMock.resolveModelSelectionOrSingle).toHaveBeenCalledWith(\n      'user-1',\n      'fal::custom-tts',\n      'audio',\n    )\n  })\n\n  it('falls back to user preference audioModel when project audioModel is empty', async () => {\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({\n      id: 'np-1',\n      audioModel: null,\n      characters: [\n        { name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.wav', voiceId: null },\n      ],\n    })\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/voice-generate',\n      method: 'POST',\n      body: {\n        episodeId: 'episode-1',\n        lineId: 'line-1',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(200)\n    expect(apiConfigMock.resolveModelSelectionOrSingle).toHaveBeenCalledWith(\n      'user-1',\n      'fal::fal-ai/index-tts-2/text-to-speech',\n      'audio',\n    )\n  })\n\n  it('returns an explicit qwen voiceId error when only uploaded reference audio is available', async () => {\n    apiConfigMock.resolveModelSelectionOrSingle.mockResolvedValueOnce({\n      provider: 'bailian',\n      modelId: 'qwen3-tts-vd-2026-01-26',\n      modelKey: 'bailian::qwen3-tts-vd-2026-01-26',\n      mediaType: 'audio',\n    })\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')\n    const req = buildMockRequest({\n      path: '/api/novel-promotion/project-1/voice-generate',\n      method: 'POST',\n      body: {\n        episodeId: 'episode-1',\n        lineId: 'line-1',\n      },\n    })\n\n    const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })\n    expect(res.status).toBe(400)\n\n    const json = await res.json()\n    expect(json.error?.message).toBe('无音色ID，QwenTTS 必须使用 AI 设计音色')\n    expect(submitTaskMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/integration/billing/api-contract.integration.test.ts",
    "content": "import { beforeEach, describe, expect, it } from 'vitest'\nimport { NextRequest, NextResponse } from 'next/server'\nimport { apiHandler } from '@/lib/api-errors'\nimport { calcText } from '@/lib/billing/cost'\nimport { withTextBilling } from '@/lib/billing/service'\nimport { prisma } from '../../helpers/prisma'\nimport { resetBillingState } from '../../helpers/db-reset'\nimport { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'\n\ndescribe('billing/api contract integration', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n    process.env.BILLING_MODE = 'ENFORCE'\n  })\n\n  it('returns 402 payload when balance is insufficient', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 0)\n\n    const route = apiHandler(async () => {\n      await withTextBilling(\n        user.id,\n        'anthropic/claude-sonnet-4',\n        1000,\n        500,\n        { projectId: project.id, action: 'api_contract_insufficient' },\n        async () => ({ ok: true }),\n      )\n      return NextResponse.json({ ok: true })\n    })\n\n    const req = new NextRequest('http://localhost/api/test', {\n      method: 'POST',\n      headers: { 'x-request-id': 'req_insufficient' },\n    })\n    const response = await route(req, { params: Promise.resolve({}) })\n    const body = await response.json()\n\n    expect(response.status).toBe(402)\n    expect(body?.error?.code).toBe('INSUFFICIENT_BALANCE')\n    expect(typeof body?.required).toBe('number')\n    expect(typeof body?.available).toBe('number')\n  })\n\n  it('rejects duplicate retry with same request id and prevents duplicate charge', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 5)\n\n    const route = apiHandler(async () => {\n      await withTextBilling(\n        user.id,\n        'anthropic/claude-sonnet-4',\n        1000,\n        500,\n        { projectId: project.id, action: 'api_contract_dedupe' },\n        async () => ({ ok: true }),\n      )\n      return NextResponse.json({ ok: true })\n    })\n\n    const req1 = new NextRequest('http://localhost/api/test', {\n      method: 'POST',\n      headers: { 'x-request-id': 'same_request_id' },\n    })\n    const req2 = new NextRequest('http://localhost/api/test', {\n      method: 'POST',\n      headers: { 'x-request-id': 'same_request_id' },\n    })\n\n    const resp1 = await route(req1, { params: Promise.resolve({}) })\n    const resp2 = await route(req2, { params: Promise.resolve({}) })\n    const body2 = await resp2.json()\n\n    expect(resp1.status).toBe(200)\n    expect(resp2.status).toBe(409)\n    expect(body2?.error?.code).toBe('CONFLICT')\n    expect(String(body2?.error?.message || '')).toContain('duplicate billing request already confirmed')\n\n    const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })\n    const expectedCharge = calcText('anthropic/claude-sonnet-4', 1000, 500)\n    expect(balance?.totalSpent).toBeCloseTo(expectedCharge, 8)\n    expect(await prisma.balanceFreeze.count()).toBe(1)\n  })\n})\n"
  },
  {
    "path": "tests/integration/billing/ledger.integration.test.ts",
    "content": "import { beforeEach, describe, expect, it } from 'vitest'\nimport {\n  confirmChargeWithRecord,\n  freezeBalance,\n  getBalance,\n  recordShadowUsage,\n  rollbackFreeze,\n} from '@/lib/billing/ledger'\nimport { prisma } from '../../helpers/prisma'\nimport { resetBillingState } from '../../helpers/db-reset'\nimport { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'\n\ndescribe('billing/ledger integration', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n    process.env.BILLING_MODE = 'ENFORCE'\n  })\n\n  it('freezes balance when enough funds exist', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 10)\n\n    const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'freeze_ok' })\n    expect(freezeId).toBeTruthy()\n\n    const balance = await getBalance(user.id)\n    expect(balance.balance).toBeCloseTo(7, 8)\n    expect(balance.frozenAmount).toBeCloseTo(3, 8)\n  })\n\n  it('returns null freeze id when balance is insufficient', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 1)\n\n    const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'freeze_no_money' })\n    expect(freezeId).toBeNull()\n  })\n\n  it('reuses same freeze record with the same idempotency key', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 10)\n\n    const first = await freezeBalance(user.id, 2, { idempotencyKey: 'idem_key' })\n    const second = await freezeBalance(user.id, 2, { idempotencyKey: 'idem_key' })\n\n    expect(first).toBeTruthy()\n    expect(second).toBe(first)\n\n    const balance = await getBalance(user.id)\n    expect(balance.balance).toBeCloseTo(8, 8)\n    expect(balance.frozenAmount).toBeCloseTo(2, 8)\n    expect(await prisma.balanceFreeze.count()).toBe(1)\n  })\n\n  it('supports partial confirmation and refunds difference', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 10)\n\n    const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'confirm_partial' })\n    expect(freezeId).toBeTruthy()\n\n    const confirmed = await confirmChargeWithRecord(\n      freezeId!,\n      {\n        projectId: project.id,\n        action: 'integration_confirm',\n        apiType: 'voice',\n        model: 'index-tts2',\n        quantity: 2,\n        unit: 'second',\n      },\n      { chargedAmount: 2 },\n    )\n    expect(confirmed).toBe(true)\n\n    const balance = await getBalance(user.id)\n    expect(balance.balance).toBeCloseTo(8, 8)\n    expect(balance.frozenAmount).toBeCloseTo(0, 8)\n    expect(balance.totalSpent).toBeCloseTo(2, 8)\n    expect(await prisma.usageCost.count()).toBe(1)\n  })\n\n  it('is idempotent when confirm is called repeatedly', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 10)\n\n    const freezeId = await freezeBalance(user.id, 2, { idempotencyKey: 'confirm_idem' })\n    expect(freezeId).toBeTruthy()\n\n    const first = await confirmChargeWithRecord(\n      freezeId!,\n      {\n        projectId: project.id,\n        action: 'integration_confirm',\n        apiType: 'image',\n        model: 'seedream',\n        quantity: 1,\n        unit: 'image',\n      },\n      { chargedAmount: 1 },\n    )\n    const second = await confirmChargeWithRecord(\n      freezeId!,\n      {\n        projectId: project.id,\n        action: 'integration_confirm',\n        apiType: 'image',\n        model: 'seedream',\n        quantity: 1,\n        unit: 'image',\n      },\n      { chargedAmount: 1 },\n    )\n\n    expect(first).toBe(true)\n    expect(second).toBe(true)\n    expect(await prisma.balanceTransaction.count({ where: { freezeId: freezeId! } })).toBe(1)\n  })\n\n  it('rolls back pending freeze and restores funds', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 10)\n\n    const freezeId = await freezeBalance(user.id, 4, { idempotencyKey: 'rollback_ok' })\n    expect(freezeId).toBeTruthy()\n\n    const rolled = await rollbackFreeze(freezeId!)\n    expect(rolled).toBe(true)\n\n    const balance = await getBalance(user.id)\n    expect(balance.balance).toBeCloseTo(10, 8)\n    expect(balance.frozenAmount).toBeCloseTo(0, 8)\n  })\n\n  it('returns false when trying to rollback a non-pending freeze', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 10)\n\n    const freezeId = await freezeBalance(user.id, 2, { idempotencyKey: 'rollback_after_confirm' })\n    expect(freezeId).toBeTruthy()\n\n    await confirmChargeWithRecord(\n      freezeId!,\n      {\n        projectId: project.id,\n        action: 'integration_confirm',\n        apiType: 'voice',\n        model: 'index-tts2',\n        quantity: 5,\n        unit: 'second',\n      },\n      { chargedAmount: 1 },\n    )\n\n    const rolled = await rollbackFreeze(freezeId!)\n    expect(rolled).toBe(false)\n  })\n\n  it('records shadow usage as audit transaction without balance change', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 5)\n\n    const ok = await recordShadowUsage(user.id, {\n      projectId: 'asset-hub',\n      action: 'shadow_test',\n      apiType: 'text',\n      model: 'anthropic/claude-sonnet-4',\n      quantity: 1200,\n      unit: 'token',\n      cost: 0.25,\n      metadata: { source: 'test' },\n    })\n    expect(ok).toBe(true)\n\n    const balance = await getBalance(user.id)\n    expect(balance.balance).toBeCloseTo(5, 8)\n    expect(balance.totalSpent).toBeCloseTo(0, 8)\n    expect(await prisma.balanceTransaction.count({ where: { type: 'shadow_consume' } })).toBe(1)\n  })\n})\n"
  },
  {
    "path": "tests/integration/billing/service.integration.test.ts",
    "content": "import { randomUUID } from 'node:crypto'\nimport { beforeEach, describe, expect, it } from 'vitest'\nimport { calcVoice } from '@/lib/billing/cost'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'\nimport { prepareTaskBilling, rollbackTaskBilling, settleTaskBilling } from '@/lib/billing/service'\nimport { TASK_TYPE, type TaskBillingInfo } from '@/lib/task/types'\nimport { prisma } from '../../helpers/prisma'\nimport { resetBillingState } from '../../helpers/db-reset'\nimport { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'\n\nfunction expectBillableInfo(info: TaskBillingInfo | null | undefined): Extract<TaskBillingInfo, { billable: true }> {\n  expect(info?.billable).toBe(true)\n  if (!info || !info.billable) {\n    throw new Error('Expected billable task billing info')\n  }\n  return info\n}\n\ndescribe('billing/service integration', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n  })\n\n  it('marks task billing as skipped in OFF mode', async () => {\n    process.env.BILLING_MODE = 'OFF'\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 10)\n\n    const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!\n    const result = await prepareTaskBilling({\n      id: randomUUID(),\n      userId: user.id,\n      projectId: project.id,\n      billingInfo: info,\n    })\n\n    expect(result?.billable).toBe(true)\n    expect((result as TaskBillingInfo & { status: string }).status).toBe('skipped')\n  })\n\n  it('records shadow audit in SHADOW mode and does not consume balance', async () => {\n    process.env.BILLING_MODE = 'SHADOW'\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 10)\n\n    const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!\n    const taskId = randomUUID()\n    const prepared = expectBillableInfo(await prepareTaskBilling({\n      id: taskId,\n      userId: user.id,\n      projectId: project.id,\n      billingInfo: info,\n    }))\n\n    expect(prepared.status).toBe('quoted')\n\n    const settled = expectBillableInfo(await settleTaskBilling({\n      id: taskId,\n      userId: user.id,\n      projectId: project.id,\n      billingInfo: prepared,\n    }, {\n      result: { actualDurationSeconds: 2 },\n    }))\n\n    expect(settled.status).toBe('settled')\n    expect(settled.chargedCost).toBe(0)\n\n    const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })\n    expect(balance?.balance).toBeCloseTo(10, 8)\n    expect(balance?.totalSpent).toBeCloseTo(0, 8)\n    expect(await prisma.balanceTransaction.count({ where: { type: 'shadow_consume' } })).toBe(1)\n  })\n\n  it('freezes and settles in ENFORCE mode with actual usage', async () => {\n    process.env.BILLING_MODE = 'ENFORCE'\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 10)\n\n    const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!\n    const taskId = randomUUID()\n    const prepared = expectBillableInfo(await prepareTaskBilling({\n      id: taskId,\n      userId: user.id,\n      projectId: project.id,\n      billingInfo: info,\n    }))\n\n    expect(prepared.status).toBe('frozen')\n    expect(prepared.freezeId).toBeTruthy()\n\n    const settled = expectBillableInfo(await settleTaskBilling({\n      id: taskId,\n      userId: user.id,\n      projectId: project.id,\n      billingInfo: prepared,\n    }, {\n      result: { actualDurationSeconds: 2 },\n    }))\n\n    expect(settled.status).toBe('settled')\n    expect(settled.chargedCost).toBeCloseTo(calcVoice(2), 8)\n\n    const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })\n    expect(balance?.totalSpent).toBeCloseTo(calcVoice(2), 8)\n    expect(balance?.frozenAmount).toBeCloseTo(0, 8)\n  })\n\n  it('rolls back frozen billing in ENFORCE mode', async () => {\n    process.env.BILLING_MODE = 'ENFORCE'\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    await seedBalance(user.id, 10)\n\n    const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!\n    const taskId = randomUUID()\n    const prepared = expectBillableInfo(await prepareTaskBilling({\n      id: taskId,\n      userId: user.id,\n      projectId: project.id,\n      billingInfo: info,\n    }))\n\n    const rolled = expectBillableInfo(await rollbackTaskBilling({\n      id: taskId,\n      billingInfo: prepared,\n    }))\n\n    expect(rolled.status).toBe('rolled_back')\n    const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })\n    expect(balance?.balance).toBeCloseTo(10, 8)\n    expect(balance?.frozenAmount).toBeCloseTo(0, 8)\n  })\n})\n"
  },
  {
    "path": "tests/integration/billing/submitter.integration.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { ApiError } from '@/lib/api-errors'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'\nimport { createRun } from '@/lib/run-runtime/service'\nimport { submitTask } from '@/lib/task/submitter'\nimport { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'\nimport { prisma } from '../../helpers/prisma'\nimport { resetBillingState } from '../../helpers/db-reset'\nimport { createTestUser, seedBalance } from '../../helpers/billing-fixtures'\n\nconst queueState = vi.hoisted(() => ({\n  mode: 'success' as 'success' | 'fail',\n  errorMessage: 'queue add failed',\n}))\nconst addTaskJobMock = vi.hoisted(() => vi.fn(async () => ({ id: 'mock-job' })))\nconst publishTaskEventMock = vi.hoisted(() => vi.fn(async () => ({})))\n\nvi.mock('@/lib/task/queues', () => ({\n  addTaskJob: addTaskJobMock,\n}))\n\nvi.mock('@/lib/task/publisher', () => ({\n  publishTaskEvent: publishTaskEventMock,\n}))\n\naddTaskJobMock.mockImplementation(async () => {\n    if (queueState.mode === 'fail') {\n      throw new Error(queueState.errorMessage)\n    }\n    return { id: 'mock-job' }\n})\n\ndescribe('billing/submitter integration', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n    process.env.BILLING_MODE = 'ENFORCE'\n    queueState.mode = 'success'\n    queueState.errorMessage = 'queue add failed'\n    vi.clearAllMocks()\n  })\n\n  it('builds billing info server-side for billable task submission', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 10)\n\n    const result = await submitTask({\n      userId: user.id,\n      locale: 'en',\n      projectId: 'project-a',\n      type: TASK_TYPE.VOICE_LINE,\n      targetType: 'VoiceLine',\n      targetId: 'line-a',\n      payload: { maxSeconds: 5 },\n    })\n\n    expect(result.success).toBe(true)\n    const task = await prisma.task.findUnique({ where: { id: result.taskId } })\n    expect(task).toBeTruthy()\n    const billing = task?.billingInfo as { billable?: boolean; source?: string } | null\n    expect(billing?.billable).toBe(true)\n    expect(billing?.source).toBe('task')\n  })\n\n  it('marks task as failed when balance is insufficient', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 0)\n\n    const billingInfo = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 10 })\n    expect(billingInfo?.billable).toBe(true)\n\n    await expect(\n      submitTask({\n        userId: user.id,\n        locale: 'en',\n        projectId: 'project-b',\n        type: TASK_TYPE.VOICE_LINE,\n        targetType: 'VoiceLine',\n        targetId: 'line-b',\n        payload: { maxSeconds: 10 },\n        billingInfo,\n      }),\n    ).rejects.toMatchObject({ code: 'INSUFFICIENT_BALANCE' } satisfies Pick<ApiError, 'code'>)\n\n    const task = await prisma.task.findFirst({\n      where: {\n        userId: user.id,\n        type: TASK_TYPE.VOICE_LINE,\n      },\n      orderBy: { createdAt: 'desc' },\n    })\n\n    expect(task).toBeTruthy()\n    expect(task?.status).toBe('failed')\n    expect(task?.errorCode).toBe('INSUFFICIENT_BALANCE')\n  })\n\n  it('allows billable task submission without computed billingInfo in OFF mode (regression)', async () => {\n    process.env.BILLING_MODE = 'OFF'\n    const user = await createTestUser()\n\n    const result = await submitTask({\n      userId: user.id,\n      locale: 'en',\n      projectId: 'project-c',\n      type: TASK_TYPE.IMAGE_CHARACTER,\n      targetType: 'CharacterAppearance',\n      targetId: 'appearance-c',\n      payload: {},\n    })\n\n    expect(result.success).toBe(true)\n    const task = await prisma.task.findUnique({ where: { id: result.taskId } })\n    expect(task).toBeTruthy()\n    expect(task?.errorCode).toBeNull()\n    expect(task?.billingInfo).toBeNull()\n  })\n\n  it('keeps strict billingInfo validation in ENFORCE mode (regression)', async () => {\n    process.env.BILLING_MODE = 'ENFORCE'\n    const user = await createTestUser()\n    await seedBalance(user.id, 10)\n\n    await expect(\n      submitTask({\n        userId: user.id,\n        locale: 'en',\n        projectId: 'project-d',\n        type: TASK_TYPE.IMAGE_CHARACTER,\n        targetType: 'CharacterAppearance',\n        targetId: 'appearance-d',\n        payload: {},\n      }),\n    ).rejects.toMatchObject({ code: 'INVALID_PARAMS' } satisfies Pick<ApiError, 'code'>)\n\n    const task = await prisma.task.findFirst({\n      where: {\n        userId: user.id,\n        type: TASK_TYPE.IMAGE_CHARACTER,\n      },\n      orderBy: { createdAt: 'desc' },\n    })\n\n    expect(task).toBeTruthy()\n    expect(task?.status).toBe('failed')\n    expect(task?.errorCode).toBe('INVALID_PARAMS')\n    expect(task?.errorMessage).toContain('missing server-generated billingInfo')\n  })\n\n  it('rolls back billing freeze and marks task failed when queue enqueue fails', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 10)\n    queueState.mode = 'fail'\n    queueState.errorMessage = 'queue unavailable'\n\n    await expect(\n      submitTask({\n        userId: user.id,\n        locale: 'en',\n        projectId: 'project-e',\n        type: TASK_TYPE.VOICE_LINE,\n        targetType: 'VoiceLine',\n        targetId: 'line-e',\n        payload: { maxSeconds: 6 },\n      }),\n    ).rejects.toMatchObject({ code: 'EXTERNAL_ERROR' } satisfies Pick<ApiError, 'code'>)\n\n    const task = await prisma.task.findFirst({\n      where: {\n        userId: user.id,\n        type: TASK_TYPE.VOICE_LINE,\n      },\n      orderBy: { createdAt: 'desc' },\n    })\n    const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })\n\n    expect(task).toBeTruthy()\n    expect(task?.status).toBe('failed')\n    expect(task?.errorCode).toBe('ENQUEUE_FAILED')\n    expect(task?.errorMessage).toContain('queue unavailable')\n    expect(task?.billingInfo).toMatchObject({\n      billable: true,\n      status: 'rolled_back',\n    })\n    expect(balance?.balance).toBeCloseTo(10, 8)\n    expect(balance?.frozenAmount).toBeCloseTo(0, 8)\n    expect(await prisma.balanceFreeze.count()).toBe(1)\n    const freeze = await prisma.balanceFreeze.findFirst({ orderBy: { createdAt: 'desc' } })\n    expect(freeze?.status).toBe('rolled_back')\n  })\n\n  it('reuses the active core analysis run instead of creating a second run', async () => {\n    process.env.BILLING_MODE = 'OFF'\n    const user = await createTestUser()\n    const existingTask = await prisma.task.create({\n      data: {\n        userId: user.id,\n        projectId: 'project-core',\n        episodeId: 'episode-core',\n        type: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n        targetType: 'NovelPromotionEpisode',\n        targetId: 'episode-core',\n        status: TASK_STATUS.QUEUED,\n        payload: {\n          episodeId: 'episode-core',\n          analysisModel: 'model-core',\n          meta: { locale: 'zh' },\n        },\n        queuedAt: new Date(),\n      },\n    })\n    const run = await createRun({\n      userId: user.id,\n      projectId: 'project-core',\n      episodeId: 'episode-core',\n      workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n      taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n      taskId: existingTask.id,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-core',\n      input: {\n        episodeId: 'episode-core',\n        analysisModel: 'model-core',\n        meta: { locale: 'zh' },\n      },\n    })\n    await prisma.task.update({\n      where: { id: existingTask.id },\n      data: {\n        payload: {\n          episodeId: 'episode-core',\n          analysisModel: 'model-core',\n          runId: run.id,\n          meta: { locale: 'zh', runId: run.id },\n        },\n      },\n    })\n\n    const result = await submitTask({\n      userId: user.id,\n      locale: 'zh',\n      projectId: 'project-core',\n      episodeId: 'episode-core',\n      type: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-core',\n      payload: {\n        episodeId: 'episode-core',\n        analysisModel: 'model-core',\n      },\n      dedupeKey: 'story_to_script:episode-core',\n    })\n\n    expect(result.deduped).toBe(true)\n    expect(result.taskId).toBe(existingTask.id)\n    expect(result.runId).toBe(run.id)\n    expect(await prisma.graphRun.count()).toBe(1)\n    expect(addTaskJobMock).not.toHaveBeenCalled()\n  })\n\n  it('reattaches a new task to the existing active run when the old task is already terminal', async () => {\n    process.env.BILLING_MODE = 'OFF'\n    const user = await createTestUser()\n    const failedTask = await prisma.task.create({\n      data: {\n        userId: user.id,\n        projectId: 'project-core-retry',\n        episodeId: 'episode-core-retry',\n        type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n        targetType: 'NovelPromotionEpisode',\n        targetId: 'episode-core-retry',\n        status: TASK_STATUS.FAILED,\n        errorCode: 'TEST_FAILED',\n        errorMessage: 'old task already failed',\n        payload: {\n          episodeId: 'episode-core-retry',\n          analysisModel: 'model-core',\n          meta: { locale: 'zh' },\n        },\n        queuedAt: new Date(),\n        finishedAt: new Date(),\n      },\n    })\n    const run = await createRun({\n      userId: user.id,\n      projectId: 'project-core-retry',\n      episodeId: 'episode-core-retry',\n      workflowType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n      taskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n      taskId: failedTask.id,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-core-retry',\n      input: {\n        episodeId: 'episode-core-retry',\n        analysisModel: 'model-core',\n        meta: { locale: 'zh' },\n      },\n    })\n\n    const result = await submitTask({\n      userId: user.id,\n      locale: 'zh',\n      projectId: 'project-core-retry',\n      episodeId: 'episode-core-retry',\n      type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-core-retry',\n      payload: {\n        episodeId: 'episode-core-retry',\n        analysisModel: 'model-core',\n      },\n      dedupeKey: 'script_to_storyboard:episode-core-retry',\n    })\n\n    expect(result.deduped).toBe(false)\n    expect(result.runId).toBe(run.id)\n    expect(result.taskId).not.toBe(failedTask.id)\n\n    const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })\n    const newTask = await prisma.task.findUnique({ where: { id: result.taskId } })\n    expect(refreshedRun?.taskId).toBe(result.taskId)\n    expect(newTask?.status).toBe(TASK_STATUS.QUEUED)\n    expect(newTask?.payload).toMatchObject({\n      runId: run.id,\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/billing/worker-lifecycle.integration.test.ts",
    "content": "import { randomUUID } from 'node:crypto'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { Job } from 'bullmq'\nimport { UnrecoverableError } from 'bullmq'\nimport { prepareTaskBilling } from '@/lib/billing/service'\nimport { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'\nimport { TaskTerminatedError } from '@/lib/task/errors'\nimport { withTaskLifecycle } from '@/lib/workers/shared'\nimport { TASK_TYPE, type TaskBillingInfo, type TaskJobData } from '@/lib/task/types'\nimport { prisma } from '../../helpers/prisma'\nimport { resetBillingState } from '../../helpers/db-reset'\nimport { createQueuedTask, createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'\n\nvi.mock('@/lib/task/publisher', () => ({\n  publishTaskEvent: vi.fn(async () => ({})),\n}))\n\nasync function createPreparedVoiceTask() {\n  process.env.BILLING_MODE = 'ENFORCE'\n  const user = await createTestUser()\n  const project = await createTestProject(user.id)\n  await seedBalance(user.id, 10)\n\n  const taskId = randomUUID()\n  const raw = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })\n  if (!raw || !raw.billable) {\n    throw new Error('failed to build billing info fixture')\n  }\n  const prepared = await prepareTaskBilling({\n    id: taskId,\n    userId: user.id,\n    projectId: project.id,\n    billingInfo: raw,\n  })\n\n  const billingInfo = prepared as TaskBillingInfo\n  await createQueuedTask({\n    id: taskId,\n    userId: user.id,\n    projectId: project.id,\n    type: TASK_TYPE.VOICE_LINE,\n    targetType: 'VoiceLine',\n    targetId: 'line-1',\n    billingInfo,\n  })\n\n  const jobData: TaskJobData = {\n    taskId,\n    type: TASK_TYPE.VOICE_LINE,\n    locale: 'en',\n    projectId: project.id,\n    targetType: 'VoiceLine',\n    targetId: 'line-1',\n    billingInfo,\n    userId: user.id,\n    payload: {},\n  }\n\n  const job = {\n    data: jobData,\n    queueName: 'voice',\n    opts: {\n      attempts: 5,\n      backoff: {\n        type: 'exponential',\n        delay: 2_000,\n      },\n    },\n    attemptsMade: 0,\n  } as unknown as Job<TaskJobData>\n\n  return { taskId, user, project, job }\n}\n\ndescribe('billing/worker lifecycle integration', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n  })\n\n  it('settles billing and marks task completed on success', async () => {\n    const fixture = await createPreparedVoiceTask()\n\n    await withTaskLifecycle(fixture.job, async () => ({ actualDurationSeconds: 2 }))\n\n    const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })\n    expect(task?.status).toBe('completed')\n    const billing = task?.billingInfo as TaskBillingInfo\n    expect(billing?.billable).toBe(true)\n    expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')\n  })\n\n  it('rolls back billing and marks task failed on error', async () => {\n    const fixture = await createPreparedVoiceTask()\n\n    await expect(\n      withTaskLifecycle(fixture.job, async () => {\n        throw new Error('worker failed')\n      }),\n    ).rejects.toBeInstanceOf(UnrecoverableError)\n\n    const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })\n    expect(task?.status).toBe('failed')\n    const billing = task?.billingInfo as TaskBillingInfo\n    expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')\n  })\n\n  it('keeps task active for queue retry on retryable worker error', async () => {\n    const fixture = await createPreparedVoiceTask()\n\n    await expect(\n      withTaskLifecycle(fixture.job, async () => {\n        throw new TypeError('terminated')\n      }),\n    ).rejects.toBeInstanceOf(TypeError)\n\n    const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })\n    expect(task?.status).toBe('processing')\n    const billing = task?.billingInfo as TaskBillingInfo\n    expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('frozen')\n  })\n\n  it('rolls back billing on cancellation path', async () => {\n    const fixture = await createPreparedVoiceTask()\n\n    await expect(\n      withTaskLifecycle(fixture.job, async () => {\n        throw new TaskTerminatedError(fixture.taskId)\n      }),\n    ).rejects.toBeInstanceOf(UnrecoverableError)\n\n    const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })\n    const billing = task?.billingInfo as TaskBillingInfo\n    expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')\n    expect(task?.status).not.toBe('failed')\n  })\n})\n"
  },
  {
    "path": "tests/integration/chain/image.chain.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\ntype AddCall = {\n  jobName: string\n  data: TaskJobData\n  options: Record<string, unknown>\n}\n\nconst queueState = vi.hoisted(() => ({\n  addCallsByQueue: new Map<string, AddCall[]>(),\n}))\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => undefined),\n  getUserModels: vi.fn(async () => ({\n    characterModel: 'model-character-1',\n    locationModel: 'model-location-1',\n  })),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  globalCharacter: {\n    findFirst: vi.fn(),\n  },\n  globalCharacterAppearance: {\n    update: vi.fn(async () => ({})),\n  },\n  globalLocation: {\n    findFirst: vi.fn(),\n  },\n  globalLocationImage: {\n    update: vi.fn(async () => ({})),\n  },\n}))\n\nconst sharedMock = vi.hoisted(() => ({\n  generateLabeledImageToCos: vi.fn(async () => 'cos/global-character-generated.png'),\n  parseJsonStringArray: vi.fn(() => [] as string[]),\n}))\n\nvi.mock('bullmq', () => ({\n  Queue: class {\n    private readonly queueName: string\n\n    constructor(queueName: string) {\n      this.queueName = queueName\n    }\n\n    async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {\n      const list = queueState.addCallsByQueue.get(this.queueName) || []\n      list.push({ jobName, data, options })\n      queueState.addCallsByQueue.set(this.queueName, list)\n      return { id: data.taskId }\n    }\n\n    async getJob() {\n      return null\n    }\n  },\n}))\n\nvi.mock('@/lib/redis', () => ({ queueRedis: {} }))\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(\n    '@/lib/workers/handlers/image-task-handler-shared',\n  )\n  return {\n    ...actual,\n    generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,\n    parseJsonStringArray: sharedMock.parseJsonStringArray,\n  }\n})\n\nfunction toJob(data: TaskJobData): Job<TaskJobData> {\n  return { data } as unknown as Job<TaskJobData>\n}\n\ndescribe('chain contract - image queue behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    queueState.addCallsByQueue.clear()\n  })\n\n  it('image tasks are enqueued into image queue with jobId=taskId', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n\n    await addTaskJob({\n      taskId: 'task-image-1',\n      type: TASK_TYPE.ASSET_HUB_IMAGE,\n      locale: 'zh',\n      projectId: 'global-asset-hub',\n      episodeId: null,\n      targetType: 'GlobalCharacter',\n      targetId: 'global-character-1',\n      payload: { type: 'character', id: 'global-character-1' },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []\n    expect(calls).toHaveLength(1)\n    expect(calls[0]).toEqual(expect.objectContaining({\n      jobName: TASK_TYPE.ASSET_HUB_IMAGE,\n      options: expect.objectContaining({ jobId: 'task-image-1', priority: 0 }),\n    }))\n  })\n\n  it('modify asset image task also routes to image queue', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n\n    await addTaskJob({\n      taskId: 'task-image-2',\n      type: TASK_TYPE.MODIFY_ASSET_IMAGE,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'CharacterAppearance',\n      targetId: 'appearance-1',\n      payload: { appearanceId: 'appearance-1', modifyPrompt: 'make it cleaner' },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []\n    expect(calls).toHaveLength(1)\n    expect(calls[0]?.jobName).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)\n    expect(calls[0]?.data.type).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)\n  })\n\n  it('queued image job payload can be consumed by worker handler and persist image fields', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n    const { handleAssetHubImageTask } = await import('@/lib/workers/handlers/asset-hub-image-task-handler')\n\n    prismaMock.globalCharacter.findFirst.mockResolvedValue({\n      id: 'global-character-1',\n      name: 'Hero',\n      appearances: [\n        {\n          id: 'appearance-1',\n          appearanceIndex: 0,\n          changeReason: 'base',\n          description: '黑发，风衣',\n          descriptions: null,\n        },\n      ],\n    })\n\n    await addTaskJob({\n      taskId: 'task-image-chain-worker-1',\n      type: TASK_TYPE.ASSET_HUB_IMAGE,\n      locale: 'zh',\n      projectId: 'global-asset-hub',\n      episodeId: null,\n      targetType: 'GlobalCharacter',\n      targetId: 'global-character-1',\n      payload: { type: 'character', id: 'global-character-1', appearanceIndex: 0 },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []\n    const queued = calls[0]?.data\n    expect(queued?.type).toBe(TASK_TYPE.ASSET_HUB_IMAGE)\n\n    const result = await handleAssetHubImageTask(toJob(queued!))\n    expect(result).toEqual({\n      type: 'character',\n      appearanceId: 'appearance-1',\n      imageCount: 3,\n    })\n\n    expect(prismaMock.globalCharacterAppearance.update).toHaveBeenCalledWith({\n      where: { id: 'appearance-1' },\n      data: {\n        imageUrls: JSON.stringify(['cos/global-character-generated.png', 'cos/global-character-generated.png', 'cos/global-character-generated.png']),\n        imageUrl: 'cos/global-character-generated.png',\n        selectedIndex: null,\n      },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/chain/text.chain.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\ntype AddCall = {\n  jobName: string\n  data: TaskJobData\n  options: Record<string, unknown>\n}\n\nconst queueState = vi.hoisted(() => ({\n  addCallsByQueue: new Map<string, AddCall[]>(),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  project: {\n    findUnique: vi.fn(async () => ({ id: 'project-1', mode: 'novel-promotion' })),\n  },\n  novelPromotionProject: {\n    findFirst: vi.fn(async () => ({ id: 'np-project-1' })),\n  },\n}))\n\nconst llmMock = vi.hoisted(() => ({\n  chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),\n  getCompletionContent: vi.fn(() => JSON.stringify({\n    episodes: [\n      {\n        number: 1,\n        title: '第一集',\n        summary: '开端',\n        startMarker: 'START_MARKER',\n        endMarker: 'END_MARKER',\n      },\n    ],\n  })),\n}))\n\nconst configMock = vi.hoisted(() => ({\n  getUserModelConfig: vi.fn(async () => ({ analysisModel: 'llm::analysis-1' })),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('bullmq', () => ({\n  Queue: class {\n    private readonly queueName: string\n\n    constructor(queueName: string) {\n      this.queueName = queueName\n    }\n\n    async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {\n      const list = queueState.addCallsByQueue.get(this.queueName) || []\n      list.push({ jobName, data, options })\n      queueState.addCallsByQueue.set(this.queueName, list)\n      return { id: data.taskId }\n    }\n\n    async getJob() {\n      return null\n    }\n  },\n}))\n\nvi.mock('@/lib/redis', () => ({ queueRedis: {} }))\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => llmMock)\nvi.mock('@/lib/config-service', () => configMock)\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))\nvi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'run-1' })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({ flush: vi.fn(async () => undefined) })),\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },\n  buildPrompt: vi.fn(() => 'episode-split-prompt'),\n}))\nvi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({\n  createTextMarkerMatcher: (content: string) => ({\n    matchMarker: (marker: string, fromIndex = 0) => {\n      const startIndex = content.indexOf(marker, fromIndex)\n      if (startIndex === -1) return null\n      return { startIndex, endIndex: startIndex + marker.length }\n    },\n  }),\n}))\n\nfunction toJob(data: TaskJobData): Job<TaskJobData> {\n  return { data } as unknown as Job<TaskJobData>\n}\n\ndescribe('chain contract - text queue behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    queueState.addCallsByQueue.clear()\n  })\n\n  it('text tasks are enqueued into text queue', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n\n    await addTaskJob({\n      taskId: 'task-text-1',\n      type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      payload: { episodeId: 'episode-1' },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []\n    expect(calls).toHaveLength(1)\n    expect(calls[0]).toEqual(expect.objectContaining({\n      jobName: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n      options: expect.objectContaining({ jobId: 'task-text-1', priority: 0, attempts: 1 }),\n    }))\n  })\n\n  it('forces single queue attempt for core analysis workflows', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n\n    await addTaskJob({\n      taskId: 'task-text-story-1',\n      type: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      payload: { episodeId: 'episode-1' },\n      userId: 'user-1',\n    }, { attempts: 5 })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []\n    expect(calls).toHaveLength(1)\n    expect(calls[0]?.options).toEqual(expect.objectContaining({\n      jobId: 'task-text-story-1',\n      attempts: 1,\n    }))\n  })\n\n  it('explicit priority is preserved for text queue jobs', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n\n    await addTaskJob({\n      taskId: 'task-text-2',\n      type: TASK_TYPE.REFERENCE_TO_CHARACTER,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: null,\n      targetType: 'NovelPromotionProject',\n      targetId: 'project-1',\n      payload: { referenceImageUrl: 'https://example.com/ref.png' },\n      userId: 'user-1',\n    }, { priority: 7 })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []\n    expect(calls).toHaveLength(1)\n    expect(calls[0]?.options).toEqual(expect.objectContaining({ priority: 7, jobId: 'task-text-2' }))\n  })\n\n  it('queued text job payload can be consumed by text handler and resolve episode boundaries', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n    const { handleEpisodeSplitTask } = await import('@/lib/workers/handlers/episode-split')\n\n    const content = [\n      '前置内容用于凑长度，确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',\n      '前置内容用于凑长度，确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',\n      'START_MARKER',\n      '这里是第一集的正文内容，包含角色冲突与场景推进，长度足够用于链路测试验证。',\n      'END_MARKER',\n      '后置内容用于确保边界外还有文本，并继续补足长度。',\n    ].join('')\n\n    await addTaskJob({\n      taskId: 'task-text-chain-worker-1',\n      type: TASK_TYPE.EPISODE_SPLIT_LLM,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: null,\n      targetType: 'NovelPromotionProject',\n      targetId: 'project-1',\n      payload: { content },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []\n    const queued = calls[0]?.data\n    expect(queued?.type).toBe(TASK_TYPE.EPISODE_SPLIT_LLM)\n\n    const result = await handleEpisodeSplitTask(toJob(queued!))\n    expect(result.success).toBe(true)\n    expect(result.episodes).toHaveLength(1)\n    expect(result.episodes[0]?.title).toBe('第一集')\n    expect(result.episodes[0]?.content).toContain('START_MARKER')\n    expect(result.episodes[0]?.content).toContain('END_MARKER')\n  })\n})\n"
  },
  {
    "path": "tests/integration/chain/video.chain.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\ntype AddCall = {\n  jobName: string\n  data: TaskJobData\n  options: Record<string, unknown>\n}\n\nconst queueState = vi.hoisted(() => ({\n  addCallsByQueue: new Map<string, AddCall[]>(),\n}))\n\nconst workerState = vi.hoisted(() => ({\n  processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),\n}))\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => undefined),\n  getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })),\n  resolveLipSyncVideoSource: vi.fn(async () => 'https://provider.example/lipsync.mp4'),\n  resolveVideoSourceFromGeneration: vi.fn(async () => 'https://provider.example/video.mp4'),\n  toSignedUrlIfCos: vi.fn((url: string | null) => (url ? `https://signed.example/${url}` : null)),\n  uploadVideoSourceToCos: vi.fn(async () => 'cos/lip-sync/video.mp4'),\n}))\nconst configServiceMock = vi.hoisted(() => ({\n  getUserWorkflowConcurrencyConfig: vi.fn(async () => ({\n    analysis: 5,\n    image: 5,\n    video: 5,\n  })),\n}))\nconst concurrencyGateMock = vi.hoisted(() => ({\n  withUserConcurrencyGate: vi.fn(async <T>(input: {\n    run: () => Promise<T>\n  }) => await input.run()),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionPanel: {\n    findUnique: vi.fn(),\n    findFirst: vi.fn(),\n    update: vi.fn(async () => undefined),\n  },\n  novelPromotionVoiceLine: {\n    findUnique: vi.fn(),\n  },\n}))\n\nvi.mock('bullmq', () => ({\n  Queue: class {\n    private readonly queueName: string\n\n    constructor(queueName: string) {\n      this.queueName = queueName\n    }\n\n    async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {\n      const list = queueState.addCallsByQueue.get(this.queueName) || []\n      list.push({ jobName, data, options })\n      queueState.addCallsByQueue.set(this.queueName, list)\n      return { id: data.taskId }\n    }\n\n    async getJob() {\n      return null\n    }\n  },\n  Worker: class {\n    constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {\n      workerState.processor = processor\n    }\n  },\n}))\n\nvi.mock('@/lib/redis', () => ({ queueRedis: {} }))\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: workerMock.reportTaskProgress,\n  withTaskLifecycle: workerMock.withTaskLifecycle,\n}))\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/media/outbound-image', () => ({\n  normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),\n}))\nvi.mock('@/lib/model-capabilities/lookup', () => ({\n  resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),\n}))\nvi.mock('@/lib/model-config-contract', () => ({\n  parseModelKeyStrict: vi.fn(() => ({ provider: 'fal' })),\n}))\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: vi.fn(async () => ({ apiKey: 'api-key' })),\n}))\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/workers/user-concurrency-gate', () => concurrencyGateMock)\n\nfunction toJob(data: TaskJobData): Job<TaskJobData> {\n  return { data } as unknown as Job<TaskJobData>\n}\n\ndescribe('chain contract - video queue behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    queueState.addCallsByQueue.clear()\n    workerState.processor = null\n    prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({\n      id: 'panel-1',\n      videoUrl: 'cos/base-video.mp4',\n    })\n    prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({\n      id: 'line-1',\n      audioUrl: 'cos/line-1.mp3',\n    })\n  })\n\n  it('VIDEO_PANEL is enqueued into video queue', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n\n    await addTaskJob({\n      taskId: 'task-video-1',\n      type: TASK_TYPE.VIDEO_PANEL,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      payload: { videoModel: 'fal::video-model' },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []\n    expect(calls).toHaveLength(1)\n    expect(calls[0]).toEqual(expect.objectContaining({\n      jobName: TASK_TYPE.VIDEO_PANEL,\n      options: expect.objectContaining({ jobId: 'task-video-1', priority: 0 }),\n    }))\n  })\n\n  it('LIP_SYNC is enqueued into video queue', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n\n    await addTaskJob({\n      taskId: 'task-video-2',\n      type: TASK_TYPE.LIP_SYNC,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []\n    expect(calls).toHaveLength(1)\n    expect(calls[0]?.data.type).toBe(TASK_TYPE.LIP_SYNC)\n  })\n\n  it('queued video job payload can be consumed by video worker and persist lipSyncVideoUrl', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n    const { createVideoWorker } = await import('@/lib/workers/video.worker')\n    createVideoWorker()\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    await addTaskJob({\n      taskId: 'task-video-chain-worker-1',\n      type: TASK_TYPE.LIP_SYNC,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []\n    const queued = calls[0]?.data\n    expect(queued?.type).toBe(TASK_TYPE.LIP_SYNC)\n\n    const result = await processor!(toJob(queued!)) as { panelId: string; voiceLineId: string; lipSyncVideoUrl: string }\n    expect(result).toEqual({\n      panelId: 'panel-1',\n      voiceLineId: 'line-1',\n      lipSyncVideoUrl: 'cos/lip-sync/video.mp4',\n    })\n    expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({\n      where: { id: 'panel-1' },\n      data: {\n        lipSyncVideoUrl: 'cos/lip-sync/video.mp4',\n        lipSyncTaskId: null,\n      },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/chain/voice.chain.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\ntype AddCall = {\n  jobName: string\n  data: TaskJobData\n  options: Record<string, unknown>\n}\n\nconst queueState = vi.hoisted(() => ({\n  addCallsByQueue: new Map<string, AddCall[]>(),\n}))\n\nconst workerState = vi.hoisted(() => ({\n  processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,\n}))\n\nconst voiceMock = vi.hoisted(() => ({\n  generateVoiceLine: vi.fn(),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),\n}))\n\nconst voiceDesignMock = vi.hoisted(() => ({\n  handleVoiceDesignTask: vi.fn(),\n}))\n\nvi.mock('bullmq', () => ({\n  Queue: class {\n    private readonly queueName: string\n\n    constructor(queueName: string) {\n      this.queueName = queueName\n    }\n\n    async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {\n      const list = queueState.addCallsByQueue.get(this.queueName) || []\n      list.push({ jobName, data, options })\n      queueState.addCallsByQueue.set(this.queueName, list)\n      return { id: data.taskId }\n    }\n\n    async getJob() {\n      return null\n    }\n  },\n  Worker: class {\n    constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {\n      workerState.processor = processor\n    }\n  },\n}))\n\nvi.mock('@/lib/redis', () => ({ queueRedis: {} }))\nvi.mock('@/lib/voice/generate-voice-line', () => ({\n  generateVoiceLine: voiceMock.generateVoiceLine,\n}))\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: workerMock.reportTaskProgress,\n  withTaskLifecycle: workerMock.withTaskLifecycle,\n}))\nvi.mock('@/lib/workers/handlers/voice-design', () => ({\n  handleVoiceDesignTask: voiceDesignMock.handleVoiceDesignTask,\n}))\n\nfunction toJob(data: TaskJobData): Job<TaskJobData> {\n  return { data } as unknown as Job<TaskJobData>\n}\n\ndescribe('chain contract - voice queue behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    queueState.addCallsByQueue.clear()\n    workerState.processor = null\n    voiceMock.generateVoiceLine.mockResolvedValue({\n      lineId: 'line-1',\n      audioUrl: 'cos/voice-line-1.mp3',\n    })\n    voiceDesignMock.handleVoiceDesignTask.mockResolvedValue({\n      presetId: 'voice-design-1',\n      previewAudioUrl: 'cos/preview-1.mp3',\n    })\n  })\n\n  it('VOICE_LINE is enqueued into voice queue', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n\n    await addTaskJob({\n      taskId: 'task-voice-1',\n      type: TASK_TYPE.VOICE_LINE,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionVoiceLine',\n      targetId: 'line-1',\n      payload: { lineId: 'line-1', episodeId: 'episode-1' },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []\n    expect(calls).toHaveLength(1)\n    expect(calls[0]).toEqual(expect.objectContaining({\n      jobName: TASK_TYPE.VOICE_LINE,\n      options: expect.objectContaining({ jobId: 'task-voice-1', priority: 0 }),\n    }))\n  })\n\n  it('ASSET_HUB_VOICE_DESIGN is enqueued into voice queue', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n\n    await addTaskJob({\n      taskId: 'task-voice-2',\n      type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,\n      locale: 'zh',\n      projectId: 'global-asset-hub',\n      episodeId: null,\n      targetType: 'GlobalAssetHubVoiceDesign',\n      targetId: 'voice-design-1',\n      payload: { voicePrompt: 'female calm narrator' },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []\n    expect(calls).toHaveLength(1)\n    expect(calls[0]?.data.type).toBe(TASK_TYPE.ASSET_HUB_VOICE_DESIGN)\n  })\n\n  it('queued voice job payload can be consumed by voice worker and forwarded with concrete params', async () => {\n    const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')\n    const { createVoiceWorker } = await import('@/lib/workers/voice.worker')\n    createVoiceWorker()\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    await addTaskJob({\n      taskId: 'task-voice-chain-worker-1',\n      type: TASK_TYPE.VOICE_LINE,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionVoiceLine',\n      targetId: 'line-1',\n      payload: {\n        lineId: 'line-1',\n        episodeId: 'episode-1',\n        audioModel: 'fal::voice-model',\n      },\n      userId: 'user-1',\n    })\n\n    const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []\n    const queued = calls[0]?.data\n    expect(queued?.type).toBe(TASK_TYPE.VOICE_LINE)\n\n    const result = await processor!(toJob(queued!))\n    expect(result).toEqual({\n      lineId: 'line-1',\n      audioUrl: 'cos/voice-line-1.mp3',\n    })\n    expect(voiceMock.generateVoiceLine).toHaveBeenCalledWith({\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      lineId: 'line-1',\n      userId: 'user-1',\n      audioModel: 'fal::voice-model',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/provider/fal-provider.contract.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it } from 'vitest'\nimport { queryFalStatus, submitFalTask } from '@/lib/async-submit'\nimport { startScenarioServer } from '../../helpers/fakes/scenario-server'\n\ndescribe('provider contract - fal queue', () => {\n  let server: Awaited<ReturnType<typeof startScenarioServer>> | null = null\n\n  beforeEach(async () => {\n    server = await startScenarioServer()\n    process.env.FAL_QUEUE_BASE_URL = `${server.baseUrl}/fal`\n  })\n\n  afterEach(async () => {\n    delete process.env.FAL_QUEUE_BASE_URL\n    await server?.close()\n    server = null\n  })\n\n  it('submits the expected auth header and json payload', async () => {\n    server!.defineScenario({\n      method: 'POST',\n      path: '/fal/fal-ai/nano-banana-pro',\n      mode: 'success',\n      submitResponse: {\n        status: 200,\n        body: { request_id: 'req_image_1' },\n      },\n    })\n\n    const requestId = await submitFalTask(\n      'fal-ai/nano-banana-pro',\n      {\n        prompt: 'generate image',\n        image_urls: ['data:image/png;base64,AAAA'],\n      },\n      'fal-key-1',\n    )\n\n    expect(requestId).toBe('req_image_1')\n    const requests = server!.getRequests('POST', '/fal/fal-ai/nano-banana-pro')\n    expect(requests).toHaveLength(1)\n    expect(requests[0]?.headers.authorization).toBe('Key fal-key-1')\n    expect(JSON.parse(requests[0]?.bodyText || '{}')).toEqual({\n      prompt: 'generate image',\n      image_urls: ['data:image/png;base64,AAAA'],\n    })\n  })\n\n  it('treats transient status failure as pending and completes after retry', async () => {\n    server!.defineScenario({\n      method: 'GET',\n      path: '/fal/fal-ai/veo3.1/requests/req_video_1/status',\n      mode: 'retryable_error_then_success',\n      pollSequence: [\n        { status: 503, body: { error: 'upstream unavailable' } },\n        { status: 200, body: { status: 'COMPLETED' } },\n      ],\n    })\n    server!.defineScenario({\n      method: 'GET',\n      path: '/fal/fal-ai/veo3.1/fast/image-to-video/requests/req_video_1',\n      mode: 'success',\n      submitResponse: {\n        status: 200,\n        body: {\n          video: { url: 'https://cdn.local/video.mp4' },\n        },\n      },\n    })\n\n    const first = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')\n    const second = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')\n\n    expect(first).toEqual({\n      status: 'IN_PROGRESS',\n      completed: false,\n      failed: false,\n    })\n    expect(second).toEqual({\n      status: 'COMPLETED',\n      completed: true,\n      failed: false,\n      resultUrl: 'https://cdn.local/video.mp4',\n    })\n  })\n\n  it('marks a failed status response as failed with explicit provider error', async () => {\n    server!.defineScenario({\n      method: 'GET',\n      path: '/fal/fal-ai/veo3.1/requests/req_failed/status',\n      mode: 'fatal_error',\n      submitResponse: {\n        status: 200,\n        body: {\n          status: 'FAILED',\n          error: 'content moderation failed',\n        },\n      },\n    })\n\n    const result = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_failed', 'fal-key-3')\n    expect(result).toEqual({\n      status: 'FAILED',\n      completed: false,\n      failed: true,\n      error: 'content moderation failed',\n    })\n  })\n\n  it('fails explicitly when submit response is malformed', async () => {\n    server!.defineScenario({\n      method: 'POST',\n      path: '/fal/fal-ai/nano-banana-pro',\n      mode: 'malformed_response',\n      submitResponse: {\n        status: 200,\n        body: { ok: true },\n      },\n    })\n\n    await expect(\n      submitFalTask('fal-ai/nano-banana-pro', { prompt: 'bad response' }, 'fal-key-4'),\n    ).rejects.toThrow('FAL未返回request_id')\n  })\n\n  it('treats completed result without media url as failed', async () => {\n    server!.defineScenario({\n      method: 'GET',\n      path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media/status',\n      mode: 'queued_then_success',\n      submitResponse: {\n        status: 200,\n        body: { status: 'COMPLETED' },\n      },\n    })\n    server!.defineScenario({\n      method: 'GET',\n      path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media',\n      mode: 'malformed_response',\n      submitResponse: {\n        status: 200,\n        body: { images: [] },\n      },\n    })\n\n    const result = await queryFalStatus('fal-ai/nano-banana-pro', 'req_no_media', 'fal-key-5')\n    expect(result).toEqual({\n      status: 'COMPLETED',\n      completed: true,\n      failed: false,\n      resultUrl: undefined,\n    })\n  })\n})\n"
  },
  {
    "path": "tests/integration/provider/openai-compat-provider.contract.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { generateVideoViaOpenAICompatTemplate } from '@/lib/model-gateway/openai-compat/template-video'\nimport { pollAsyncTask } from '@/lib/async-poll'\nimport { startScenarioServer } from '../../helpers/fakes/scenario-server'\n\nconst getProviderConfigMock = vi.hoisted(() => vi.fn())\nconst getUserModelsMock = vi.hoisted(() => vi.fn())\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n  getUserModels: getUserModelsMock,\n}))\n\nfunction encode(value: string): string {\n  return Buffer.from(value, 'utf8').toString('base64url')\n}\n\ndescribe('provider contract - openai compatible media template', () => {\n  let server: Awaited<ReturnType<typeof startScenarioServer>> | null = null\n\n  beforeEach(async () => {\n    server = await startScenarioServer()\n    vi.clearAllMocks()\n    getProviderConfigMock.mockResolvedValue({\n      id: 'openai-compatible:provider-local',\n      apiKey: 'sk-local',\n      baseUrl: `${server.baseUrl}/compat/v1`,\n    })\n  })\n\n  afterEach(async () => {\n    await server?.close()\n    server = null\n  })\n\n  it('renders create request against provider baseUrl and returns OCOMPAT externalId', async () => {\n    server!.defineScenario({\n      method: 'POST',\n      path: '/compat/v1/video/create',\n      mode: 'success',\n      submitResponse: {\n        status: 200,\n        body: { status: 'queued', task_id: 'task_local_1' },\n      },\n    })\n\n    const result = await generateVideoViaOpenAICompatTemplate({\n      userId: 'user-local',\n      providerId: 'openai-compatible:provider-local',\n      modelId: 'veo-local',\n      modelKey: 'openai-compatible:provider-local::veo-local',\n      imageUrl: 'data:image/png;base64,AAAA',\n      prompt: 'animate this frame',\n      options: {\n        duration: 5,\n        aspectRatio: '9:16',\n      },\n      profile: 'openai-compatible',\n      template: {\n        version: 1,\n        mediaType: 'video',\n        mode: 'async',\n        create: {\n          method: 'POST',\n          path: '/video/create',\n          bodyTemplate: {\n            model: '{{model}}',\n            prompt: '{{prompt}}',\n            image: '{{image}}',\n            duration: '{{duration}}',\n          },\n        },\n        status: { method: 'GET', path: '/video/status/{{task_id}}' },\n        response: {\n          taskIdPath: '$.task_id',\n          statusPath: '$.status',\n        },\n        polling: {\n          intervalMs: 1000,\n          timeoutMs: 30_000,\n          doneStates: ['done'],\n          failStates: ['failed'],\n        },\n      },\n    })\n\n    expect(result).toMatchObject({\n      success: true,\n      async: true,\n      requestId: 'task_local_1',\n      externalId: `OCOMPAT:VIDEO:b64_${encode('openai-compatible:provider-local')}:${encode('veo-local')}:task_local_1`,\n    })\n\n    const requests = server!.getRequests('POST', '/compat/v1/video/create')\n    expect(requests).toHaveLength(1)\n    expect(requests[0]?.headers.authorization).toBe('Bearer sk-local')\n    expect(JSON.parse(requests[0]?.bodyText || '{}')).toEqual({\n      model: 'veo-local',\n      prompt: 'animate this frame',\n      image: 'data:image/png;base64,AAAA',\n      duration: 5,\n    })\n  })\n\n  it('polls localhost provider status and falls back to content endpoint when output url is missing', async () => {\n    getUserModelsMock.mockResolvedValue([\n      {\n        modelKey: 'openai-compatible:provider-local::veo-local',\n        modelId: 'veo-local',\n        name: 'Local Veo',\n        type: 'video',\n        provider: 'openai-compatible:provider-local',\n        price: 0,\n        compatMediaTemplate: {\n          version: 1,\n          mediaType: 'video',\n          mode: 'async',\n          create: { method: 'POST', path: '/video/create' },\n          status: { method: 'GET', path: '/video/status/{{task_id}}' },\n          content: { method: 'GET', path: '/video/content/{{task_id}}' },\n          response: {\n            statusPath: '$.status',\n          },\n          polling: {\n            intervalMs: 1000,\n            timeoutMs: 30_000,\n            doneStates: ['done'],\n            failStates: ['failed'],\n          },\n        },\n      },\n    ])\n    server!.defineScenario({\n      method: 'GET',\n      path: '/compat/v1/video/status/task_local_2',\n      mode: 'queued_then_success',\n      pollSequence: [\n        { status: 200, body: { status: 'running' } },\n        { status: 200, body: { status: 'done' } },\n      ],\n    })\n\n    const first = await pollAsyncTask(\n      `OCOMPAT:VIDEO:${encode('openai-compatible:provider-local')}:${encode('openai-compatible:provider-local::veo-local')}:task_local_2`,\n      'user-local',\n    )\n    const second = await pollAsyncTask(\n      `OCOMPAT:VIDEO:${encode('openai-compatible:provider-local')}:${encode('openai-compatible:provider-local::veo-local')}:task_local_2`,\n      'user-local',\n    )\n\n    expect(first).toEqual({ status: 'pending' })\n    expect(second).toEqual({\n      status: 'completed',\n      resultUrl: `${server!.baseUrl}/compat/v1/video/content/task_local_2`,\n      videoUrl: `${server!.baseUrl}/compat/v1/video/content/task_local_2`,\n      downloadHeaders: {\n        Authorization: 'Bearer sk-local',\n      },\n    })\n  })\n\n  it('fails explicitly when async create response omits task id', async () => {\n    server!.defineScenario({\n      method: 'POST',\n      path: '/compat/v1/video/create',\n      mode: 'malformed_response',\n      submitResponse: {\n        status: 200,\n        body: { status: 'queued' },\n      },\n    })\n\n    await expect(\n      generateVideoViaOpenAICompatTemplate({\n        userId: 'user-local',\n        providerId: 'openai-compatible:provider-local',\n        modelId: 'veo-local',\n        modelKey: 'openai-compatible:provider-local::veo-local',\n        imageUrl: 'data:image/png;base64,AAAA',\n        prompt: 'bad create payload',\n        profile: 'openai-compatible',\n        template: {\n          version: 1,\n          mediaType: 'video',\n          mode: 'async',\n          create: {\n            method: 'POST',\n            path: '/video/create',\n            bodyTemplate: { prompt: '{{prompt}}' },\n          },\n          status: { method: 'GET', path: '/video/status/{{task_id}}' },\n          response: {\n            taskIdPath: '$.task_id',\n            statusPath: '$.status',\n          },\n          polling: {\n            intervalMs: 1000,\n            timeoutMs: 30_000,\n            doneStates: ['done'],\n            failStates: ['failed'],\n          },\n        },\n      }),\n    ).rejects.toThrow('OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND')\n  })\n})\n"
  },
  {
    "path": "tests/integration/run-runtime/retry-failed-step.integration.test.ts",
    "content": "import { beforeEach, describe, expect, it } from 'vitest'\nimport { retryFailedStep } from '@/lib/run-runtime/service'\nimport { RUN_STATUS, RUN_STEP_STATUS } from '@/lib/run-runtime/types'\nimport { prisma } from '../../helpers/prisma'\nimport { resetBillingState } from '../../helpers/db-reset'\nimport { createTestUser } from '../../helpers/billing-fixtures'\n\ndescribe('run runtime retryFailedStep invalidation', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n  })\n\n  it('invalidates downstream story-to-script steps and artifacts', async () => {\n    const user = await createTestUser()\n    const run = await prisma.graphRun.create({\n      data: {\n        userId: user.id,\n        projectId: 'project-retry-story',\n        episodeId: 'episode-retry-story',\n        workflowType: 'story_to_script_run',\n        taskType: 'story_to_script_run',\n        targetType: 'NovelPromotionEpisode',\n        targetId: 'episode-retry-story',\n        status: RUN_STATUS.FAILED,\n        queuedAt: new Date(),\n        startedAt: new Date(),\n        finishedAt: new Date(),\n      },\n    })\n\n    await prisma.graphStep.createMany({\n      data: [\n        {\n          runId: run.id,\n          stepKey: 'analyze_characters',\n          stepTitle: 'Analyze Characters',\n          status: RUN_STEP_STATUS.FAILED,\n          currentAttempt: 1,\n          stepIndex: 1,\n          stepTotal: 5,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n          lastErrorCode: 'STEP_FAILED',\n          lastErrorMessage: 'characters failed',\n        },\n        {\n          runId: run.id,\n          stepKey: 'analyze_locations',\n          stepTitle: 'Analyze Locations',\n          status: RUN_STEP_STATUS.COMPLETED,\n          currentAttempt: 1,\n          stepIndex: 2,\n          stepTotal: 5,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n        },\n        {\n          runId: run.id,\n          stepKey: 'split_clips',\n          stepTitle: 'Split Clips',\n          status: RUN_STEP_STATUS.COMPLETED,\n          currentAttempt: 1,\n          stepIndex: 3,\n          stepTotal: 5,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n        },\n        {\n          runId: run.id,\n          stepKey: 'screenplay_clip-a',\n          stepTitle: 'Screenplay A',\n          status: RUN_STEP_STATUS.COMPLETED,\n          currentAttempt: 1,\n          stepIndex: 4,\n          stepTotal: 5,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n        },\n        {\n          runId: run.id,\n          stepKey: 'screenplay_clip-b',\n          stepTitle: 'Screenplay B',\n          status: RUN_STEP_STATUS.COMPLETED,\n          currentAttempt: 1,\n          stepIndex: 5,\n          stepTotal: 5,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n        },\n      ],\n    })\n\n    await prisma.graphArtifact.createMany({\n      data: [\n        {\n          runId: run.id,\n          stepKey: 'analyze_characters',\n          artifactType: 'analysis.characters',\n          refId: 'episode-retry-story',\n          payload: { rows: [{ name: 'Hero' }] },\n        },\n        {\n          runId: run.id,\n          stepKey: 'analyze_locations',\n          artifactType: 'analysis.locations',\n          refId: 'episode-retry-story',\n          payload: { rows: [{ name: 'City' }] },\n        },\n        {\n          runId: run.id,\n          stepKey: 'split_clips',\n          artifactType: 'clips',\n          refId: 'episode-retry-story',\n          payload: { clips: [{ id: 'clip-a' }] },\n        },\n        {\n          runId: run.id,\n          stepKey: 'screenplay_clip-a',\n          artifactType: 'screenplay.clip',\n          refId: 'clip-a',\n          payload: { scenes: [{ id: 1 }] },\n        },\n      ],\n    })\n\n    const retried = await retryFailedStep({\n      runId: run.id,\n      userId: user.id,\n      stepKey: 'analyze_characters',\n    })\n\n    expect(retried?.retryAttempt).toBe(2)\n    expect(retried?.invalidatedStepKeys.slice().sort()).toEqual([\n      'analyze_characters',\n      'screenplay_clip-a',\n      'screenplay_clip-b',\n      'split_clips',\n    ])\n\n    const steps = await prisma.graphStep.findMany({\n      where: { runId: run.id },\n      orderBy: { stepIndex: 'asc' },\n    })\n    const stepMap = new Map(steps.map((step) => [step.stepKey, step]))\n    expect(stepMap.get('analyze_characters')).toMatchObject({\n      status: RUN_STEP_STATUS.PENDING,\n      currentAttempt: 2,\n      lastErrorCode: null,\n      lastErrorMessage: null,\n    })\n    expect(stepMap.get('split_clips')).toMatchObject({\n      status: RUN_STEP_STATUS.PENDING,\n      currentAttempt: 0,\n    })\n    expect(stepMap.get('screenplay_clip-a')).toMatchObject({\n      status: RUN_STEP_STATUS.PENDING,\n      currentAttempt: 0,\n    })\n    expect(stepMap.get('analyze_locations')).toMatchObject({\n      status: RUN_STEP_STATUS.COMPLETED,\n      currentAttempt: 1,\n    })\n\n    const artifacts = await prisma.graphArtifact.findMany({\n      where: { runId: run.id },\n      orderBy: { stepKey: 'asc' },\n    })\n    expect(artifacts.map((artifact) => artifact.stepKey)).toEqual(['analyze_locations'])\n\n    const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })\n    expect(refreshedRun?.status).toBe(RUN_STATUS.RUNNING)\n    expect(refreshedRun?.errorCode).toBeNull()\n    expect(refreshedRun?.errorMessage).toBeNull()\n  })\n\n  it('invalidates only the dependent storyboard branch plus voice analyze', async () => {\n    const user = await createTestUser()\n    const run = await prisma.graphRun.create({\n      data: {\n        userId: user.id,\n        projectId: 'project-retry-storyboard',\n        episodeId: 'episode-retry-storyboard',\n        workflowType: 'script_to_storyboard_run',\n        taskType: 'script_to_storyboard_run',\n        targetType: 'NovelPromotionEpisode',\n        targetId: 'episode-retry-storyboard',\n        status: RUN_STATUS.FAILED,\n        queuedAt: new Date(),\n        startedAt: new Date(),\n        finishedAt: new Date(),\n      },\n    })\n\n    await prisma.graphStep.createMany({\n      data: [\n        {\n          runId: run.id,\n          stepKey: 'clip_clip-1_phase1',\n          stepTitle: 'Clip 1 Phase 1',\n          status: RUN_STEP_STATUS.FAILED,\n          currentAttempt: 1,\n          stepIndex: 1,\n          stepTotal: 6,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n          lastErrorCode: 'STEP_FAILED',\n          lastErrorMessage: 'phase1 failed',\n        },\n        {\n          runId: run.id,\n          stepKey: 'clip_clip-1_phase2_cinematography',\n          stepTitle: 'Clip 1 Phase 2 Cine',\n          status: RUN_STEP_STATUS.COMPLETED,\n          currentAttempt: 1,\n          stepIndex: 2,\n          stepTotal: 6,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n        },\n        {\n          runId: run.id,\n          stepKey: 'clip_clip-1_phase2_acting',\n          stepTitle: 'Clip 1 Phase 2 Acting',\n          status: RUN_STEP_STATUS.COMPLETED,\n          currentAttempt: 1,\n          stepIndex: 3,\n          stepTotal: 6,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n        },\n        {\n          runId: run.id,\n          stepKey: 'clip_clip-1_phase3_detail',\n          stepTitle: 'Clip 1 Phase 3',\n          status: RUN_STEP_STATUS.COMPLETED,\n          currentAttempt: 1,\n          stepIndex: 4,\n          stepTotal: 6,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n        },\n        {\n          runId: run.id,\n          stepKey: 'clip_clip-2_phase3_detail',\n          stepTitle: 'Clip 2 Phase 3',\n          status: RUN_STEP_STATUS.COMPLETED,\n          currentAttempt: 1,\n          stepIndex: 5,\n          stepTotal: 6,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n        },\n        {\n          runId: run.id,\n          stepKey: 'voice_analyze',\n          stepTitle: 'Voice Analyze',\n          status: RUN_STEP_STATUS.COMPLETED,\n          currentAttempt: 1,\n          stepIndex: 6,\n          stepTotal: 6,\n          startedAt: new Date(),\n          finishedAt: new Date(),\n        },\n      ],\n    })\n\n    await prisma.graphArtifact.createMany({\n      data: [\n        {\n          runId: run.id,\n          stepKey: 'clip_clip-1_phase1',\n          artifactType: 'storyboard.clip.phase1',\n          refId: 'clip-1',\n          payload: { panels: [] },\n        },\n        {\n          runId: run.id,\n          stepKey: 'clip_clip-1_phase2_cinematography',\n          artifactType: 'storyboard.clip.phase2.cine',\n          refId: 'clip-1',\n          payload: { rules: [] },\n        },\n        {\n          runId: run.id,\n          stepKey: 'clip_clip-2_phase3_detail',\n          artifactType: 'storyboard.clip.phase3',\n          refId: 'clip-2',\n          payload: { panels: [] },\n        },\n        {\n          runId: run.id,\n          stepKey: 'voice_analyze',\n          artifactType: 'voice.lines',\n          refId: 'episode-retry-storyboard',\n          payload: { lines: [] },\n        },\n      ],\n    })\n\n    const retried = await retryFailedStep({\n      runId: run.id,\n      userId: user.id,\n      stepKey: 'clip_clip-1_phase1',\n    })\n\n    expect(retried?.retryAttempt).toBe(2)\n    expect(retried?.invalidatedStepKeys.slice().sort()).toEqual([\n      'clip_clip-1_phase1',\n      'clip_clip-1_phase2_acting',\n      'clip_clip-1_phase2_cinematography',\n      'clip_clip-1_phase3_detail',\n      'voice_analyze',\n    ])\n\n    const steps = await prisma.graphStep.findMany({\n      where: { runId: run.id },\n      orderBy: { stepIndex: 'asc' },\n    })\n    const stepMap = new Map(steps.map((step) => [step.stepKey, step]))\n    expect(stepMap.get('clip_clip-1_phase1')).toMatchObject({\n      status: RUN_STEP_STATUS.PENDING,\n      currentAttempt: 2,\n    })\n    expect(stepMap.get('clip_clip-1_phase2_cinematography')).toMatchObject({\n      status: RUN_STEP_STATUS.PENDING,\n      currentAttempt: 0,\n    })\n    expect(stepMap.get('voice_analyze')).toMatchObject({\n      status: RUN_STEP_STATUS.PENDING,\n      currentAttempt: 0,\n    })\n    expect(stepMap.get('clip_clip-2_phase3_detail')).toMatchObject({\n      status: RUN_STEP_STATUS.COMPLETED,\n      currentAttempt: 1,\n    })\n\n    const artifacts = await prisma.graphArtifact.findMany({\n      where: { runId: run.id },\n      orderBy: { stepKey: 'asc' },\n    })\n    expect(artifacts.map((artifact) => artifact.stepKey)).toEqual(['clip_clip-2_phase3_detail'])\n  })\n})\n"
  },
  {
    "path": "tests/integration/task/create-task-dedupe.integration.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'\nimport { createTask } from '@/lib/task/service'\nimport { prisma } from '../../helpers/prisma'\nimport { resetBillingState } from '../../helpers/db-reset'\nimport { createTestProject, createTestUser } from '../../helpers/billing-fixtures'\n\nconst reconcileMock = vi.hoisted(() => ({\n  isJobAlive: vi.fn(async () => true),\n}))\n\nvi.mock('@/lib/task/reconcile', () => reconcileMock)\n\ndescribe('task service dedupe + orphan recovery', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n    vi.clearAllMocks()\n    reconcileMock.isJobAlive.mockResolvedValue(true)\n  })\n\n  it('dedupes to an active task when dedupeKey matches and queue job is alive', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    const existing = await prisma.task.create({\n      data: {\n        userId: user.id,\n        projectId: project.id,\n        type: TASK_TYPE.VOICE_LINE,\n        targetType: 'NovelPromotionVoiceLine',\n        targetId: 'line-1',\n        status: TASK_STATUS.QUEUED,\n        payload: {\n          episodeId: 'episode-1',\n          lineId: 'line-1',\n          meta: { locale: 'zh' },\n        },\n        dedupeKey: 'voice_line:line-1',\n        queuedAt: new Date(),\n      },\n    })\n\n    const result = await createTask({\n      userId: user.id,\n      projectId: project.id,\n      type: TASK_TYPE.VOICE_LINE,\n      targetType: 'NovelPromotionVoiceLine',\n      targetId: 'line-1',\n      payload: {\n        episodeId: 'episode-1',\n        lineId: 'line-1',\n        meta: { locale: 'zh' },\n      },\n      dedupeKey: 'voice_line:line-1',\n    })\n\n    expect(result.deduped).toBe(true)\n    expect(result.task.id).toBe(existing.id)\n    expect(reconcileMock.isJobAlive).toHaveBeenCalledWith(existing.id)\n  })\n\n  it('fails orphaned active task and creates a replacement when queue job is missing', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    const existing = await prisma.task.create({\n      data: {\n        userId: user.id,\n        projectId: project.id,\n        type: TASK_TYPE.VIDEO_PANEL,\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-1',\n        status: TASK_STATUS.QUEUED,\n        payload: {\n          storyboardId: 'storyboard-1',\n          panelIndex: 1,\n          meta: { locale: 'zh' },\n        },\n        dedupeKey: 'video_panel:panel-1',\n        queuedAt: new Date(),\n      },\n    })\n    reconcileMock.isJobAlive.mockResolvedValue(false)\n\n    const result = await createTask({\n      userId: user.id,\n      projectId: project.id,\n      type: TASK_TYPE.VIDEO_PANEL,\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      payload: {\n        storyboardId: 'storyboard-1',\n        panelIndex: 1,\n        meta: { locale: 'zh' },\n      },\n      dedupeKey: 'video_panel:panel-1',\n    })\n\n    expect(result.deduped).toBe(false)\n    expect(result.task.id).not.toBe(existing.id)\n\n    const failedExisting = await prisma.task.findUnique({ where: { id: existing.id } })\n    expect(failedExisting).toMatchObject({\n      status: TASK_STATUS.FAILED,\n      errorCode: 'RECONCILE_ORPHAN',\n      dedupeKey: null,\n    })\n  })\n\n  it('fails locale-less active task and replaces it instead of deduping forever', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    const existing = await prisma.task.create({\n      data: {\n        userId: user.id,\n        projectId: project.id,\n        type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n        targetType: 'NovelPromotionEpisode',\n        targetId: 'episode-1',\n        status: TASK_STATUS.QUEUED,\n        payload: {\n          episodeId: 'episode-1',\n        },\n        dedupeKey: 'script_to_storyboard_run:episode-1',\n        queuedAt: new Date(),\n      },\n    })\n\n    const result = await createTask({\n      userId: user.id,\n      projectId: project.id,\n      type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      payload: {\n        episodeId: 'episode-1',\n        meta: { locale: 'zh' },\n      },\n      dedupeKey: 'script_to_storyboard_run:episode-1',\n    })\n\n    expect(result.deduped).toBe(false)\n    expect(result.task.id).not.toBe(existing.id)\n\n    const failedExisting = await prisma.task.findUnique({ where: { id: existing.id } })\n    expect(failedExisting).toMatchObject({\n      status: TASK_STATUS.FAILED,\n      errorCode: 'TASK_LOCALE_REQUIRED',\n      dedupeKey: null,\n    })\n  })\n})\n"
  },
  {
    "path": "tests/regression/panel-variant-cross-storyboard.test.ts",
    "content": "import { beforeEach, describe, expect, it } from 'vitest'\nimport { callRoute } from '../integration/api/helpers/call-route'\nimport { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'\nimport { resetSystemState } from '../helpers/db-reset'\nimport { prisma } from '../helpers/prisma'\nimport { seedMinimalDomainState } from '../system/helpers/seed'\n\ndescribe('regression - panel variant cross storyboard safety', () => {\n  beforeEach(async () => {\n    await resetSystemState()\n    installAuthMocks()\n  })\n\n  it('sourcePanelId from another storyboard -> explicit invalid params and no dirty panel', async () => {\n    const seeded = await seedMinimalDomainState()\n    mockAuthenticated(seeded.user.id)\n\n    const beforeCount = await prisma.novelPromotionPanel.count({\n      where: { storyboardId: seeded.storyboard.id },\n    })\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route')\n    const response = await callRoute(\n      mod.POST,\n      'POST',\n      {\n        locale: 'zh',\n        storyboardId: seeded.storyboard.id,\n        insertAfterPanelId: seeded.panel.id,\n        sourcePanelId: seeded.foreignPanel.id,\n        variant: {\n          video_prompt: 'variant prompt',\n          description: 'variant description',\n        },\n      },\n      { params: { projectId: seeded.project.id } },\n    )\n\n    expect(response.status).toBe(400)\n    const json = await response.json() as { error?: { code?: string } }\n    expect(json.error?.code).toBe('INVALID_PARAMS')\n\n    const afterCount = await prisma.novelPromotionPanel.count({\n      where: { storyboardId: seeded.storyboard.id },\n    })\n    expect(afterCount).toBe(beforeCount)\n\n    resetAuthMockState()\n  })\n})\n"
  },
  {
    "path": "tests/regression/task-dedupe-recovery.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'\nimport { createTask } from '@/lib/task/service'\nimport { prisma } from '../helpers/prisma'\nimport { resetBillingState } from '../helpers/db-reset'\nimport { createTestProject, createTestUser } from '../helpers/billing-fixtures'\n\nconst reconcileMock = vi.hoisted(() => ({\n  isJobAlive: vi.fn(async () => true),\n}))\n\nvi.mock('@/lib/task/reconcile', () => reconcileMock)\n\ndescribe('regression - task dedupe recovery', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n    vi.clearAllMocks()\n    reconcileMock.isJobAlive.mockResolvedValue(true)\n  })\n\n  it('replaces locale-less queued task instead of deduping forever', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    const stale = await prisma.task.create({\n      data: {\n        userId: user.id,\n        projectId: project.id,\n        type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n        targetType: 'NovelPromotionEpisode',\n        targetId: 'episode-regression-1',\n        status: TASK_STATUS.QUEUED,\n        payload: { episodeId: 'episode-regression-1' },\n        dedupeKey: 'script_to_storyboard_run:episode-regression-1',\n        queuedAt: new Date(),\n      },\n    })\n\n    const replacement = await createTask({\n      userId: user.id,\n      projectId: project.id,\n      type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-regression-1',\n      payload: {\n        episodeId: 'episode-regression-1',\n        meta: { locale: 'zh' },\n      },\n      dedupeKey: 'script_to_storyboard_run:episode-regression-1',\n    })\n\n    expect(replacement.deduped).toBe(false)\n    expect(replacement.task.id).not.toBe(stale.id)\n\n    const failedStale = await prisma.task.findUnique({ where: { id: stale.id } })\n    expect(failedStale).toMatchObject({\n      status: TASK_STATUS.FAILED,\n      errorCode: 'TASK_LOCALE_REQUIRED',\n      dedupeKey: null,\n    })\n  })\n\n  it('replaces orphaned queued task when queue job is gone', async () => {\n    const user = await createTestUser()\n    const project = await createTestProject(user.id)\n    const orphan = await prisma.task.create({\n      data: {\n        userId: user.id,\n        projectId: project.id,\n        type: TASK_TYPE.VIDEO_PANEL,\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-regression-1',\n        status: TASK_STATUS.QUEUED,\n        payload: {\n          storyboardId: 'storyboard-regression-1',\n          panelIndex: 1,\n          meta: { locale: 'zh' },\n        },\n        dedupeKey: 'video_panel:panel-regression-1',\n        queuedAt: new Date(),\n      },\n    })\n    reconcileMock.isJobAlive.mockResolvedValue(false)\n\n    const replacement = await createTask({\n      userId: user.id,\n      projectId: project.id,\n      type: TASK_TYPE.VIDEO_PANEL,\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-regression-1',\n      payload: {\n        storyboardId: 'storyboard-regression-1',\n        panelIndex: 1,\n        meta: { locale: 'zh' },\n      },\n      dedupeKey: 'video_panel:panel-regression-1',\n    })\n\n    expect(replacement.deduped).toBe(false)\n    expect(replacement.task.id).not.toBe(orphan.id)\n\n    const failedOrphan = await prisma.task.findUnique({ where: { id: orphan.id } })\n    expect(failedOrphan).toMatchObject({\n      status: TASK_STATUS.FAILED,\n      errorCode: 'RECONCILE_ORPHAN',\n      dedupeKey: null,\n    })\n  })\n})\n"
  },
  {
    "path": "tests/regression/task-enqueue-billing-rollback.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { submitTask } from '@/lib/task/submitter'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { prisma } from '../helpers/prisma'\nimport { resetBillingState } from '../helpers/db-reset'\nimport { createTestUser, seedBalance } from '../helpers/billing-fixtures'\n\nconst queueState = vi.hoisted(() => ({\n  message: 'queue add failed',\n}))\n\nvi.mock('@/lib/task/queues', () => ({\n  addTaskJob: vi.fn(async () => {\n    throw new Error(queueState.message)\n  }),\n}))\n\nvi.mock('@/lib/task/publisher', () => ({\n  publishTaskEvent: vi.fn(async () => ({})),\n}))\n\ndescribe('regression - enqueue compensation', () => {\n  beforeEach(async () => {\n    await resetBillingState()\n    vi.clearAllMocks()\n    process.env.BILLING_MODE = 'ENFORCE'\n    queueState.message = 'queue unavailable'\n  })\n\n  it('rolls back frozen balance when queue submission fails', async () => {\n    const user = await createTestUser()\n    await seedBalance(user.id, 10)\n\n    await expect(\n      submitTask({\n        userId: user.id,\n        locale: 'en',\n        projectId: 'project-regression-enqueue',\n        type: TASK_TYPE.VOICE_LINE,\n        targetType: 'VoiceLine',\n        targetId: 'line-regression-enqueue',\n        payload: { maxSeconds: 6 },\n      }),\n    ).rejects.toMatchObject({ code: 'EXTERNAL_ERROR' })\n\n    const task = await prisma.task.findFirst({\n      where: {\n        userId: user.id,\n        type: TASK_TYPE.VOICE_LINE,\n      },\n      orderBy: { createdAt: 'desc' },\n    })\n    const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })\n    const freeze = await prisma.balanceFreeze.findFirst({ orderBy: { createdAt: 'desc' } })\n\n    expect(task).toMatchObject({\n      status: 'failed',\n      errorCode: 'ENQUEUE_FAILED',\n    })\n    expect(task?.billingInfo).toMatchObject({\n      billable: true,\n      status: 'rolled_back',\n    })\n    expect(balance?.balance).toBeCloseTo(10, 8)\n    expect(balance?.frozenAmount).toBeCloseTo(0, 8)\n    expect(freeze?.status).toBe('rolled_back')\n  })\n})\n"
  },
  {
    "path": "tests/setup/env.ts",
    "content": "import fs from 'node:fs'\nimport path from 'node:path'\n\nlet loaded = false\n\nfunction parseEnvLine(line: string) {\n  const trimmed = line.trim()\n  if (!trimmed || trimmed.startsWith('#')) return null\n  const idx = trimmed.indexOf('=')\n  if (idx <= 0) return null\n  const key = trimmed.slice(0, idx).trim()\n  if (!key) return null\n  const rawValue = trimmed.slice(idx + 1).trim()\n  const unquoted =\n    (rawValue.startsWith('\"') && rawValue.endsWith('\"'))\n    || (rawValue.startsWith(\"'\") && rawValue.endsWith(\"'\"))\n      ? rawValue.slice(1, -1)\n      : rawValue\n  return { key, value: unquoted }\n}\n\nexport function loadTestEnv() {\n  if (loaded) return\n  loaded = true\n  const mutableEnv = process.env as Record<string, string | undefined>\n  const setIfMissing = (key: string, value: string) => {\n    if (!mutableEnv[key]) {\n      mutableEnv[key] = value\n    }\n  }\n\n  const envPath = path.resolve(process.cwd(), '.env.test')\n  if (fs.existsSync(envPath)) {\n    const content = fs.readFileSync(envPath, 'utf8')\n    for (const line of content.split('\\n')) {\n      const pair = parseEnvLine(line)\n      if (!pair) continue\n      if (mutableEnv[pair.key] === undefined) {\n        mutableEnv[pair.key] = pair.value\n      }\n    }\n  }\n\n  setIfMissing('NODE_ENV', 'test')\n  setIfMissing('BILLING_MODE', 'OFF')\n  setIfMissing('DATABASE_URL', 'mysql://root:root@127.0.0.1:3307/waoowaoo_test')\n  setIfMissing('REDIS_HOST', '127.0.0.1')\n  setIfMissing('REDIS_PORT', '6380')\n}\n\nloadTestEnv()\n\nif (process.env.ALLOW_TEST_NETWORK !== '1' && typeof globalThis.fetch === 'function') {\n  const originalFetch = globalThis.fetch\n  const allowHosts = new Set(['localhost', '127.0.0.1'])\n\n  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {\n    const rawUrl =\n      typeof input === 'string'\n        ? input\n        : input instanceof URL\n          ? input.toString()\n          : input.url\n    const parsed = new URL(rawUrl, 'http://localhost')\n    if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {\n      if (!allowHosts.has(parsed.hostname)) {\n        throw new Error(`Network blocked in tests: ${parsed.hostname}`)\n      }\n    }\n    return await originalFetch(input, init)\n  }) as typeof fetch\n}\n"
  },
  {
    "path": "tests/setup/global-setup.ts",
    "content": "import { execSync } from 'node:child_process'\nimport { setTimeout as sleep } from 'node:timers/promises'\nimport mysql from 'mysql2/promise'\nimport Redis from 'ioredis'\nimport { loadTestEnv } from './env'\nimport { runGlobalTeardown } from './global-teardown'\n\nfunction parseDbUrl(dbUrl: string) {\n  const url = new URL(dbUrl)\n  return {\n    host: url.hostname,\n    port: Number(url.port || 3306),\n    user: decodeURIComponent(url.username),\n    password: decodeURIComponent(url.password),\n    database: url.pathname.replace(/^\\//, ''),\n  }\n}\n\nasync function waitForMysql(maxAttempts = 180) {\n  const db = parseDbUrl(process.env.DATABASE_URL || '')\n\n  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {\n    try {\n      const conn = await mysql.createConnection({\n        host: db.host,\n        port: db.port,\n        user: db.user,\n        password: db.password,\n        database: db.database,\n        connectTimeout: 5_000,\n      })\n      await conn.query('SELECT 1')\n      await conn.end()\n      return\n    } catch {\n      await sleep(1_000)\n    }\n  }\n\n  throw new Error('MySQL test service did not become ready in time')\n}\n\nasync function waitForRedis(maxAttempts = 60) {\n  const redis = new Redis({\n    host: process.env.REDIS_HOST || '127.0.0.1',\n    port: Number(process.env.REDIS_PORT || '6380'),\n    maxRetriesPerRequest: 1,\n    lazyConnect: true,\n  })\n\n  try {\n    for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {\n      try {\n        if (redis.status !== 'ready') {\n          await redis.connect()\n        }\n        const pong = await redis.ping()\n        if (pong === 'PONG') return\n      } catch {\n        await sleep(1_000)\n      }\n    }\n  } finally {\n    redis.disconnect()\n  }\n\n  throw new Error('Redis test service did not become ready in time')\n}\n\nexport default async function globalSetup() {\n  loadTestEnv()\n\n  const shouldBootstrap = process.env.BILLING_TEST_BOOTSTRAP === '1' || process.env.SYSTEM_TEST_BOOTSTRAP === '1'\n  if (!shouldBootstrap) {\n    return async () => {}\n  }\n\n  execSync('docker compose -f docker-compose.test.yml down -v --remove-orphans', {\n    cwd: process.cwd(),\n    stdio: 'inherit',\n  })\n\n  execSync('docker compose -f docker-compose.test.yml up -d --remove-orphans', {\n    cwd: process.cwd(),\n    stdio: 'inherit',\n  })\n\n  await waitForMysql()\n  await waitForRedis()\n\n  execSync('npx prisma db push --skip-generate --schema prisma/schema.prisma', {\n    cwd: process.cwd(),\n    stdio: 'inherit',\n  })\n\n  return async () => {\n    await runGlobalTeardown()\n  }\n}\n"
  },
  {
    "path": "tests/setup/global-teardown.ts",
    "content": "import { execSync } from 'node:child_process'\nimport { loadTestEnv } from './env'\n\nexport async function runGlobalTeardown() {\n  loadTestEnv()\n\n  const shouldBootstrap = process.env.BILLING_TEST_BOOTSTRAP === '1' || process.env.SYSTEM_TEST_BOOTSTRAP === '1'\n  if (!shouldBootstrap) return\n  if (process.env.BILLING_TEST_KEEP_SERVICES === '1') return\n\n  execSync('docker compose -f docker-compose.test.yml down -v --remove-orphans', {\n    cwd: process.cwd(),\n    stdio: 'inherit',\n  })\n}\n"
  },
  {
    "path": "tests/system/generate-image.system.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { callRoute } from '../integration/api/helpers/call-route'\nimport { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'\nimport { resetSystemState } from '../helpers/db-reset'\nimport { prisma } from '../helpers/prisma'\nimport { seedMinimalDomainState } from './helpers/seed'\nimport { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'\nimport { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'\n\nconst imageState = vi.hoisted(() => ({\n  mode: 'success' as 'success' | 'fatal',\n  cosKey: 'cos/system-image-generated.png',\n  errorMessage: 'IMAGE_GENERATION_FATAL',\n}))\n\nvi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(\n    '@/lib/workers/handlers/image-task-handler-shared',\n  )\n  return {\n    ...actual,\n    generateLabeledImageToCos: vi.fn(async () => {\n      if (imageState.mode === 'fatal') {\n        throw new Error(imageState.errorMessage)\n      }\n      return imageState.cosKey\n    }),\n  }\n})\n\nvi.mock('@/lib/media/outbound-image', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/media/outbound-image')>('@/lib/media/outbound-image')\n  return {\n    ...actual,\n    normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)),\n  }\n})\n\ndescribe('system - generate image', () => {\n  let workers: SystemWorkers = {}\n\n  beforeEach(async () => {\n    vi.resetModules()\n    vi.clearAllMocks()\n    imageState.mode = 'success'\n    imageState.cosKey = 'cos/system-image-generated.png'\n    imageState.errorMessage = 'IMAGE_GENERATION_FATAL'\n    await resetSystemState()\n    installAuthMocks()\n  })\n\n  afterEach(async () => {\n    await stopSystemWorkers(workers)\n    workers = {}\n    resetAuthMockState()\n  })\n\n  it('route -> queue -> worker -> db writes imageUrl and lifecycle events', async () => {\n    const seeded = await seedMinimalDomainState()\n    mockAuthenticated(seeded.user.id)\n    workers = await startSystemWorkers(['image'])\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')\n    const response = await callRoute(\n      mod.POST,\n      'POST',\n      {\n        locale: 'zh',\n        type: 'character',\n        id: seeded.character.id,\n        appearanceId: seeded.appearance.id,\n        count: 1,\n      },\n      { params: { projectId: seeded.project.id } },\n    )\n\n    expect(response.status).toBe(200)\n    const json = await response.json() as { async: boolean; taskId: string }\n    expect(json.async).toBe(true)\n    expect(typeof json.taskId).toBe('string')\n\n    const task = await waitForTaskTerminalState(json.taskId)\n    expect(task.status).toBe('completed')\n    expect(task.type).toBe('image_character')\n    expect(task.targetId).toBe(seeded.appearance.id)\n\n    const appearance = await prisma.characterAppearance.findUnique({\n      where: { id: seeded.appearance.id },\n      select: { imageUrl: true, imageUrls: true, selectedIndex: true },\n    })\n    expect(appearance).toEqual({\n      imageUrl: imageState.cosKey,\n      imageUrls: JSON.stringify([imageState.cosKey]),\n      selectedIndex: 0,\n    })\n\n    const eventTypes = await listTaskEventTypes(json.taskId)\n    expectLifecycleEvents(eventTypes, 'completed')\n  })\n\n  it('fatal provider path -> task fails and existing appearance images stay unchanged', async () => {\n    const seeded = await seedMinimalDomainState()\n    mockAuthenticated(seeded.user.id)\n    imageState.mode = 'fatal'\n    imageState.errorMessage = 'IMAGE_GENERATION_FATAL'\n    workers = await startSystemWorkers(['image'])\n\n    const originalAppearance = await prisma.characterAppearance.findUnique({\n      where: { id: seeded.appearance.id },\n      select: { imageUrl: true, imageUrls: true, selectedIndex: true },\n    })\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')\n    const response = await callRoute(\n      mod.POST,\n      'POST',\n      {\n        locale: 'zh',\n        type: 'character',\n        id: seeded.character.id,\n        appearanceId: seeded.appearance.id,\n        count: 1,\n      },\n      { params: { projectId: seeded.project.id } },\n    )\n\n    expect(response.status).toBe(200)\n    const json = await response.json() as { taskId: string }\n    const task = await waitForTaskTerminalState(json.taskId)\n    expect(task.status).toBe('failed')\n    expect(task.errorMessage).toContain('IMAGE_GENERATION_FATAL')\n\n    const appearance = await prisma.characterAppearance.findUnique({\n      where: { id: seeded.appearance.id },\n      select: { imageUrl: true, imageUrls: true, selectedIndex: true },\n    })\n    expect(appearance).toEqual(originalAppearance)\n\n    const eventTypes = await listTaskEventTypes(json.taskId)\n    expectLifecycleEvents(eventTypes, 'failed')\n  })\n})\n"
  },
  {
    "path": "tests/system/generate-video.system.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { callRoute } from '../integration/api/helpers/call-route'\nimport { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'\nimport { resetSystemState } from '../helpers/db-reset'\nimport { prisma } from '../helpers/prisma'\nimport { seedMinimalDomainState } from './helpers/seed'\nimport { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'\nimport { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'\n\ntype PollState = {\n  status: 'processing' | 'completed'\n  resultUrl?: string\n}\n\nconst videoState = vi.hoisted(() => ({\n  pollResponses: new Map<string, PollState[]>(),\n  uploadedCosKey: 'video/system-video.mp4',\n}))\n\nvi.mock('@/lib/generator-api', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/generator-api')>('@/lib/generator-api')\n  return {\n    ...actual,\n    generateVideo: vi.fn(async () => ({\n      success: true,\n      async: true,\n      externalId: 'video-ext-1',\n    })),\n  }\n})\n\nvi.mock('@/lib/async-poll', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/async-poll')>('@/lib/async-poll')\n  return {\n    ...actual,\n    pollAsyncTask: vi.fn(async (externalId: string) => {\n      const queue = videoState.pollResponses.get(externalId) || []\n      const next = queue.shift()\n      if (!next) {\n        return { status: 'completed', resultUrl: 'https://provider.example/video-final.mp4' }\n      }\n      videoState.pollResponses.set(externalId, queue)\n      return next\n    }),\n  }\n})\n\nvi.mock('@/lib/media/outbound-image', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/media/outbound-image')>('@/lib/media/outbound-image')\n  return {\n    ...actual,\n    normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),\n  }\n})\n\nvi.mock('@/lib/workers/utils', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/utils')>('@/lib/workers/utils')\n  return {\n    ...actual,\n    uploadVideoSourceToCos: vi.fn(async () => videoState.uploadedCosKey),\n  }\n})\n\ndescribe('system - generate video', () => {\n  let workers: SystemWorkers = {}\n\n  beforeEach(async () => {\n    vi.resetModules()\n    vi.clearAllMocks()\n    videoState.uploadedCosKey = 'video/system-video.mp4'\n    videoState.pollResponses.clear()\n    videoState.pollResponses.set('video-ext-1', [\n      { status: 'processing' },\n      { status: 'completed', resultUrl: 'https://provider.example/video-final.mp4' },\n    ])\n    await resetSystemState()\n    installAuthMocks()\n  })\n\n  afterEach(async () => {\n    await stopSystemWorkers(workers)\n    workers = {}\n    resetAuthMockState()\n  })\n\n  it('queued external generation -> polling -> videoUrl persisted', async () => {\n    const seeded = await seedMinimalDomainState()\n    mockAuthenticated(seeded.user.id)\n    workers = await startSystemWorkers(['video'])\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/generate-video/route')\n    const response = await callRoute(\n      mod.POST,\n      'POST',\n      {\n        locale: 'zh',\n        storyboardId: seeded.storyboard.id,\n        panelIndex: 0,\n        videoModel: 'fal::seedance/video',\n      },\n      { params: { projectId: seeded.project.id } },\n    )\n\n    expect(response.status).toBe(200)\n    const json = await response.json() as { async: boolean; taskId: string }\n    const task = await waitForTaskTerminalState(json.taskId)\n\n    expect(task.status).toBe('completed')\n    expect(task.type).toBe('video_panel')\n    expect(task.externalId).toBe('video-ext-1')\n\n    const panel = await prisma.novelPromotionPanel.findUnique({\n      where: { id: seeded.panel.id },\n      select: { videoUrl: true },\n    })\n    expect(panel?.videoUrl).toBe(videoState.uploadedCosKey)\n\n    const eventTypes = await listTaskEventTypes(json.taskId)\n    expectLifecycleEvents(eventTypes, 'completed')\n  })\n})\n"
  },
  {
    "path": "tests/system/helpers/seed.ts",
    "content": "import { randomUUID } from 'node:crypto'\nimport { prisma } from '../../helpers/prisma'\nimport {\n  createFixtureEpisode,\n  createFixtureNovelProject,\n  createFixtureProject,\n  createFixtureUser,\n} from '../../helpers/fixtures'\n\nfunction nextSuffix() {\n  return randomUUID().slice(0, 8)\n}\n\nexport async function seedMinimalDomainState() {\n  const user = await createFixtureUser()\n  const project = await createFixtureProject(user.id)\n  const novelProject = await createFixtureNovelProject(project.id)\n  const episode = await createFixtureEpisode(novelProject.id)\n\n  const clip = await prisma.novelPromotionClip.create({\n    data: {\n      episodeId: episode.id,\n      summary: 'seed clip',\n      content: 'seed clip content',\n      screenplay: 'seed screenplay',\n      location: 'Office',\n      characters: JSON.stringify(['Narrator']),\n    },\n  })\n\n  const storyboard = await prisma.novelPromotionStoryboard.create({\n    data: {\n      episodeId: episode.id,\n      clipId: clip.id,\n      panelCount: 1,\n    },\n  })\n\n  const panel = await prisma.novelPromotionPanel.create({\n    data: {\n      storyboardId: storyboard.id,\n      panelIndex: 0,\n      panelNumber: 1,\n      shotType: '中景',\n      cameraMove: '固定',\n      description: 'seed panel',\n      videoPrompt: 'seed video prompt',\n      location: 'Office',\n      characters: JSON.stringify(['Narrator']),\n      imageUrl: 'https://provider.example/panel.jpg',\n    },\n  })\n\n  const character = await prisma.novelPromotionCharacter.create({\n    data: {\n      novelPromotionProjectId: novelProject.id,\n      name: 'Narrator',\n    },\n  })\n\n  const appearance = await prisma.characterAppearance.create({\n    data: {\n      characterId: character.id,\n      appearanceIndex: 0,\n      changeReason: 'default',\n      description: 'Narrator appearance',\n      imageUrls: JSON.stringify(['images/character-seed.jpg']),\n      imageUrl: 'images/character-seed.jpg',\n      selectedIndex: 0,\n    },\n  })\n\n  const location = await prisma.novelPromotionLocation.create({\n    data: {\n      novelPromotionProjectId: novelProject.id,\n      name: 'Office',\n      summary: 'Office summary',\n    },\n  })\n\n  const locationImage = await prisma.locationImage.create({\n    data: {\n      locationId: location.id,\n      imageIndex: 0,\n      description: 'Office image',\n      imageUrl: 'images/location-seed.jpg',\n      isSelected: true,\n    },\n  })\n\n  const voiceLine = await prisma.novelPromotionVoiceLine.create({\n    data: {\n      episodeId: episode.id,\n      lineIndex: 1,\n      speaker: 'Narrator',\n      content: 'Hello world',\n      matchedPanelId: panel.id,\n      matchedStoryboardId: storyboard.id,\n      matchedPanelIndex: panel.panelIndex,\n    },\n  })\n\n  await prisma.novelPromotionEpisode.update({\n    where: { id: episode.id },\n    data: {\n      speakerVoices: JSON.stringify({\n        Narrator: {\n          provider: 'fal',\n          voiceType: 'uploaded',\n          audioUrl: 'https://provider.example/reference.wav',\n        },\n      }),\n    },\n  })\n\n  const secondaryPanel = await prisma.novelPromotionPanel.create({\n    data: {\n      storyboardId: storyboard.id,\n      panelIndex: 1,\n      panelNumber: 2,\n      shotType: '近景',\n      cameraMove: '推镜',\n      description: 'secondary panel',\n      videoPrompt: 'secondary prompt',\n      location: 'Office',\n      characters: JSON.stringify(['Narrator']),\n    },\n  })\n\n  await prisma.novelPromotionStoryboard.update({\n    where: { id: storyboard.id },\n    data: { panelCount: 2 },\n  })\n\n  const foreignStoryboard = await prisma.novelPromotionStoryboard.create({\n    data: {\n      episodeId: episode.id,\n      clipId: (await prisma.novelPromotionClip.create({\n        data: {\n          episodeId: episode.id,\n          summary: 'foreign clip',\n          content: 'foreign clip content',\n          screenplay: 'foreign screenplay',\n          location: 'Office',\n          characters: JSON.stringify(['Narrator']),\n        },\n      })).id,\n      panelCount: 1,\n    },\n  })\n\n  const foreignPanel = await prisma.novelPromotionPanel.create({\n    data: {\n      id: `panel-foreign-${nextSuffix()}`,\n      storyboardId: foreignStoryboard.id,\n      panelIndex: 0,\n      panelNumber: 1,\n      shotType: '远景',\n      cameraMove: '固定',\n      description: 'foreign panel',\n      videoPrompt: 'foreign prompt',\n      location: 'Office',\n      characters: JSON.stringify(['Narrator']),\n    },\n  })\n\n  return {\n    user,\n    project,\n    novelProject,\n    episode,\n    clip,\n    storyboard,\n    panel,\n    secondaryPanel,\n    foreignStoryboard,\n    foreignPanel,\n    character,\n    appearance,\n    location,\n    locationImage,\n    voiceLine,\n  }\n}\n"
  },
  {
    "path": "tests/system/helpers/tasks.ts",
    "content": "import { TASK_EVENT_TYPE, TASK_STATUS, type TaskEventType, type TaskStatus } from '@/lib/task/types'\nimport { expect } from 'vitest'\nimport { prisma } from '../../helpers/prisma'\n\ntype WaitTaskOptions = {\n  timeoutMs?: number\n  intervalMs?: number\n}\n\nconst TERMINAL_STATUSES = new Set<TaskStatus>([\n  TASK_STATUS.COMPLETED,\n  TASK_STATUS.FAILED,\n  TASK_STATUS.CANCELED,\n  TASK_STATUS.DISMISSED,\n])\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\nexport async function waitForTaskTerminalState(taskId: string, options: WaitTaskOptions = {}) {\n  const timeoutMs = options.timeoutMs ?? 15_000\n  const intervalMs = options.intervalMs ?? 100\n  const startedAt = Date.now()\n\n  while (Date.now() - startedAt <= timeoutMs) {\n    const task = await prisma.task.findUnique({\n      where: { id: taskId },\n    })\n    if (task && TERMINAL_STATUSES.has(task.status as TaskStatus)) {\n      return task\n    }\n    await sleep(intervalMs)\n  }\n\n  throw new Error(`TASK_WAIT_TIMEOUT: ${taskId}`)\n}\n\nexport async function listTaskEventTypes(taskId: string): Promise<TaskEventType[]> {\n  const events = await prisma.taskEvent.findMany({\n    where: { taskId },\n    orderBy: { createdAt: 'asc' },\n    select: { eventType: true },\n  })\n  return events.map((event) => event.eventType as TaskEventType)\n}\n\nexport function expectLifecycleEvents(types: ReadonlyArray<TaskEventType>, terminal: 'completed' | 'failed') {\n  const expectedTerminal = terminal === 'completed' ? TASK_EVENT_TYPE.COMPLETED : TASK_EVENT_TYPE.FAILED\n  expect(types).toContain(TASK_EVENT_TYPE.CREATED)\n  expect(types).toContain(TASK_EVENT_TYPE.PROCESSING)\n  expect(types).toContain(expectedTerminal)\n}\n"
  },
  {
    "path": "tests/system/helpers/workers.ts",
    "content": "import type { Worker } from 'bullmq'\nimport type { TaskJobData } from '@/lib/task/types'\n\nexport type SystemWorkerScope = 'image' | 'video' | 'voice' | 'text'\n\nexport type SystemWorkers = Partial<Record<SystemWorkerScope, Worker<TaskJobData>>>\n\nasync function createWorker(scope: SystemWorkerScope): Promise<Worker<TaskJobData>> {\n  if (scope === 'image') {\n    const mod = await import('@/lib/workers/image.worker')\n    return mod.createImageWorker()\n  }\n  if (scope === 'video') {\n    const mod = await import('@/lib/workers/video.worker')\n    return mod.createVideoWorker()\n  }\n  if (scope === 'voice') {\n    const mod = await import('@/lib/workers/voice.worker')\n    return mod.createVoiceWorker()\n  }\n  const mod = await import('@/lib/workers/text.worker')\n  return mod.createTextWorker()\n}\n\nexport async function startSystemWorkers(scopes: ReadonlyArray<SystemWorkerScope>): Promise<SystemWorkers> {\n  const started: SystemWorkers = {}\n  for (const scope of scopes) {\n    const worker = await createWorker(scope)\n    await worker.waitUntilReady()\n    started[scope] = worker\n  }\n  return started\n}\n\nexport async function stopSystemWorkers(workers: SystemWorkers): Promise<void> {\n  for (const worker of Object.values(workers)) {\n    if (!worker) continue\n    await worker.close()\n  }\n}\n"
  },
  {
    "path": "tests/system/text-workflow.system.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { callRoute } from '../integration/api/helpers/call-route'\nimport { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'\nimport { resetSystemState } from '../helpers/db-reset'\nimport { prisma } from '../helpers/prisma'\nimport { seedMinimalDomainState } from './helpers/seed'\nimport { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'\nimport { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'\nimport { createFixtureEpisode, createFixtureNovelProject, createFixtureProject, createFixtureUser } from '../helpers/fixtures'\n\ntype FakeAiResult = {\n  text: string\n  reasoning?: string\n}\n\ntype FakeVoiceLineRow = {\n  lineIndex: number\n  speaker: string\n  content: string\n  emotionStrength: number\n  matchedPanel: {\n    storyboardId: string\n    panelIndex: number\n  }\n}\n\nconst textState = vi.hoisted(() => ({\n  aiResults: [] as FakeAiResult[],\n  voiceLineResults: [] as FakeVoiceLineRow[],\n  parseFailureCount: 0,\n  orchestratorClipId: 'clip-seed',\n}))\n\nvi.mock('@/lib/ai-runtime', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/ai-runtime')>('@/lib/ai-runtime')\n  return {\n    ...actual,\n    executeAiTextStep: vi.fn(async () => {\n      const next = textState.aiResults.shift()\n      if (!next) {\n        return {\n          text: '{\"ok\":true}',\n          reasoning: '',\n          usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },\n          completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },\n        }\n      }\n      return {\n        text: next.text,\n        reasoning: next.reasoning || '',\n        usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },\n        completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },\n      }\n    }),\n  }\n})\n\nvi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/novel-promotion/script-to-storyboard/orchestrator')>(\n    '@/lib/novel-promotion/script-to-storyboard/orchestrator',\n  )\n  return {\n    ...actual,\n    runScriptToStoryboardOrchestrator: vi.fn(async () => ({\n      clipPanels: [\n        {\n          clipId: textState.orchestratorClipId,\n          panels: [\n            {\n              panelIndex: 1,\n              shotType: 'close-up',\n              cameraMove: 'static',\n              description: 'system generated panel',\n              videoPrompt: 'system video prompt',\n              location: 'Office',\n              characters: ['Narrator'],\n            },\n          ],\n        },\n      ],\n      summary: {\n        totalPanelCount: 1,\n        totalStepCount: 4,\n      },\n    })),\n  }\n})\n\nvi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/script-to-storyboard-helpers')>(\n    '@/lib/workers/handlers/script-to-storyboard-helpers',\n  )\n  return {\n    ...actual,\n    parseVoiceLinesJson: vi.fn(() => {\n      if (textState.parseFailureCount > 0) {\n        textState.parseFailureCount -= 1\n        throw new Error('invalid voice json')\n      }\n      return textState.voiceLineResults\n    }),\n    persistStoryboardsAndPanels: vi.fn(async (input: { episodeId: string }) => {\n      const clip = await prisma.novelPromotionClip.findFirst({\n        where: { episodeId: input.episodeId },\n        orderBy: { createdAt: 'asc' },\n      })\n      if (!clip) {\n        throw new Error(`TEST_CLIP_NOT_FOUND: ${input.episodeId}`)\n      }\n      const storyboard = await prisma.novelPromotionStoryboard.create({\n        data: {\n          id: 'storyboard-1',\n          episodeId: input.episodeId,\n          clipId: clip.id,\n          panelCount: 1,\n        },\n      })\n      const panel = await prisma.novelPromotionPanel.create({\n        data: {\n          id: 'panel-1',\n          storyboardId: storyboard.id,\n          panelIndex: 1,\n          panelNumber: 1,\n          shotType: 'close-up',\n          cameraMove: 'static',\n          description: 'system generated panel',\n          videoPrompt: 'system video prompt',\n          location: 'Office',\n          characters: JSON.stringify(['Narrator']),\n        },\n      })\n      return [{ storyboardId: storyboard.id, panels: [{ id: panel.id, panelIndex: 1 }] }]\n    }),\n  }\n})\n\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\n\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    onStage: vi.fn(),\n    onChunk: vi.fn(),\n    onComplete: vi.fn(),\n    onError: vi.fn(),\n    flush: vi.fn(async () => undefined),\n  })),\n}))\n\nasync function seedScriptToStoryboardState() {\n  const user = await createFixtureUser()\n  const project = await createFixtureProject(user.id)\n  const novelProject = await createFixtureNovelProject(project.id)\n  const episode = await createFixtureEpisode(novelProject.id)\n  const clip = await prisma.novelPromotionClip.create({\n    data: {\n      episodeId: episode.id,\n      summary: 'script clip',\n      content: 'clip content',\n      screenplay: 'screenplay text',\n      location: 'Office',\n      characters: JSON.stringify(['Narrator']),\n    },\n  })\n  await prisma.novelPromotionCharacter.create({\n    data: {\n      novelPromotionProjectId: novelProject.id,\n      name: 'Narrator',\n    },\n  })\n  await prisma.novelPromotionLocation.create({\n    data: {\n      novelPromotionProjectId: novelProject.id,\n      name: 'Office',\n      summary: 'Office',\n    },\n  })\n  textState.orchestratorClipId = clip.id\n  return { user, project, novelProject, episode, clip }\n}\n\ndescribe('system - text workflows', () => {\n  let workers: SystemWorkers = {}\n\n  beforeEach(async () => {\n    vi.resetModules()\n    vi.clearAllMocks()\n    textState.aiResults = []\n    textState.voiceLineResults = []\n    textState.parseFailureCount = 0\n    textState.orchestratorClipId = 'clip-seed'\n    await resetSystemState()\n    installAuthMocks()\n  })\n\n  afterEach(async () => {\n    await stopSystemWorkers(workers)\n    workers = {}\n    resetAuthMockState()\n  })\n\n  it('script-to-storyboard success -> persists storyboard/panel/voiceLine and completes task', async () => {\n    const seeded = await seedScriptToStoryboardState()\n    mockAuthenticated(seeded.user.id)\n    textState.aiResults = [{ text: 'voice-lines-json' }]\n    textState.voiceLineResults = [\n      {\n        lineIndex: 1,\n        speaker: 'Narrator',\n        content: 'Hello world',\n        emotionStrength: 0.8,\n        matchedPanel: {\n          storyboardId: 'storyboard-1',\n          panelIndex: 1,\n        },\n      },\n    ]\n    workers = await startSystemWorkers(['text'])\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')\n    const response = await callRoute(\n      mod.POST,\n      'POST',\n      { locale: 'zh', episodeId: seeded.episode.id },\n      { params: { projectId: seeded.project.id } },\n    )\n\n    expect(response.status).toBe(200)\n    const json = await response.json() as { taskId: string }\n    const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })\n    expect(task.status).toBe('completed')\n    expect(task.type).toBe('script_to_storyboard_run')\n    expect(task.result).toEqual(expect.objectContaining({\n      episodeId: seeded.episode.id,\n      panelCount: 1,\n      voiceLineCount: 1,\n    }))\n\n    const storyboards = await prisma.novelPromotionStoryboard.findMany({\n      where: { episodeId: seeded.episode.id },\n      select: { id: true, panelCount: true },\n    })\n    expect(storyboards.length).toBeGreaterThan(0)\n\n    const persistedVoiceLines = await prisma.novelPromotionVoiceLine.findMany({\n      where: { episodeId: seeded.episode.id },\n      orderBy: { lineIndex: 'asc' },\n      select: {\n        lineIndex: true,\n        speaker: true,\n        content: true,\n        matchedPanelId: true,\n        matchedPanelIndex: true,\n      },\n    })\n    expect(persistedVoiceLines).toEqual([\n      {\n        lineIndex: 1,\n        speaker: 'Narrator',\n        content: 'Hello world',\n        matchedPanelId: expect.any(String),\n        matchedPanelIndex: 1,\n      },\n    ])\n\n    const eventTypes = await listTaskEventTypes(json.taskId)\n    expectLifecycleEvents(eventTypes, 'completed')\n  })\n\n  it('script-to-storyboard parse retry -> second attempt succeeds', async () => {\n    const seeded = await seedScriptToStoryboardState()\n    mockAuthenticated(seeded.user.id)\n    textState.aiResults = [\n      { text: 'invalid-voice-json' },\n      { text: 'valid-voice-json' },\n    ]\n    textState.voiceLineResults = [\n      {\n        lineIndex: 1,\n        speaker: 'Narrator',\n        content: 'Retry success',\n        emotionStrength: 0.4,\n        matchedPanel: {\n          storyboardId: 'storyboard-1',\n          panelIndex: 1,\n        },\n      },\n    ]\n    textState.parseFailureCount = 1\n    workers = await startSystemWorkers(['text'])\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')\n    const response = await callRoute(\n      mod.POST,\n      'POST',\n      { locale: 'zh', episodeId: seeded.episode.id },\n      { params: { projectId: seeded.project.id } },\n    )\n\n    const json = await response.json() as { taskId: string }\n    const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })\n    expect(task.status).toBe('completed')\n    expect(task.result).toEqual(expect.objectContaining({\n      voiceLineCount: 1,\n    }))\n\n    const voiceLines = await prisma.novelPromotionVoiceLine.findMany({\n      where: { episodeId: seeded.episode.id },\n      select: { content: true },\n    })\n    expect(voiceLines).toEqual([{ content: 'Retry success' }])\n  })\n\n  it('insert-panel invalid ai payload -> task fails and no dirty panel remains', async () => {\n    const seeded = await seedMinimalDomainState()\n    mockAuthenticated(seeded.user.id)\n    textState.aiResults = [{ text: 'not-json' }]\n    workers = await startSystemWorkers(['text'])\n\n    const beforeCount = await prisma.novelPromotionPanel.count({\n      where: { storyboardId: seeded.storyboard.id },\n    })\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/insert-panel/route')\n    const response = await callRoute(\n      mod.POST,\n      'POST',\n      {\n        locale: 'zh',\n        storyboardId: seeded.storyboard.id,\n        insertAfterPanelId: seeded.panel.id,\n      },\n      { params: { projectId: seeded.project.id } },\n    )\n\n    expect(response.status).toBe(200)\n    const json = await response.json() as { taskId: string }\n    const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })\n    expect(task.status).toBe('failed')\n\n    const afterCount = await prisma.novelPromotionPanel.count({\n      where: { storyboardId: seeded.storyboard.id },\n    })\n    expect(afterCount).toBe(beforeCount)\n\n    const eventTypes = await listTaskEventTypes(json.taskId)\n    expectLifecycleEvents(eventTypes, 'failed')\n  })\n})\n"
  },
  {
    "path": "tests/system/voice-generate.system.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\nimport { callRoute } from '../integration/api/helpers/call-route'\nimport { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'\nimport { resetSystemState } from '../helpers/db-reset'\nimport { prisma } from '../helpers/prisma'\nimport { seedMinimalDomainState } from './helpers/seed'\nimport { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'\nimport { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'\n\nconst voiceState = vi.hoisted(() => ({\n  audioUrl: 'voice/system-line.wav',\n  audioDuration: 1200,\n}))\n\nvi.mock('@/lib/api-config', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/api-config')>('@/lib/api-config')\n  return {\n    ...actual,\n    resolveModelSelectionOrSingle: vi.fn(async () => ({\n      provider: 'fal',\n      modelId: 'fal-audio-model',\n      modelKey: 'fal::audio-model',\n      mediaType: 'audio',\n    })),\n    getProviderKey: vi.fn((providerId: string) => providerId),\n  }\n})\n\nvi.mock('@/lib/voice/generate-voice-line', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/voice/generate-voice-line')>('@/lib/voice/generate-voice-line')\n  return {\n    ...actual,\n    generateVoiceLine: vi.fn(async (params: {\n      lineId: string\n    }) => {\n      await prisma.novelPromotionVoiceLine.update({\n        where: { id: params.lineId },\n        data: {\n          audioUrl: voiceState.audioUrl,\n          audioDuration: voiceState.audioDuration,\n        },\n      })\n      return {\n        lineId: params.lineId,\n        audioUrl: voiceState.audioUrl,\n        storageKey: voiceState.audioUrl,\n        audioDuration: voiceState.audioDuration,\n      }\n    }),\n  }\n})\n\ndescribe('system - voice generate', () => {\n  let workers: SystemWorkers = {}\n\n  beforeEach(async () => {\n    vi.resetModules()\n    vi.clearAllMocks()\n    voiceState.audioUrl = 'voice/system-line.wav'\n    voiceState.audioDuration = 1200\n    await resetSystemState()\n    installAuthMocks()\n  })\n\n  afterEach(async () => {\n    await stopSystemWorkers(workers)\n    workers = {}\n    resetAuthMockState()\n  })\n\n  it('route -> voice worker -> line audio persisted', async () => {\n    const seeded = await seedMinimalDomainState()\n    mockAuthenticated(seeded.user.id)\n    workers = await startSystemWorkers(['voice'])\n\n    const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')\n    const response = await callRoute(\n      mod.POST,\n      'POST',\n      {\n        locale: 'zh',\n        episodeId: seeded.episode.id,\n        lineId: seeded.voiceLine.id,\n        audioModel: 'fal::audio-model',\n      },\n      { params: { projectId: seeded.project.id } },\n    )\n\n    expect(response.status).toBe(200)\n    const json = await response.json() as { success: boolean; async: boolean; taskId: string }\n    expect(json.success).toBe(true)\n    const task = await waitForTaskTerminalState(json.taskId)\n    expect(task.status).toBe('completed')\n    expect(task.type).toBe('voice_line')\n\n    const voiceLine = await prisma.novelPromotionVoiceLine.findUnique({\n      where: { id: seeded.voiceLine.id },\n      select: { audioUrl: true, audioDuration: true },\n    })\n    expect(voiceLine).toEqual({\n      audioUrl: voiceState.audioUrl,\n      audioDuration: voiceState.audioDuration,\n    })\n\n    const eventTypes = await listTaskEventTypes(json.taskId)\n    expectLifecycleEvents(eventTypes, 'completed')\n  })\n})\n"
  },
  {
    "path": "tests/unit/ai-runtime/errors.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { toAiRuntimeError } from '@/lib/ai-runtime/errors'\n\ndescribe('toAiRuntimeError empty response mapping', () => {\n  it('maps nested Gemini empty response signal to EMPTY_RESPONSE even when status is 429', () => {\n    const upstreamError = new Error('Too Many Requests') as Error & {\n      status?: number\n      cause?: unknown\n    }\n    upstreamError.status = 429\n    upstreamError.cause = {\n      error: {\n        message: 'received empty response from Gemini: no meaningful content in candidates (request id: x)',\n        type: 'channel_error',\n        code: 'channel:empty_response',\n      },\n      code: 429,\n      status: 'Too Many Requests',\n    }\n\n    const runtimeError = toAiRuntimeError(upstreamError)\n    expect(runtimeError.code).toBe('EMPTY_RESPONSE')\n    expect(runtimeError.retryable).toBe(true)\n  })\n\n  it('keeps RATE_LIMIT when there is no empty response signal', () => {\n    const runtimeError = toAiRuntimeError({\n      status: 429,\n      message: 'Too Many Requests',\n    })\n    expect(runtimeError.code).toBe('RATE_LIMIT')\n    expect(runtimeError.retryable).toBe(true)\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/assistant-chat-modal-content.test.ts",
    "content": "import type { UIMessage } from 'ai'\nimport { describe, expect, it } from 'vitest'\nimport { extractMessageContent } from '@/components/assistant/AssistantChatModal'\n\nfunction createAssistantMessage(parts: Array<Record<string, unknown>>): UIMessage {\n  return {\n    id: 'assistant-message',\n    role: 'assistant',\n    parts,\n  } as unknown as UIMessage\n}\n\ndescribe('assistant chat modal message content parser', () => {\n  it('keeps reasoning parts out of normal visible lines', () => {\n    const message = createAssistantMessage([\n      { type: 'reasoning', text: '先分析接口字段映射' },\n      { type: 'text', text: '我需要你的 status 返回样例。' },\n    ])\n\n    const content = extractMessageContent(message)\n\n    expect(content.lines).toEqual(['我需要你的 status 返回样例。'])\n    expect(content.reasoningLines).toEqual(['先分析接口字段映射'])\n  })\n\n  it('extracts think tags from text into reasoning section', () => {\n    const message = createAssistantMessage([\n      {\n        type: 'text',\n        text: '<think>先确认 create/status/content 三个端点</think>请补充 status 返回 JSON',\n      },\n    ])\n\n    const content = extractMessageContent(message)\n\n    expect(content.lines).toEqual(['请补充 status 返回 JSON'])\n    expect(content.reasoningLines).toEqual(['先确认 create/status/content 三个端点'])\n  })\n\n  it('extracts reasoning from unclosed think tag during streaming', () => {\n    const message = createAssistantMessage([\n      {\n        type: 'text',\n        text: '<think>先确认任务状态枚举和输出路径',\n      },\n    ])\n\n    const content = extractMessageContent(message)\n\n    expect(content.lines).toEqual([])\n    expect(content.reasoningLines).toEqual(['先确认任务状态枚举和输出路径'])\n  })\n\n  it('preserves tool output and issues as visible lines', () => {\n    const message = createAssistantMessage([\n      {\n        type: 'tool-saveModelTemplate',\n        state: 'output-available',\n        output: {\n          message: '模型已保存',\n          issues: [{ field: 'response.statusPath', message: 'missing' }],\n        },\n      },\n    ])\n\n    const content = extractMessageContent(message)\n\n    expect(content.lines).toEqual(['模型已保存', 'response.statusPath: missing'])\n    expect(content.reasoningLines).toEqual([])\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/minimax-preset.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { PRESET_MODELS, PRESET_PROVIDERS } from '@/app/[locale]/profile/components/api-config/types'\n\ndescribe('api-config minimax preset', () => {\n  it('uses official minimax baseUrl in preset provider', () => {\n    const minimaxProvider = PRESET_PROVIDERS.find((provider) => provider.id === 'minimax')\n    expect(minimaxProvider).toBeDefined()\n    expect(minimaxProvider?.baseUrl).toBe('https://api.minimaxi.com/v1')\n  })\n\n  it('includes all required minimax official llm preset models', () => {\n    const minimaxLlmModelIds = PRESET_MODELS\n      .filter((model) => model.provider === 'minimax' && model.type === 'llm')\n      .map((model) => model.modelId)\n\n    expect(minimaxLlmModelIds).toContain('MiniMax-M2.5')\n    expect(minimaxLlmModelIds).toContain('MiniMax-M2.5-highspeed')\n    expect(minimaxLlmModelIds).toContain('MiniMax-M2.1')\n    expect(minimaxLlmModelIds).toContain('MiniMax-M2.1-highspeed')\n    expect(minimaxLlmModelIds).toContain('MiniMax-M2')\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/preset-coming-soon.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  PRESET_MODELS,\n  encodeModelKey,\n  isPresetComingSoonModel,\n  isPresetComingSoonModelKey,\n} from '@/app/[locale]/profile/components/api-config/types'\n\ndescribe('api-config preset coming soon', () => {\n  it('registers Nano Banana 2 under Google AI Studio presets', () => {\n    const model = PRESET_MODELS.find(\n      (entry) => entry.provider === 'google' && entry.modelId === 'gemini-3.1-flash-image-preview',\n    )\n    expect(model).toBeDefined()\n    expect(model?.name).toBe('Nano Banana 2')\n  })\n\n  it('registers Seedance 2.0 as a coming-soon preset model', () => {\n    const model = PRESET_MODELS.find(\n      (entry) => entry.provider === 'ark' && entry.modelId === 'doubao-seedance-2-0-260128',\n    )\n    expect(model).toBeDefined()\n    expect(model?.name).toContain('待上线')\n  })\n\n  it('recognizes coming-soon model by provider/modelId and modelKey', () => {\n    const modelKey = encodeModelKey('ark', 'doubao-seedance-2-0-260128')\n    expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-260128')).toBe(true)\n    expect(isPresetComingSoonModelKey(modelKey)).toBe(true)\n  })\n\n  it('does not mark normal preset models as coming soon', () => {\n    const modelKey = encodeModelKey('ark', 'doubao-seedance-1-5-pro-251215')\n    expect(isPresetComingSoonModel('ark', 'doubao-seedance-1-5-pro-251215')).toBe(false)\n    expect(isPresetComingSoonModelKey(modelKey)).toBe(false)\n  })\n\n  it('registers Bailian Wan i2v preset models', () => {\n    const modelIds = PRESET_MODELS\n      .filter((entry) => entry.provider === 'bailian' && entry.type === 'video')\n      .map((entry) => entry.modelId)\n\n    expect(modelIds).toEqual(expect.arrayContaining([\n      'wan2.6-i2v-flash',\n      'wan2.6-i2v',\n      'wan2.5-i2v-preview',\n      'wan2.2-i2v-plus',\n      'wan2.2-kf2v-flash',\n      'wanx2.1-kf2v-plus',\n    ]))\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/provider-card-assistant-saved-label.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { getAssistantSavedModelLabel } from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'\n\ndescribe('provider card assistant saved label', () => {\n  it('prefers draft model name when available', () => {\n    const label = getAssistantSavedModelLabel({\n      savedModelKey: 'openai-compatible:oa-1::veo_3_1-fast-4K',\n      draftModel: {\n        modelId: 'veo_3_1-fast-4K',\n        name: 'Veo 3.1 Fast 4K',\n        type: 'video',\n        provider: 'openai-compatible:oa-1',\n        compatMediaTemplate: {\n          version: 1,\n          mediaType: 'video',\n          mode: 'async',\n          create: {\n            method: 'POST',\n            path: '/v1/video/create',\n          },\n          status: {\n            method: 'GET',\n            path: '/v1/video/query?id={{task_id}}',\n          },\n          response: {\n            taskIdPath: '$.id',\n            statusPath: '$.status',\n          },\n          polling: {\n            intervalMs: 5000,\n            timeoutMs: 600000,\n            doneStates: ['completed'],\n            failStates: ['failed'],\n          },\n        },\n      },\n    })\n\n    expect(label).toBe('Veo 3.1 Fast 4K')\n  })\n\n  it('falls back to model id parsed from savedModelKey', () => {\n    const label = getAssistantSavedModelLabel({\n      savedModelKey: 'openai-compatible:oa-1::veo_3_1-fast-4K',\n    })\n\n    expect(label).toBe('veo_3_1-fast-4K')\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/provider-card-pricing-form.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  getAddableModelTypesForProvider,\n  getVisibleModelTypesForProvider,\n  shouldShowOpenAICompatVideoHint,\n} from '@/app/[locale]/profile/components/api-config/provider-card/ProviderAdvancedFields'\nimport {\n  buildCustomPricingFromModelForm,\n  buildProviderConnectionPayload,\n} from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'\n\ndescribe('provider card pricing form behavior', () => {\n  it('allows openai-compatible provider to add llm/image/video', () => {\n    expect(getAddableModelTypesForProvider('openai-compatible:oa-1')).toEqual(['llm', 'image', 'video'])\n  })\n\n  it('shows llm/image/video tabs by default for openai-compatible even with only image models', () => {\n    const visible = getVisibleModelTypesForProvider(\n      'openai-compatible:oa-1',\n      {\n        image: [\n          {\n            modelId: 'gpt-image-1',\n            modelKey: 'openai-compatible:oa-1::gpt-image-1',\n            name: 'Image',\n            type: 'image',\n            provider: 'openai-compatible:oa-1',\n            price: 0,\n            enabled: true,\n          },\n        ],\n      },\n    )\n\n    expect(visible).toEqual(['llm', 'image', 'video'])\n  })\n\n  it('shows the openai-compatible video hint only for openai-compatible video add forms', () => {\n    expect(shouldShowOpenAICompatVideoHint('openai-compatible:oa-1', 'video')).toBe(true)\n    expect(shouldShowOpenAICompatVideoHint('openai-compatible:oa-1', 'image')).toBe(false)\n    expect(shouldShowOpenAICompatVideoHint('gemini-compatible:gm-1', 'video')).toBe(false)\n    expect(shouldShowOpenAICompatVideoHint('ark', 'video')).toBe(false)\n  })\n\n  it('keeps payload without customPricing when pricing toggle is off', () => {\n    const result = buildCustomPricingFromModelForm(\n      'image',\n      {\n        name: 'Image',\n        modelId: 'gpt-image-1',\n        enableCustomPricing: false,\n        basePrice: '0.8',\n      },\n      { needsCustomPricing: true },\n    )\n\n    expect(result).toEqual({ ok: true })\n  })\n\n  it('builds llm customPricing payload when pricing toggle is on', () => {\n    const result = buildCustomPricingFromModelForm(\n      'llm',\n      {\n        name: 'GPT',\n        modelId: 'gpt-4.1',\n        enableCustomPricing: true,\n        priceInput: '2.5',\n        priceOutput: '8',\n      },\n      { needsCustomPricing: true },\n    )\n\n    expect(result).toEqual({\n      ok: true,\n      customPricing: {\n        llm: {\n          inputPerMillion: 2.5,\n          outputPerMillion: 8,\n        },\n      },\n    })\n  })\n\n  it('builds media customPricing payload with option prices when enabled', () => {\n    const result = buildCustomPricingFromModelForm(\n      'video',\n      {\n        name: 'Sora',\n        modelId: 'sora-2',\n        enableCustomPricing: true,\n        basePrice: '0.9',\n        optionPricesJson: '{\"resolution\":{\"720x1280\":0.1},\"duration\":{\"8\":0.4}}',\n      },\n      { needsCustomPricing: true },\n    )\n\n    expect(result).toEqual({\n      ok: true,\n      customPricing: {\n        video: {\n          basePrice: 0.9,\n          optionPrices: {\n            resolution: {\n              '720x1280': 0.1,\n            },\n            duration: {\n              '8': 0.4,\n            },\n          },\n        },\n      },\n    })\n  })\n\n  it('rejects invalid media optionPrices JSON when enabled', () => {\n    const result = buildCustomPricingFromModelForm(\n      'image',\n      {\n        name: 'Image',\n        modelId: 'gpt-image-1',\n        enableCustomPricing: true,\n        basePrice: '0.3',\n        optionPricesJson: '{\"resolution\":{\"1024x1024\":\"free\"}}',\n      },\n      { needsCustomPricing: true },\n    )\n\n    expect(result).toEqual({ ok: false, reason: 'invalid' })\n  })\n\n  it('bugfix: includes baseUrl for openai-compatible provider connection test payload', () => {\n    const payload = buildProviderConnectionPayload({\n      providerKey: 'openai-compatible',\n      apiKey: ' sk-test ',\n      baseUrl: ' https://api.openai-proxy.example/v1 ',\n    })\n\n    expect(payload).toEqual({\n      apiType: 'openai-compatible',\n      apiKey: 'sk-test',\n      baseUrl: 'https://api.openai-proxy.example/v1',\n    })\n  })\n\n  it('omits baseUrl for non-compatible provider connection test payload', () => {\n    const payload = buildProviderConnectionPayload({\n      providerKey: 'ark',\n      apiKey: ' ark-key ',\n      baseUrl: ' https://ignored.example/v1 ',\n    })\n\n    expect(payload).toEqual({\n      apiType: 'ark',\n      apiKey: 'ark-key',\n    })\n  })\n\n  it('includes llmModel in provider connection test payload when configured', () => {\n    const payload = buildProviderConnectionPayload({\n      providerKey: 'openai-compatible',\n      apiKey: ' sk-test ',\n      baseUrl: ' https://compat.example.com/v1 ',\n      llmModel: ' gpt-4.1-mini ',\n    })\n\n    expect(payload).toEqual({\n      apiType: 'openai-compatible',\n      apiKey: 'sk-test',\n      baseUrl: 'https://compat.example.com/v1',\n      llmModel: 'gpt-4.1-mini',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/provider-card-protocol-probe.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { CustomModel } from '@/app/[locale]/profile/components/api-config/types'\nimport {\n  probeModelLlmProtocolViaApi,\n  shouldProbeModelLlmProtocol,\n  shouldReprobeModelLlmProtocol,\n} from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'\n\ndescribe('api-config provider-card protocol probe helpers', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('only probes openai-compatible llm models', () => {\n    expect(shouldProbeModelLlmProtocol({ providerId: 'openai-compatible:oa-1', modelType: 'llm' })).toBe(true)\n    expect(shouldProbeModelLlmProtocol({ providerId: 'openai-compatible:oa-1', modelType: 'image' })).toBe(false)\n    expect(shouldProbeModelLlmProtocol({ providerId: 'gemini-compatible:gm-1', modelType: 'llm' })).toBe(false)\n  })\n\n  it('re-probes only when modelId/provider changed on openai-compatible llm', () => {\n    const originalModel: CustomModel = {\n      modelId: 'gpt-4.1-mini',\n      modelKey: 'openai-compatible:oa-1::gpt-4.1-mini',\n      name: 'GPT 4.1 Mini',\n      type: 'llm',\n      provider: 'openai-compatible:oa-1',\n      llmProtocol: 'chat-completions',\n      llmProtocolCheckedAt: '2026-01-01T00:00:00.000Z',\n      price: 0,\n      enabled: true,\n    }\n\n    expect(shouldReprobeModelLlmProtocol({\n      providerId: 'openai-compatible:oa-1',\n      originalModel,\n      nextModelId: 'gpt-4.1-mini',\n    })).toBe(false)\n\n    expect(shouldReprobeModelLlmProtocol({\n      providerId: 'openai-compatible:oa-1',\n      originalModel,\n      nextModelId: 'gpt-4.1',\n    })).toBe(true)\n\n    expect(shouldReprobeModelLlmProtocol({\n      providerId: 'gemini-compatible:gm-1',\n      originalModel,\n      nextModelId: 'gpt-4.1',\n    })).toBe(false)\n  })\n\n  it('parses successful probe response payload', async () => {\n    const fetchMock = vi.fn(async () => new Response(JSON.stringify({\n      success: true,\n      protocol: 'responses',\n      checkedAt: '2026-03-05T10:00:00.000Z',\n    }), { status: 200 }))\n    vi.stubGlobal('fetch', fetchMock)\n\n    const result = await probeModelLlmProtocolViaApi({\n      providerId: 'openai-compatible:oa-1',\n      modelId: 'gpt-4.1-mini',\n    })\n\n    expect(result).toEqual({\n      llmProtocol: 'responses',\n      llmProtocolCheckedAt: '2026-03-05T10:00:00.000Z',\n    })\n  })\n\n  it('throws probe failure code on unsuccessful probe response', async () => {\n    const fetchMock = vi.fn(async () => new Response(JSON.stringify({\n      success: false,\n      code: 'PROBE_INCONCLUSIVE',\n    }), { status: 200 }))\n    vi.stubGlobal('fetch', fetchMock)\n\n    await expect(probeModelLlmProtocolViaApi({\n      providerId: 'openai-compatible:oa-1',\n      modelId: 'gpt-4.1-mini',\n    })).rejects.toThrow('PROBE_INCONCLUSIVE')\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/provider-card-shell.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { getCompatibilityLayerBadgeLabel } from '@/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell'\n\ndescribe('provider card shell compatibility layer badge', () => {\n  const t = (key: string): string => {\n    if (key === 'compatibilityLayerOpenAI') return 'OpenAI 兼容层'\n    if (key === 'compatibilityLayerGemini') return 'Gemini 兼容层'\n    return key\n  }\n\n  it('shows OpenAI compatible layer label for openai-compatible providers', () => {\n    expect(getCompatibilityLayerBadgeLabel('openai-compatible:oa-1', t)).toBe('OpenAI 兼容层')\n  })\n\n  it('shows Gemini compatible layer label for gemini-compatible providers', () => {\n    expect(getCompatibilityLayerBadgeLabel('gemini-compatible:gm-1', t)).toBe('Gemini 兼容层')\n  })\n\n  it('does not show compatibility label for preset providers', () => {\n    expect(getCompatibilityLayerBadgeLabel('google', t)).toBeNull()\n    expect(getCompatibilityLayerBadgeLabel('ark', t)).toBeNull()\n    expect(getCompatibilityLayerBadgeLabel('bailian', t)).toBeNull()\n    expect(getCompatibilityLayerBadgeLabel('siliconflow', t)).toBeNull()\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/provider-card-tutorial-modal.test.ts",
    "content": "import * as React from 'react'\nimport { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { afterEach, describe, expect, it, vi } from 'vitest'\nimport type { UseProviderCardStateResult } from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'\nimport { ProviderCardShell } from '@/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell'\nimport type { ProviderTutorial } from '@/app/[locale]/profile/components/api-config/types'\n\nconst portalMocks = vi.hoisted(() => {\n  return {\n    currentPortalTarget: null as unknown,\n    createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {\n      const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'\n      return createElement('div', { 'data-portal-target': targetLabel }, node)\n    }),\n  }\n})\n\nvi.mock('react-dom', async () => {\n  const actual = await vi.importActual<typeof import('react-dom')>('react-dom')\n  return {\n    ...actual,\n    createPortal: portalMocks.createPortalMock,\n  }\n})\n\nfunction createState(tutorial: ProviderTutorial): UseProviderCardStateResult {\n  return {\n    providerKey: 'ark',\n    isPresetProvider: true,\n    showBaseUrlEdit: false,\n    tutorial,\n    groupedModels: {},\n    hasModels: false,\n    isEditing: false,\n    isEditingUrl: false,\n    showKey: false,\n    tempKey: '',\n    tempUrl: '',\n    showTutorial: true,\n    showAddForm: null,\n    newModel: {\n      name: '',\n      modelId: '',\n      enableCustomPricing: false,\n      priceInput: '',\n      priceOutput: '',\n      basePrice: '',\n      optionPricesJson: '',\n    },\n    batchMode: false,\n    editingModelId: null,\n    editModel: {\n      name: '',\n      modelId: '',\n      enableCustomPricing: false,\n      priceInput: '',\n      priceOutput: '',\n      basePrice: '',\n      optionPricesJson: '',\n    },\n    maskedKey: '',\n    isPresetModel: () => false,\n    isDefaultModel: () => false,\n    setShowKey: () => undefined,\n    setShowTutorial: () => undefined,\n    setShowAddForm: () => undefined,\n    setBatchMode: () => undefined,\n    setNewModel: () => undefined,\n    setEditModel: () => undefined,\n    setTempKey: () => undefined,\n    setTempUrl: () => undefined,\n    startEditKey: () => undefined,\n    startEditUrl: () => undefined,\n    handleSaveKey: () => Promise.resolve(),\n    handleCancelEdit: () => undefined,\n    handleSaveUrl: () => undefined,\n    handleCancelUrlEdit: () => undefined,\n    handleEditModel: () => undefined,\n    handleCancelEditModel: () => undefined,\n    handleSaveModel: () => Promise.resolve(),\n    handleAddModel: () => Promise.resolve(),\n    handleCancelAdd: () => undefined,\n    needsCustomPricing: false,\n    keyTestStatus: 'idle',\n    keyTestSteps: [],\n    handleForceSaveKey: () => undefined,\n    handleTestOnly: () => undefined,\n    handleDismissTest: () => undefined,\n    isModelSavePending: false,\n    assistantEnabled: false,\n    isAssistantOpen: false,\n    assistantSavedEvent: null,\n    assistantChat: {\n      messages: [],\n      input: '',\n      status: 'ready',\n      pending: false,\n      error: undefined,\n      setInput: () => undefined,\n      send: async () => undefined,\n      clear: () => undefined,\n    },\n    openAssistant: () => undefined,\n    closeAssistant: () => undefined,\n    handleAssistantSend: () => Promise.resolve(),\n  }\n}\n\nfunction ProviderCardShellWithBody(\n  props: Omit<React.ComponentProps<typeof ProviderCardShell>, 'children'>,\n): React.ReactElement {\n  const ProviderCardShellComponent =\n    ProviderCardShell as unknown as React.ComponentType<\n      React.PropsWithChildren<Omit<React.ComponentProps<typeof ProviderCardShell>, 'children'>>\n    >\n  return createElement(\n    ProviderCardShellComponent,\n    props,\n    createElement('div', null, 'provider-body'),\n  )\n}\n\ndescribe('ProviderCardShell tutorial modal', () => {\n  afterEach(() => {\n    vi.clearAllMocks()\n    portalMocks.currentPortalTarget = null\n    Reflect.deleteProperty(globalThis, 'React')\n    Reflect.deleteProperty(globalThis, 'document')\n  })\n\n  it('mounts the tutorial modal through a portal to document.body', () => {\n    const fakeDocument = {\n      body: { nodeName: 'BODY' },\n    }\n    Reflect.set(globalThis, 'React', React)\n    portalMocks.currentPortalTarget = fakeDocument.body\n    Reflect.set(globalThis, 'document', fakeDocument)\n\n    const tutorial: ProviderTutorial = {\n      providerId: 'ark',\n      steps: [\n        {\n          text: 'ark_step1',\n          url: 'https://example.com/ark-key',\n        },\n      ],\n    }\n    const state = createState(tutorial)\n    const t = (key: string): string => {\n      if (key === 'tutorial.button') return '开通教程'\n      if (key === 'tutorial.title') return '开通教程'\n      if (key === 'tutorial.subtitle') return '按照以下步骤完成配置'\n      if (key === 'tutorial.steps.ark_step1') return '进入控制台创建 API Key'\n      if (key === 'tutorial.openLink') return '点击打开'\n      if (key === 'tutorial.close') return '关闭'\n      return key\n    }\n\n    const html = renderToStaticMarkup(\n      createElement(\n        ProviderCardShellWithBody,\n        {\n          provider: {\n            id: 'ark',\n            name: '阿里云百炼',\n            hasApiKey: true,\n          },\n          onDeleteProvider: () => undefined,\n          t,\n          state,\n        },\n      ),\n    )\n\n    expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(1)\n    expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)\n    expect(html).toContain('data-portal-target=\"body\"')\n    expect(html).toContain('进入控制台创建 API Key')\n    expect(html).toContain('href=\"https://example.com/ark-key\"')\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/use-api-config-filters.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useMemo: <T,>(factory: () => T) => factory(),\n  }\n})\n\nimport { useApiConfigFilters } from '@/app/[locale]/profile/components/api-config-tab/hooks/useApiConfigFilters'\nimport type { CustomModel, Provider } from '@/app/[locale]/profile/components/api-config/types'\n\ndescribe('api config filters', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('merges audio providers into modelProviders and removes audioProviders output', () => {\n    const providers: Provider[] = [\n      { id: 'fal', name: 'FAL', hasApiKey: true, apiKey: 'k-fal' },\n      { id: 'bailian', name: 'Alibaba Bailian', hasApiKey: true, apiKey: 'k-bl' },\n    ]\n    const models: CustomModel[] = [\n      {\n        modelId: 'fal-ai/index-tts-2/text-to-speech',\n        modelKey: 'fal::fal-ai/index-tts-2/text-to-speech',\n        name: 'IndexTTS 2',\n        type: 'audio',\n        provider: 'fal',\n        price: 0,\n        enabled: true,\n      },\n      {\n        modelId: 'qwen3-tts-vd-2026-01-26',\n        modelKey: 'bailian::qwen3-tts-vd-2026-01-26',\n        name: 'Qwen3 TTS',\n        type: 'audio',\n        provider: 'bailian',\n        price: 0,\n        enabled: true,\n      },\n      {\n        modelId: 'qwen-voice-design',\n        modelKey: 'bailian::qwen-voice-design',\n        name: 'Qwen Voice Design',\n        type: 'audio',\n        provider: 'bailian',\n        price: 0,\n        enabled: true,\n      },\n      {\n        modelId: 'qwen3.5-flash',\n        modelKey: 'bailian::qwen3.5-flash',\n        name: 'Qwen 3.5 Flash',\n        type: 'llm',\n        provider: 'bailian',\n        price: 0,\n        enabled: true,\n      },\n    ]\n\n    const result = useApiConfigFilters({ providers, models })\n    const providerIds = result.modelProviders.map((provider) => provider.id)\n    const audioDefaultIds = result.getEnabledModelsByType('audio').map((model) => model.modelId)\n\n    expect(providerIds).toEqual(['fal', 'bailian'])\n    expect(audioDefaultIds).toEqual(expect.arrayContaining([\n      'fal-ai/index-tts-2/text-to-speech',\n      'qwen3-tts-vd-2026-01-26',\n    ]))\n    expect(audioDefaultIds).not.toContain('qwen-voice-design')\n    expect(Object.prototype.hasOwnProperty.call(result, 'audioProviders')).toBe(false)\n  })\n\n  it('keeps modelProviders order aligned with providers input order', () => {\n    const providers: Provider[] = [\n      { id: 'google', name: 'Google AI Studio', hasApiKey: true, apiKey: 'k-google' },\n      { id: 'openai-compatible:oa-2', name: 'OpenAI B', hasApiKey: true, apiKey: 'k-oa2' },\n      { id: 'ark', name: 'Volcengine Ark', hasApiKey: true, apiKey: 'k-ark' },\n    ]\n    const models: CustomModel[] = [\n      {\n        modelId: 'gemini-3.1-pro-preview',\n        modelKey: 'google::gemini-3.1-pro-preview',\n        name: 'Gemini 3.1 Pro',\n        type: 'llm',\n        provider: 'google',\n        price: 0,\n        enabled: true,\n      },\n      {\n        modelId: 'gpt-4.1',\n        modelKey: 'openai-compatible:oa-2::gpt-4.1',\n        name: 'GPT 4.1',\n        type: 'llm',\n        provider: 'openai-compatible:oa-2',\n        price: 0,\n        enabled: true,\n      },\n      {\n        modelId: 'doubao-seed-2-0-pro-260215',\n        modelKey: 'ark::doubao-seed-2-0-pro-260215',\n        name: 'Doubao Seed 2.0 Pro',\n        type: 'llm',\n        provider: 'ark',\n        price: 0,\n        enabled: true,\n      },\n    ]\n\n    const result = useApiConfigFilters({ providers, models })\n    expect(result.modelProviders.map((provider) => provider.id)).toEqual([\n      'google',\n      'openai-compatible:oa-2',\n      'ark',\n    ])\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/use-assistant-chat-saved-events.test.ts",
    "content": "import type { UIMessage } from 'ai'\nimport { describe, expect, it } from 'vitest'\nimport { collectSavedEvents } from '@/components/assistant/useAssistantChat'\n\ndescribe('assistant chat saved events parser', () => {\n  it('parses single save tool output event', () => {\n    const messages = [{\n      id: 'm1',\n      role: 'assistant',\n      parts: [{\n        type: 'tool-saveModelTemplate',\n        state: 'output-available',\n        output: {\n          status: 'saved',\n          savedModelKey: 'openai-compatible:oa-1::veo3-fast',\n          draftModel: {\n            modelId: 'veo3-fast',\n            name: 'Veo 3 Fast',\n            type: 'video',\n            provider: 'openai-compatible:oa-1',\n            compatMediaTemplate: {\n              version: 1,\n              mediaType: 'video',\n              mode: 'async',\n              create: { method: 'POST', path: '/video/create' },\n              status: { method: 'GET', path: '/video/query?id={{task_id}}' },\n              response: { taskIdPath: '$.id', statusPath: '$.status' },\n              polling: { intervalMs: 5000, timeoutMs: 600000, doneStates: ['completed'], failStates: ['failed'] },\n            },\n          },\n        },\n      }],\n    }] as unknown as UIMessage[]\n\n    const events = collectSavedEvents(messages)\n\n    expect(events).toHaveLength(1)\n    expect(events[0]?.savedModelKey).toBe('openai-compatible:oa-1::veo3-fast')\n    expect(events[0]?.draftModel?.modelId).toBe('veo3-fast')\n  })\n\n  it('parses batch save tool output events', () => {\n    const messages = [{\n      id: 'm2',\n      role: 'assistant',\n      parts: [{\n        type: 'tool-saveModelTemplates',\n        state: 'output-available',\n        output: {\n          status: 'saved',\n          savedModelKeys: [\n            'openai-compatible:oa-1::veo3-fast',\n            'openai-compatible:oa-1::veo3.1-fast',\n          ],\n          draftModels: [\n            {\n              modelId: 'veo3-fast',\n              name: 'Veo 3 Fast',\n              type: 'video',\n              provider: 'openai-compatible:oa-1',\n              compatMediaTemplate: {\n                version: 1,\n                mediaType: 'video',\n                mode: 'async',\n                create: { method: 'POST', path: '/video/create' },\n                status: { method: 'GET', path: '/video/query?id={{task_id}}' },\n                response: { taskIdPath: '$.id', statusPath: '$.status' },\n                polling: { intervalMs: 5000, timeoutMs: 600000, doneStates: ['completed'], failStates: ['failed'] },\n              },\n            },\n            {\n              modelId: 'veo3.1-fast',\n              name: 'Veo 3.1 Fast',\n              type: 'video',\n              provider: 'openai-compatible:oa-1',\n              compatMediaTemplate: {\n                version: 1,\n                mediaType: 'video',\n                mode: 'async',\n                create: { method: 'POST', path: '/video/create' },\n                status: { method: 'GET', path: '/video/query?id={{task_id}}' },\n                response: { taskIdPath: '$.id', statusPath: '$.status' },\n                polling: { intervalMs: 5000, timeoutMs: 600000, doneStates: ['completed'], failStates: ['failed'] },\n              },\n            },\n          ],\n        },\n      }],\n    }] as unknown as UIMessage[]\n\n    const events = collectSavedEvents(messages)\n\n    expect(events).toHaveLength(2)\n    expect(events.map((item) => item.savedModelKey)).toEqual([\n      'openai-compatible:oa-1::veo3-fast',\n      'openai-compatible:oa-1::veo3.1-fast',\n    ])\n    expect(events[1]?.draftModel?.name).toBe('Veo 3.1 Fast')\n  })\n})\n"
  },
  {
    "path": "tests/unit/api-config/use-providers-order.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { mergeProvidersForDisplay } from '@/app/[locale]/profile/components/api-config/hooks'\nimport type { Provider } from '@/app/[locale]/profile/components/api-config/types'\n\ndescribe('useProviders provider order merge', () => {\n  it('preserves saved providers order and appends missing presets at the end', () => {\n    const presetProviders: Provider[] = [\n      { id: 'ark', name: '火山引擎 Ark' },\n      { id: 'google', name: 'Google AI Studio' },\n      { id: 'bailian', name: '阿里云百炼' },\n    ]\n    const savedProviders: Provider[] = [\n      { id: 'google', name: 'Google Legacy Name', apiKey: 'google-key', hidden: true },\n      { id: 'openai-compatible:oa-2', name: 'OpenAI B', baseUrl: 'https://oa-b.test', apiKey: 'oa-key' },\n      { id: 'ark', name: 'Ark Legacy Name', apiKey: 'ark-key' },\n    ]\n\n    const merged = mergeProvidersForDisplay(savedProviders, presetProviders)\n    expect(merged.map((provider) => provider.id)).toEqual([\n      'google',\n      'openai-compatible:oa-2',\n      'ark',\n      'bailian',\n    ])\n    expect(merged[0]?.hidden).toBe(true)\n  })\n\n  it('uses preset localized names for preset providers while keeping apiKey/baseUrl from saved data', () => {\n    const presetProviders: Provider[] = [\n      { id: 'google', name: 'Google AI Studio', baseUrl: 'https://google.default' },\n    ]\n    const savedProviders: Provider[] = [\n      { id: 'google', name: 'Google Old Name', baseUrl: 'https://google.custom', apiKey: 'google-key' },\n    ]\n\n    const merged = mergeProvidersForDisplay(savedProviders, presetProviders)\n    expect(merged).toHaveLength(1)\n    expect(merged[0]).toMatchObject({\n      id: 'google',\n      name: 'Google AI Studio',\n      baseUrl: 'https://google.custom',\n      apiKey: 'google-key',\n      hasApiKey: true,\n    })\n  })\n\n  it('uses preset official baseUrl for minimax even when saved payload contains a custom baseUrl', () => {\n    const presetProviders: Provider[] = [\n      { id: 'minimax', name: 'MiniMax Hailuo', baseUrl: 'https://api.minimaxi.com/v1' },\n    ]\n    const savedProviders: Provider[] = [\n      { id: 'minimax', name: 'MiniMax Legacy', baseUrl: 'https://custom.minimax.proxy/v1', apiKey: 'mm-key' },\n    ]\n\n    const merged = mergeProvidersForDisplay(savedProviders, presetProviders)\n    expect(merged).toHaveLength(1)\n    expect(merged[0]).toMatchObject({\n      id: 'minimax',\n      name: 'MiniMax Hailuo',\n      baseUrl: 'https://api.minimaxi.com/v1',\n      apiKey: 'mm-key',\n      hasApiKey: true,\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/assistant-platform/registry.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { getAssistantSkill, isAssistantId } from '@/lib/assistant-platform'\n\ndescribe('assistant-platform registry', () => {\n  it('recognizes supported assistant ids', () => {\n    expect(isAssistantId('api-config-template')).toBe(true)\n    expect(isAssistantId('tutorial')).toBe(true)\n    expect(isAssistantId('unknown')).toBe(false)\n  })\n\n  it('returns registered skills', () => {\n    expect(getAssistantSkill('api-config-template').id).toBe('api-config-template')\n    expect(getAssistantSkill('tutorial').id).toBe('tutorial')\n  })\n})\n"
  },
  {
    "path": "tests/unit/assistant-platform/runtime.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst getUserModelConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({ analysisModel: null })),\n)\n\nvi.mock('@/lib/config-service', () => ({\n  getUserModelConfig: getUserModelConfigMock,\n}))\n\nimport { AssistantPlatformError } from '@/lib/assistant-platform'\nimport { createAssistantChatResponse } from '@/lib/assistant-platform/runtime'\n\ndescribe('assistant-platform runtime', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('throws invalid request when messages payload is malformed', async () => {\n    await expect(createAssistantChatResponse({\n      userId: 'user-1',\n      assistantId: 'api-config-template',\n      context: {},\n      messages: { invalid: true },\n    })).rejects.toMatchObject({\n      code: 'ASSISTANT_INVALID_REQUEST',\n    } as Partial<AssistantPlatformError>)\n  })\n\n  it('throws missing model when analysisModel is not configured', async () => {\n    await expect(createAssistantChatResponse({\n      userId: 'user-1',\n      assistantId: 'api-config-template',\n      context: {\n        providerId: 'openai-compatible:oa-1',\n      },\n      messages: [{\n        id: 'u1',\n        role: 'user',\n        parts: [{ type: 'text', text: 'hello' }],\n      }],\n    })).rejects.toMatchObject({\n      code: 'ASSISTANT_MODEL_NOT_CONFIGURED',\n    } as Partial<AssistantPlatformError>)\n  })\n})\n"
  },
  {
    "path": "tests/unit/assistant-platform/skills-api-config-template.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { AssistantRuntimeContext } from '@/lib/assistant-platform'\n\nconst saveModelTemplateConfigurationMock = vi.hoisted(() =>\n  vi.fn(async () => ({ modelKey: 'openai-compatible:oa-1::veo3.1' })),\n)\n\nvi.mock('@/lib/user-api/model-template/save', () => ({\n  saveModelTemplateConfiguration: saveModelTemplateConfigurationMock,\n}))\n\nimport { apiConfigTemplateSkill } from '@/lib/assistant-platform/skills/api-config-template'\n\nfunction buildRuntimeContext(): AssistantRuntimeContext {\n  return {\n    userId: 'user-1',\n    assistantId: 'api-config-template',\n    context: {\n      providerId: 'openai-compatible:oa-1',\n    },\n    analysisModelKey: 'openrouter::gpt-5-mini',\n    resolvedModel: {\n      providerId: 'openrouter',\n      providerKey: 'openrouter',\n      modelId: 'gpt-5-mini',\n    },\n  }\n}\n\ndescribe('assistant-platform api-config-template skill', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('returns invalid when template fails schema validation', async () => {\n    const tools = apiConfigTemplateSkill.tools?.(buildRuntimeContext())\n    expect(tools).toBeTruthy()\n    const saveTool = tools?.saveModelTemplate\n    expect(saveTool).toBeTruthy()\n    if (!saveTool?.execute) {\n      throw new Error('saveModelTemplate.execute is required for test')\n    }\n\n    const result = await saveTool.execute({\n      modelId: 'veo3.1',\n      name: 'Veo 3.1',\n      type: 'video',\n      compatMediaTemplate: {\n        version: 1,\n        mediaType: 'video',\n        mode: 'async',\n        create: {\n          method: 'POST',\n          path: '/v2/videos/generations',\n        },\n        response: {\n          taskIdPath: '$.task_id',\n        },\n      },\n    }, {} as never)\n\n    expect(result.status).toBe('invalid')\n    expect(result.code).toBe('MODEL_TEMPLATE_INVALID')\n    expect(saveModelTemplateConfigurationMock).not.toHaveBeenCalled()\n  })\n\n  it('saves template when payload is valid', async () => {\n    const tools = apiConfigTemplateSkill.tools?.(buildRuntimeContext())\n    expect(tools).toBeTruthy()\n    const saveTool = tools?.saveModelTemplate\n    expect(saveTool).toBeTruthy()\n    if (!saveTool?.execute) {\n      throw new Error('saveModelTemplate.execute is required for test')\n    }\n\n    const result = await saveTool.execute({\n      modelId: 'veo3.1',\n      name: 'Veo 3.1',\n      type: 'video',\n      compatMediaTemplate: {\n        version: 1,\n        mediaType: 'video',\n        mode: 'async',\n        create: {\n          method: 'POST',\n          path: '/v2/videos/generations',\n          contentType: 'application/json',\n          bodyTemplate: {\n            model: '{{model}}',\n            prompt: '{{prompt}}',\n          },\n        },\n        status: {\n          method: 'GET',\n          path: '/v2/videos/generations/{{task_id}}',\n        },\n        response: {\n          taskIdPath: '$.task_id',\n          statusPath: '$.status',\n          outputUrlPath: '$.video_url',\n        },\n        polling: {\n          intervalMs: 3000,\n          timeoutMs: 180000,\n          doneStates: ['done'],\n          failStates: ['failed'],\n        },\n      },\n    }, {} as never)\n\n    expect(result.status).toBe('saved')\n    expect(result.savedModelKey).toBe('openai-compatible:oa-1::veo3.1')\n    expect(saveModelTemplateConfigurationMock).toHaveBeenCalledWith({\n      userId: 'user-1',\n      providerId: 'openai-compatible:oa-1',\n      modelId: 'veo3.1',\n      name: 'Veo 3.1',\n      type: 'video',\n      template: expect.objectContaining({\n        mediaType: 'video',\n      }),\n      source: 'ai',\n    })\n  })\n\n  it('saves multiple templates when batch payload is valid', async () => {\n    const tools = apiConfigTemplateSkill.tools?.(buildRuntimeContext())\n    expect(tools).toBeTruthy()\n    const batchTool = tools?.saveModelTemplates\n    expect(batchTool).toBeTruthy()\n    if (!batchTool?.execute) {\n      throw new Error('saveModelTemplates.execute is required for test')\n    }\n\n    const result = await batchTool.execute({\n      models: [\n        {\n          modelId: 'veo3-fast',\n          name: 'Veo 3 Fast',\n          type: 'video',\n          compatMediaTemplate: {\n            version: 1,\n            mediaType: 'video',\n            mode: 'async',\n            create: {\n              method: 'POST',\n              path: '/video/create',\n              contentType: 'application/json',\n              bodyTemplate: {\n                model: '{{model}}',\n                prompt: '{{prompt}}',\n                images: ['{{image}}'],\n              },\n            },\n            status: {\n              method: 'GET',\n              path: '/video/query?id={{task_id}}',\n            },\n            response: {\n              taskIdPath: '$.id',\n              statusPath: '$.status',\n              outputUrlPath: '$.video_url',\n            },\n            polling: {\n              intervalMs: 5000,\n              timeoutMs: 600000,\n              doneStates: ['completed'],\n              failStates: ['failed'],\n            },\n          },\n        },\n        {\n          modelId: 'veo3.1-fast',\n          name: 'Veo 3.1 Fast',\n          type: 'video',\n          compatMediaTemplate: {\n            version: 1,\n            mediaType: 'video',\n            mode: 'async',\n            create: {\n              method: 'POST',\n              path: '/video/create',\n              contentType: 'application/json',\n              bodyTemplate: {\n                model: '{{model}}',\n                prompt: '{{prompt}}',\n                images: ['{{image}}'],\n              },\n            },\n            status: {\n              method: 'GET',\n              path: '/video/query?id={{task_id}}',\n            },\n            response: {\n              taskIdPath: '$.id',\n              statusPath: '$.status',\n              outputUrlPath: '$.video_url',\n            },\n            polling: {\n              intervalMs: 5000,\n              timeoutMs: 600000,\n              doneStates: ['completed'],\n              failStates: ['failed'],\n            },\n          },\n        },\n      ],\n    }, {} as never)\n\n    expect(result.status).toBe('saved')\n    expect(result.savedModelKeys).toHaveLength(2)\n    expect(saveModelTemplateConfigurationMock).toHaveBeenCalledTimes(2)\n    expect(saveModelTemplateConfigurationMock).toHaveBeenNthCalledWith(1, expect.objectContaining({\n      modelId: 'veo3-fast',\n      name: 'Veo 3 Fast',\n      providerId: 'openai-compatible:oa-1',\n      userId: 'user-1',\n      type: 'video',\n      source: 'ai',\n    }))\n    expect(saveModelTemplateConfigurationMock).toHaveBeenNthCalledWith(2, expect.objectContaining({\n      modelId: 'veo3.1-fast',\n      name: 'Veo 3.1 Fast',\n      providerId: 'openai-compatible:oa-1',\n      userId: 'user-1',\n      type: 'video',\n      source: 'ai',\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/unit/assistant-platform/system-prompts.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { renderAssistantSystemPrompt } from '@/lib/assistant-platform/system-prompts'\n\ndescribe('assistant-platform system prompts', () => {\n  it('loads api-config-template prompt from lib/prompts/skills and injects providerId', () => {\n    const prompt = renderAssistantSystemPrompt('api-config-template', {\n      providerId: 'openai-compatible:oa-1',\n    })\n\n    expect(prompt).toContain('你是 API 配置助手')\n    expect(prompt).toContain('当前 providerId=openai-compatible:oa-1')\n    expect(prompt).not.toContain('{{providerId}}')\n  })\n\n  it('loads tutorial prompt from lib/prompts/skills', () => {\n    const prompt = renderAssistantSystemPrompt('tutorial')\n\n    expect(prompt).toContain('你是产品教程助手')\n    expect(prompt).toContain('禁止编造不存在的页面')\n  })\n})\n"
  },
  {
    "path": "tests/unit/async-poll-ocompat.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({\n  id: 'openai-compatible:oa-1',\n  apiKey: 'sk-test',\n  baseUrl: 'https://compat.example.com/v1',\n})))\nconst getUserModelsMock = vi.hoisted(() =>\n  vi.fn<typeof import('@/lib/api-config').getUserModels>(async () => []),\n)\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n  getUserModels: getUserModelsMock,\n}))\n\nimport { pollAsyncTask } from '@/lib/async-poll'\n\nfunction encode(value: string): string {\n  return Buffer.from(value, 'utf8').toString('base64url')\n}\n\ndescribe('async poll ocompat', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    globalThis.fetch = vi.fn() as unknown as typeof fetch\n  })\n\n  it('returns completed with output url when async status reaches done', async () => {\n    getUserModelsMock.mockResolvedValueOnce([\n      {\n        modelKey: 'openai-compatible:oa-1::veo3.1',\n        modelId: 'veo3.1',\n        name: 'Veo 3.1',\n        type: 'video',\n        provider: 'openai-compatible:oa-1',\n        price: 0,\n        compatMediaTemplate: {\n          version: 1,\n          mediaType: 'video',\n          mode: 'async',\n          create: { method: 'POST', path: '/v2/videos/generations' },\n          status: { method: 'GET', path: '/v2/videos/generations/{{task_id}}' },\n          response: {\n            statusPath: '$.status',\n            outputUrlPath: '$.video_url',\n          },\n          polling: {\n            intervalMs: 3000,\n            timeoutMs: 180000,\n            doneStates: ['succeeded'],\n            failStates: ['failed'],\n          },\n        },\n      },\n    ])\n    const fetchMock = vi.fn(async () => new Response(JSON.stringify({\n      status: 'succeeded',\n      video_url: 'https://cdn.test/video.mp4',\n    }), { status: 200 }))\n    globalThis.fetch = fetchMock as unknown as typeof fetch\n\n    const result = await pollAsyncTask(\n      `OCOMPAT:VIDEO:${encode('openai-compatible:oa-1')}:${encode('openai-compatible:oa-1::veo3.1')}:task_1`,\n      'user-1',\n    )\n\n    expect(result).toEqual({\n      status: 'completed',\n      resultUrl: 'https://cdn.test/video.mp4',\n      videoUrl: 'https://cdn.test/video.mp4',\n    })\n  })\n\n  it('uses content endpoint when output url is missing', async () => {\n    getUserModelsMock.mockResolvedValueOnce([\n      {\n        modelKey: 'openai-compatible:oa-1::veo3.1',\n        modelId: 'veo3.1',\n        name: 'Veo 3.1',\n        type: 'video',\n        provider: 'openai-compatible:oa-1',\n        price: 0,\n        compatMediaTemplate: {\n          version: 1,\n          mediaType: 'video',\n          mode: 'async',\n          create: { method: 'POST', path: '/v2/videos/generations' },\n          status: { method: 'GET', path: '/v2/videos/generations/{{task_id}}' },\n          content: { method: 'GET', path: '/v2/videos/generations/{{task_id}}/content' },\n          response: {\n            statusPath: '$.status',\n          },\n          polling: {\n            intervalMs: 3000,\n            timeoutMs: 180000,\n            doneStates: ['succeeded'],\n            failStates: ['failed'],\n          },\n        },\n      },\n    ])\n    const fetchMock = vi.fn(async () => new Response(JSON.stringify({\n      status: 'succeeded',\n    }), { status: 200 }))\n    globalThis.fetch = fetchMock as unknown as typeof fetch\n\n    const result = await pollAsyncTask(\n      `OCOMPAT:VIDEO:${encode('openai-compatible:oa-1')}:${encode('openai-compatible:oa-1::veo3.1')}:task_2`,\n      'user-1',\n    )\n\n    expect(result.status).toBe('completed')\n    expect(result.videoUrl).toBe('https://compat.example.com/v1/v2/videos/generations/task_2/content')\n    expect(result.downloadHeaders).toEqual({\n      Authorization: 'Bearer sk-test',\n    })\n  })\n\n  it('accepts compact OCOMPAT token encoded from modelId', async () => {\n    const providerUuid = '33331fb0-2806-4da6-85ff-cd2433b587d0'\n    getUserModelsMock.mockResolvedValueOnce([\n      {\n        modelKey: `openai-compatible:${providerUuid}::veo3.1-fast`,\n        modelId: 'veo3.1-fast',\n        name: 'Veo 3.1 Fast',\n        type: 'video',\n        provider: `openai-compatible:${providerUuid}`,\n        price: 0,\n        compatMediaTemplate: {\n          version: 1,\n          mediaType: 'video',\n          mode: 'async',\n          create: { method: 'POST', path: '/video/create' },\n          status: { method: 'GET', path: '/video/query?id={{task_id}}' },\n          response: {\n            statusPath: '$.status',\n            outputUrlPath: '$.video_url',\n          },\n          polling: {\n            intervalMs: 3000,\n            timeoutMs: 180000,\n            doneStates: ['completed'],\n            failStates: ['failed'],\n          },\n        },\n      },\n    ])\n    const fetchMock = vi.fn(async () => new Response(JSON.stringify({\n      status: 'completed',\n      video_url: 'https://cdn.test/video-fast.mp4',\n    }), { status: 200 }))\n    globalThis.fetch = fetchMock as unknown as typeof fetch\n\n    const result = await pollAsyncTask(\n      `OCOMPAT:VIDEO:u_${providerUuid}:${encode('veo3.1-fast')}:task_3`,\n      'user-1',\n    )\n\n    expect(result).toEqual({\n      status: 'completed',\n      resultUrl: 'https://cdn.test/video-fast.mp4',\n      videoUrl: 'https://cdn.test/video-fast.mp4',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/billing/cost-error-branches.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst lookupMock = vi.hoisted(() => ({\n  resolveBuiltinPricing: vi.fn(),\n}))\n\nvi.mock('@/lib/model-pricing/lookup', () => ({\n  resolveBuiltinPricing: lookupMock.resolveBuiltinPricing,\n}))\n\nimport { calcImage, calcText, calcVideo, calcVoice } from '@/lib/billing/cost'\n\ndescribe('billing/cost error branches', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('throws ambiguous pricing error when catalog has multiple candidates', () => {\n    lookupMock.resolveBuiltinPricing.mockReturnValue({\n      status: 'ambiguous_model',\n      apiType: 'image',\n      modelId: 'shared-model',\n      candidates: [\n        {\n          apiType: 'image',\n          provider: 'p1',\n          modelId: 'shared-model',\n          pricing: { mode: 'flat', flatAmount: 1 },\n        },\n        {\n          apiType: 'image',\n          provider: 'p2',\n          modelId: 'shared-model',\n          pricing: { mode: 'flat', flatAmount: 1 },\n        },\n      ],\n    })\n\n    expect(() => calcImage('shared-model', 1)).toThrow('Ambiguous image pricing modelId')\n  })\n\n  it('throws unknown model when catalog returns not_configured', () => {\n    lookupMock.resolveBuiltinPricing.mockReturnValue({\n      status: 'not_configured',\n    })\n\n    expect(() => calcImage('provider::missing-image-model', 1)).toThrow('Unknown image model pricing')\n  })\n\n  it('normalizes invalid numeric inputs to zero before pricing', () => {\n    lookupMock.resolveBuiltinPricing.mockImplementation(\n      (input: { selections?: { tokenType?: 'input' | 'output' } }) => {\n        if (input.selections?.tokenType === 'input') return { status: 'resolved', amount: 2 }\n        if (input.selections?.tokenType === 'output') return { status: 'resolved', amount: 4 }\n        return { status: 'resolved', amount: 3 }\n      },\n    )\n\n    expect(calcText('text-model', Number.NaN, 1_000_000)).toBeCloseTo(4, 8)\n    expect(calcText('text-model', 1_000_000, Number.NaN)).toBeCloseTo(2, 8)\n    expect(calcImage('image-model', Number.NaN)).toBe(0)\n    expect(calcVideo('video-model', '720p', Number.NaN)).toBe(0)\n    expect(calcVoice(Number.NaN)).toBe(0)\n  })\n})\n"
  },
  {
    "path": "tests/unit/billing/cost.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  USD_TO_CNY,\n  calcImage,\n  calcLipSync,\n  calcText,\n  calcVideo,\n  calcVoice,\n  calcVoiceDesign,\n} from '@/lib/billing/cost'\n\ndescribe('billing/cost', () => {\n  it('calculates text cost by known model price table', () => {\n    const cost = calcText('anthropic/claude-sonnet-4', 1_000_000, 1_000_000)\n    expect(cost).toBeCloseTo((3 + 15) * USD_TO_CNY, 8)\n  })\n\n  it('throws when text model pricing is unknown', () => {\n    expect(() => calcText('unknown-model', 500_000, 250_000)).toThrow('Unknown text model pricing')\n  })\n\n  it('throws when image model pricing is unknown', () => {\n    expect(() => calcImage('missing-image-model', 3)).toThrow('Unknown image model pricing')\n  })\n\n  it('supports resolution-aware video pricing', () => {\n    const cost720 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '720p', 2)\n    const cost1080 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '1080p', 2)\n    expect(cost720).toBeCloseTo(0.86, 8)\n    expect(cost1080).toBeCloseTo(2.06, 8)\n    expect(() => calcVideo('doubao-seedance-1-0-pro-fast-251015', '2k', 1)).toThrow('Unsupported video resolution pricing')\n    expect(() => calcVideo('unknown-video-model', '720p', 1)).toThrow('Unknown video model pricing')\n  })\n\n  it('scales ark video pricing by selected duration when tiers omit duration', () => {\n    const shortDuration = calcVideo('doubao-seedance-1-0-pro-250528', '480p', 1, {\n      generationMode: 'normal',\n      resolution: '480p',\n      duration: 2,\n    })\n    const longDuration = calcVideo('doubao-seedance-1-0-pro-250528', '1080p', 1, {\n      generationMode: 'normal',\n      resolution: '1080p',\n      duration: 12,\n    })\n\n    expect(shortDuration).toBeCloseTo(0.292, 8)\n    expect(longDuration).toBeCloseTo(8.808, 8)\n  })\n\n  it('uses Ark 1.5 official default generateAudio=true when audio is omitted', () => {\n    const defaultAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {\n      generationMode: 'normal',\n      resolution: '720p',\n    })\n    const muteAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {\n      generationMode: 'normal',\n      resolution: '720p',\n      generateAudio: false,\n    })\n\n    expect(defaultAudio).toBeCloseTo(1.73, 8)\n    expect(muteAudio).toBeCloseTo(0.86, 8)\n  })\n\n  it('supports Ark Seedance 1.0 Lite i2v pricing and duration scaling', () => {\n    const shortDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '480p', 1, {\n      generationMode: 'normal',\n      resolution: '480p',\n      duration: 2,\n    })\n    const longDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '1080p', 1, {\n      generationMode: 'firstlastframe',\n      resolution: '1080p',\n      duration: 12,\n    })\n\n    expect(shortDuration).toBeCloseTo(0.196, 8)\n    expect(longDuration).toBeCloseTo(5.88, 8)\n  })\n\n  it('rejects unsupported Ark capability values before pricing', () => {\n    expect(() => calcVideo('doubao-seedance-1-0-lite-i2v-250428', '720p', 1, {\n      generationMode: 'normal',\n      resolution: '720p',\n      duration: 1,\n    })).toThrow('Unsupported video capability pricing')\n  })\n\n  it('supports minimax capability-aware video pricing', () => {\n    const hailuoNormal = calcVideo('minimax-hailuo-2.3', '768p', 1, {\n      generationMode: 'normal',\n      resolution: '768p',\n      duration: 6,\n    })\n    const hailuoFirstLast = calcVideo('minimax-hailuo-02', '768p', 1, {\n      generationMode: 'firstlastframe',\n      resolution: '768p',\n      duration: 10,\n    })\n    const t2v = calcVideo('t2v-01', '720p', 1, {\n      generationMode: 'normal',\n      resolution: '720p',\n      duration: 6,\n    })\n\n    expect(hailuoNormal).toBeCloseTo(2.0, 8)\n    expect(hailuoFirstLast).toBeCloseTo(4.0, 8)\n    expect(t2v).toBeCloseTo(3.0, 8)\n    expect(() => calcVideo('minimax-hailuo-02', '512p', 1, {\n      generationMode: 'firstlastframe',\n      resolution: '512p',\n      duration: 6,\n    })).toThrow('Unsupported video capability pricing')\n  })\n\n  it('prefers builtin image pricing over custom pricing when builtin exists', () => {\n    const builtin = calcImage('banana', 1)\n    const withCustom = calcImage('banana', 1, undefined, {\n      image: {\n        basePrice: 99,\n      },\n    })\n    expect(withCustom).toBeCloseTo(builtin, 8)\n  })\n\n  it('uses custom image option pricing for unknown models', () => {\n    const cost = calcImage(\n      'openai-compatible:oa-1::gpt-image-1',\n      2,\n      {\n        resolution: '1024x1024',\n        quality: 'high',\n      },\n      {\n        image: {\n          basePrice: 0.2,\n          optionPrices: {\n            resolution: {\n              '1024x1024': 0.05,\n            },\n            quality: {\n              high: 0.1,\n            },\n          },\n        },\n      },\n    )\n    expect(cost).toBeCloseTo((0.2 + 0.05 + 0.1) * 2, 8)\n  })\n\n  it('uses custom video option pricing for unknown models', () => {\n    const cost = calcVideo(\n      'openai-compatible:oa-1::sora-2',\n      '720p',\n      1,\n      {\n        resolution: '720x1280',\n        duration: 8,\n      },\n      {\n        video: {\n          basePrice: 0.8,\n          optionPrices: {\n            resolution: {\n              '720x1280': 0.2,\n            },\n            duration: {\n              '8': 0.4,\n            },\n          },\n        },\n      },\n    )\n    expect(cost).toBeCloseTo(1.4, 8)\n  })\n\n  it('fails explicitly when selected custom option price is missing', () => {\n    expect(() => calcVideo(\n      'openai-compatible:oa-1::sora-2',\n      '720p',\n      1,\n      {\n        resolution: '1792x1024',\n      },\n      {\n        video: {\n          optionPrices: {\n            resolution: {\n              '720x1280': 0.2,\n            },\n          },\n        },\n      },\n    )).toThrow('No custom video price matched')\n  })\n\n  it('returns deterministic fixed costs for call-based APIs', () => {\n    expect(calcVoiceDesign()).toBeGreaterThan(0)\n    expect(calcLipSync()).toBeGreaterThan(0)\n    expect(calcLipSync('vidu::vidu-lipsync')).toBeGreaterThan(0)\n    expect(calcLipSync('bailian::videoretalk')).toBeGreaterThan(0)\n  })\n\n  it('calculates voice costs from quantities', () => {\n    expect(calcVoice(30)).toBeGreaterThan(0)\n  })\n})\n"
  },
  {
    "path": "tests/unit/billing/ledger-extra.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst prismaMock = vi.hoisted(() => ({\n  $transaction: vi.fn(),\n}))\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nvi.mock('@/lib/logging/core', () => ({\n  logInfo: vi.fn(),\n  logError: vi.fn(),\n}))\n\nimport { addBalance, recordShadowUsage } from '@/lib/billing/ledger'\n\nfunction buildTxStub() {\n  return {\n    userBalance: {\n      upsert: vi.fn(),\n    },\n    balanceTransaction: {\n      findFirst: vi.fn(),\n      create: vi.fn(),\n    },\n  }\n}\n\ndescribe('billing/ledger extra', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('returns false when addBalance amount is invalid', async () => {\n    const result = await addBalance('u1', 0)\n    expect(result).toBe(false)\n    expect(prismaMock.$transaction).not.toHaveBeenCalled()\n  })\n\n  it('adds recharge balance with string reason', async () => {\n    const tx = buildTxStub()\n    tx.userBalance.upsert.mockResolvedValue({ balance: 8.5 })\n    prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {\n      await callback(tx)\n    })\n\n    const result = await addBalance('u1', 5, 'manual recharge')\n\n    expect(result).toBe(true)\n    expect(tx.balanceTransaction.findFirst).not.toHaveBeenCalled()\n    expect(tx.userBalance.upsert).toHaveBeenCalledTimes(1)\n    expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({\n      data: expect.objectContaining({\n        userId: 'u1',\n        type: 'recharge',\n        amount: 5,\n      }),\n    }))\n  })\n\n  it('supports idempotent addBalance and short-circuits duplicate key', async () => {\n    const tx = buildTxStub()\n    tx.balanceTransaction.findFirst.mockResolvedValue({ id: 'existing_tx' })\n    prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {\n      await callback(tx)\n    })\n\n    const result = await addBalance('u1', 3, {\n      type: 'adjust',\n      reason: 'admin adjust',\n      idempotencyKey: 'idem_1',\n      operatorId: 'op_1',\n      externalOrderId: 'order_1',\n    })\n\n    expect(result).toBe(true)\n    expect(tx.balanceTransaction.findFirst).toHaveBeenCalledTimes(1)\n    expect(tx.userBalance.upsert).not.toHaveBeenCalled()\n    expect(tx.balanceTransaction.create).not.toHaveBeenCalled()\n  })\n\n  it('returns false when transaction throws in addBalance', async () => {\n    prismaMock.$transaction.mockRejectedValue(new Error('db error'))\n\n    const result = await addBalance('u1', 2, 'x')\n\n    expect(result).toBe(false)\n  })\n\n  it('records shadow usage consume log on success', async () => {\n    const tx = buildTxStub()\n    tx.userBalance.upsert.mockResolvedValue({ balance: 11.2 })\n    prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {\n      await callback(tx)\n    })\n\n    const result = await recordShadowUsage('u1', {\n      projectId: 'p1',\n      action: 'analyze',\n      apiType: 'text',\n      model: 'anthropic/claude-sonnet-4',\n      quantity: 1000,\n      unit: 'token',\n      cost: 0.25,\n      metadata: { trace: 'abc' },\n    })\n\n    expect(result).toBe(true)\n    expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({\n      data: expect.objectContaining({\n        userId: 'u1',\n        type: 'shadow_consume',\n        amount: 0,\n      }),\n    }))\n  })\n\n  it('returns false when recordShadowUsage transaction fails', async () => {\n    prismaMock.$transaction.mockRejectedValue(new Error('shadow failed'))\n\n    const result = await recordShadowUsage('u1', {\n      projectId: 'p1',\n      action: 'analyze',\n      apiType: 'text',\n      model: 'anthropic/claude-sonnet-4',\n      quantity: 1000,\n      unit: 'token',\n      cost: 0.25,\n    })\n\n    expect(result).toBe(false)\n  })\n})\n\n"
  },
  {
    "path": "tests/unit/billing/mode.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { getBillingMode, getBootBillingEnabled } from '@/lib/billing/mode'\n\ndescribe('billing/mode', () => {\n  it('falls back to OFF when env is missing', async () => {\n    delete process.env.BILLING_MODE\n    await expect(getBillingMode()).resolves.toBe('OFF')\n    expect(getBootBillingEnabled()).toBe(false)\n  })\n\n  it('normalizes lower-case env mode', async () => {\n    process.env.BILLING_MODE = 'enforce'\n    await expect(getBillingMode()).resolves.toBe('ENFORCE')\n    expect(getBootBillingEnabled()).toBe(true)\n  })\n\n  it('falls back to OFF when env mode is invalid', async () => {\n    process.env.BILLING_MODE = 'invalid'\n    await expect(getBillingMode()).resolves.toBe('OFF')\n    expect(getBootBillingEnabled()).toBe(false)\n  })\n})\n"
  },
  {
    "path": "tests/unit/billing/runtime-usage.test.ts",
    "content": "import { AsyncLocalStorage } from 'node:async_hooks'\nimport { describe, expect, it, vi } from 'vitest'\nimport { recordTextUsage, withTextUsageCollection } from '@/lib/billing/runtime-usage'\n\ndescribe('billing/runtime-usage', () => {\n  it('ignores records outside of collection scope', () => {\n    expect(() => {\n      recordTextUsage({\n        model: 'm',\n        inputTokens: 10,\n        outputTokens: 20,\n      })\n    }).not.toThrow()\n  })\n\n  it('collects and normalizes token usage', async () => {\n    const { textUsage } = await withTextUsageCollection(async () => {\n      recordTextUsage({\n        model: 'test-model',\n        inputTokens: 10.9,\n        outputTokens: -2,\n      })\n      return { ok: true }\n    })\n\n    expect(textUsage).toEqual([\n      {\n        model: 'test-model',\n        inputTokens: 10,\n        outputTokens: 0,\n      },\n    ])\n  })\n\n  it('falls back to empty usage when store is unavailable at read time', async () => {\n    const getStoreSpy = vi.spyOn(AsyncLocalStorage.prototype, 'getStore')\n    getStoreSpy.mockReturnValueOnce(undefined as never)\n\n    const payload = await withTextUsageCollection(async () => ({ ok: true }))\n\n    expect(payload).toEqual({ result: { ok: true }, textUsage: [] })\n    getStoreSpy.mockRestore()\n  })\n\n  it('normalizes NaN and zero token values to zero', async () => {\n    const { textUsage } = await withTextUsageCollection(async () => {\n      recordTextUsage({\n        model: 'nan-model',\n        inputTokens: Number.NaN,\n        outputTokens: 0,\n      })\n      return { ok: true }\n    })\n\n    expect(textUsage).toEqual([\n      {\n        model: 'nan-model',\n        inputTokens: 0,\n        outputTokens: 0,\n      },\n    ])\n  })\n\n  it('isolates concurrent async local storage contexts', async () => {\n    const [left, right] = await Promise.all([\n      withTextUsageCollection(async () => {\n        recordTextUsage({ model: 'left', inputTokens: 1, outputTokens: 2 })\n        return 'left'\n      }),\n      withTextUsageCollection(async () => {\n        recordTextUsage({ model: 'right', inputTokens: 3, outputTokens: 4 })\n        return 'right'\n      }),\n    ])\n\n    expect(left.textUsage).toEqual([{ model: 'left', inputTokens: 1, outputTokens: 2 }])\n    expect(right.textUsage).toEqual([{ model: 'right', inputTokens: 3, outputTokens: 4 }])\n  })\n})\n"
  },
  {
    "path": "tests/unit/billing/service.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { calcText, calcVoice } from '@/lib/billing/cost'\nimport type { TaskBillingInfo } from '@/lib/task/types'\n\nconst ledgerMock = vi.hoisted(() => ({\n  confirmChargeWithRecord: vi.fn(),\n  freezeBalance: vi.fn(),\n  getBalance: vi.fn(),\n  getFreezeByIdempotencyKey: vi.fn(),\n  increasePendingFreezeAmount: vi.fn(),\n  recordShadowUsage: vi.fn(),\n  rollbackFreeze: vi.fn(),\n}))\n\nconst modeMock = vi.hoisted(() => ({\n  getBillingMode: vi.fn(),\n}))\n\nvi.mock('@/lib/billing/ledger', () => ledgerMock)\nvi.mock('@/lib/billing/mode', () => modeMock)\n\nimport { BillingOperationError, InsufficientBalanceError } from '@/lib/billing/errors'\nimport {\n  handleBillingError,\n  prepareTaskBilling,\n  rollbackTaskBilling,\n  settleTaskBilling,\n  withTextBilling,\n  withVoiceBilling,\n} from '@/lib/billing/service'\n\ndescribe('billing/service', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    ledgerMock.confirmChargeWithRecord.mockResolvedValue(true)\n    ledgerMock.freezeBalance.mockResolvedValue('freeze_1')\n    ledgerMock.getBalance.mockResolvedValue({ balance: 0 })\n    ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue(null)\n    ledgerMock.increasePendingFreezeAmount.mockResolvedValue(true)\n    ledgerMock.recordShadowUsage.mockResolvedValue(true)\n    ledgerMock.rollbackFreeze.mockResolvedValue(true)\n  })\n\n  it('returns raw execution result in OFF mode', async () => {\n    modeMock.getBillingMode.mockResolvedValue('OFF')\n\n    const result = await withTextBilling(\n      'u1',\n      'anthropic/claude-sonnet-4',\n      1000,\n      1000,\n      { projectId: 'p1', action: 'a1' },\n      async () => ({ ok: true }),\n    )\n\n    expect(result).toEqual({ ok: true })\n    expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()\n    expect(ledgerMock.confirmChargeWithRecord).not.toHaveBeenCalled()\n  })\n\n  it('records shadow usage in SHADOW mode without freezing', async () => {\n    modeMock.getBillingMode.mockResolvedValue('SHADOW')\n\n    const result = await withTextBilling(\n      'u1',\n      'anthropic/claude-sonnet-4',\n      1000,\n      1000,\n      { projectId: 'p1', action: 'a1' },\n      async () => ({ ok: true }),\n    )\n\n    expect(result).toEqual({ ok: true })\n    expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()\n    expect(ledgerMock.recordShadowUsage).toHaveBeenCalledTimes(1)\n  })\n\n  it('throws InsufficientBalanceError when ENFORCE freeze fails', async () => {\n    modeMock.getBillingMode.mockResolvedValue('ENFORCE')\n    ledgerMock.freezeBalance.mockResolvedValue(null)\n    ledgerMock.getBalance.mockResolvedValue({ balance: 0.01 })\n\n    await expect(\n      withTextBilling(\n        'u1',\n        'anthropic/claude-sonnet-4',\n        1000,\n        1000,\n        { projectId: 'p1', action: 'a1' },\n        async () => ({ ok: true }),\n      ),\n    ).rejects.toBeInstanceOf(InsufficientBalanceError)\n  })\n\n  it('rolls back freeze when execution throws', async () => {\n    modeMock.getBillingMode.mockResolvedValue('ENFORCE')\n    ledgerMock.freezeBalance.mockResolvedValue('freeze_rollback')\n\n    await expect(\n      withTextBilling(\n        'u1',\n        'anthropic/claude-sonnet-4',\n        1000,\n        1000,\n        { projectId: 'p1', action: 'a1' },\n        async () => {\n          throw new Error('boom')\n        },\n      ),\n    ).rejects.toThrow('boom')\n\n    expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_rollback')\n  })\n\n  it('expands freeze and charges actual voice usage when actual exceeds quoted', async () => {\n    modeMock.getBillingMode.mockResolvedValue('ENFORCE')\n    ledgerMock.freezeBalance.mockResolvedValue('freeze_voice')\n\n    await withVoiceBilling(\n      'u1',\n      5,\n      { projectId: 'p1', action: 'voice_gen' },\n      async () => ({ actualDurationSeconds: 50 }),\n    )\n\n    const confirmCall = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)\n    expect(confirmCall).toBeTruthy()\n    const chargedAmount = confirmCall?.[2]?.chargedAmount as number\n    expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)\n    expect(chargedAmount).toBeCloseTo(calcVoice(50), 8)\n  })\n\n  it('fails and rolls back when overage freeze expansion cannot be covered', async () => {\n    modeMock.getBillingMode.mockResolvedValue('ENFORCE')\n    ledgerMock.freezeBalance.mockResolvedValue('freeze_voice_low_balance')\n    ledgerMock.increasePendingFreezeAmount.mockResolvedValue(false)\n    ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })\n\n    await expect(\n      withVoiceBilling(\n        'u1',\n        5,\n        { projectId: 'p1', action: 'voice_gen' },\n        async () => ({ actualDurationSeconds: 50 }),\n      ),\n    ).rejects.toBeInstanceOf(InsufficientBalanceError)\n\n    expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_voice_low_balance')\n  })\n\n  it('rejects duplicate sync billing key when freeze is already confirmed', async () => {\n    modeMock.getBillingMode.mockResolvedValue('ENFORCE')\n    ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({\n      id: 'freeze_confirmed',\n      userId: 'u1',\n      amount: 0.5,\n      status: 'confirmed',\n    })\n    const execute = vi.fn(async () => ({ ok: true }))\n\n    await expect(\n      withTextBilling(\n        'u1',\n        'anthropic/claude-sonnet-4',\n        1000,\n        1000,\n        { projectId: 'p1', action: 'a1', billingKey: 'billing-key-1' },\n        execute,\n      ),\n    ).rejects.toThrow('duplicate billing request already confirmed')\n\n    expect(execute).not.toHaveBeenCalled()\n    expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()\n  })\n\n  it('rejects duplicate sync billing key when freeze is pending', async () => {\n    modeMock.getBillingMode.mockResolvedValue('ENFORCE')\n    ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({\n      id: 'freeze_pending',\n      userId: 'u1',\n      amount: 0.5,\n      status: 'pending',\n    })\n    const execute = vi.fn(async () => ({ ok: true }))\n\n    await expect(\n      withTextBilling(\n        'u1',\n        'anthropic/claude-sonnet-4',\n        1000,\n        1000,\n        { projectId: 'p1', action: 'a1', billingKey: 'billing-key-2' },\n        execute,\n      ),\n    ).rejects.toThrow('duplicate billing request is already in progress')\n\n    expect(execute).not.toHaveBeenCalled()\n    expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()\n  })\n\n  it('maps insufficient balance error to 402 response payload', async () => {\n    const response = handleBillingError(new InsufficientBalanceError(1.2, 0.3))\n    expect(response).toBeTruthy()\n    expect(response?.status).toBe(402)\n    const body = await response?.json()\n    expect(body?.code).toBe('INSUFFICIENT_BALANCE')\n    expect(body?.required).toBeCloseTo(1.2, 8)\n    expect(body?.available).toBeCloseTo(0.3, 8)\n  })\n\n  it('returns null for non-billing errors', () => {\n    expect(handleBillingError(new Error('x'))).toBeNull()\n    expect(handleBillingError('x')).toBeNull()\n  })\n\n  describe('task billing lifecycle helpers', () => {\n    function buildTaskInfo(overrides: Partial<Extract<TaskBillingInfo, { billable: true }>> = {}): Extract<TaskBillingInfo, { billable: true }> {\n      return {\n        billable: true,\n        source: 'task',\n        taskType: 'voice_line',\n        apiType: 'voice',\n        model: 'index-tts2',\n        quantity: 5,\n        unit: 'second',\n        maxFrozenCost: calcVoice(5),\n        action: 'voice_line_generate',\n        metadata: { foo: 'bar' },\n        ...overrides,\n      }\n    }\n\n    it('prepareTaskBilling handles OFF/SHADOW/ENFORCE paths', async () => {\n      modeMock.getBillingMode.mockResolvedValueOnce('OFF')\n      const off = await prepareTaskBilling({\n        id: 'task_off',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo(),\n      })\n      expect((off as Extract<TaskBillingInfo, { billable: true }>).status).toBe('skipped')\n\n      modeMock.getBillingMode.mockResolvedValueOnce('SHADOW')\n      const shadow = await prepareTaskBilling({\n        id: 'task_shadow',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo(),\n      })\n      expect((shadow as Extract<TaskBillingInfo, { billable: true }>).status).toBe('quoted')\n\n      modeMock.getBillingMode.mockResolvedValueOnce('ENFORCE')\n      ledgerMock.freezeBalance.mockResolvedValueOnce('freeze_task_1')\n      const enforce = await prepareTaskBilling({\n        id: 'task_enforce',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo(),\n      })\n      const enforceInfo = enforce as Extract<TaskBillingInfo, { billable: true }>\n      expect(enforceInfo.status).toBe('frozen')\n      expect(enforceInfo.freezeId).toBe('freeze_task_1')\n    })\n\n    it('prepareTaskBilling tolerates unknown text model pricing in SHADOW mode', async () => {\n      modeMock.getBillingMode.mockResolvedValueOnce('SHADOW')\n      const unknownTextInfo = buildTaskInfo({\n        taskType: 'story_to_script_run',\n        apiType: 'text',\n        model: 'gpt-5.2',\n        quantity: 2400,\n        unit: 'token',\n        maxFrozenCost: 0,\n        action: 'story_to_script_run',\n      })\n\n      const shadow = await prepareTaskBilling({\n        id: 'task_shadow_unknown_text_model',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: unknownTextInfo,\n      })\n\n      const shadowInfo = shadow as Extract<TaskBillingInfo, { billable: true }>\n      expect(shadowInfo.status).toBe('skipped')\n      expect(shadowInfo.maxFrozenCost).toBe(0)\n    })\n\n    it('prepareTaskBilling throws InsufficientBalanceError when ENFORCE freeze fails', async () => {\n      modeMock.getBillingMode.mockResolvedValue('ENFORCE')\n      ledgerMock.freezeBalance.mockResolvedValue(null)\n      ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })\n\n      await expect(\n        prepareTaskBilling({\n          id: 'task_no_balance',\n          userId: 'u1',\n          projectId: 'p1',\n          billingInfo: buildTaskInfo(),\n        }),\n      ).rejects.toBeInstanceOf(InsufficientBalanceError)\n    })\n\n    it('settleTaskBilling handles SHADOW and non-ENFORCE snapshots', async () => {\n      const shadowSettled = await settleTaskBilling({\n        id: 'task_shadow_settle',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo({ modeSnapshot: 'SHADOW', status: 'quoted' }),\n      })\n      const shadowInfo = shadowSettled as Extract<TaskBillingInfo, { billable: true }>\n      expect(shadowInfo.status).toBe('settled')\n      expect(shadowInfo.chargedCost).toBe(0)\n      expect(ledgerMock.recordShadowUsage).toHaveBeenCalled()\n\n      const offSettled = await settleTaskBilling({\n        id: 'task_off_settle',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo({ modeSnapshot: 'OFF', status: 'quoted' }),\n      })\n      const offInfo = offSettled as Extract<TaskBillingInfo, { billable: true }>\n      expect(offInfo.status).toBe('settled')\n      expect(offInfo.chargedCost).toBe(0)\n    })\n\n    it('settleTaskBilling does not fail OFF snapshot when text usage model pricing is unknown', async () => {\n      const settled = await settleTaskBilling({\n        id: 'task_off_unknown_usage_model',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo({\n          taskType: 'story_to_script_run',\n          apiType: 'text',\n          model: 'gpt-5.2',\n          quantity: 2400,\n          unit: 'token',\n          maxFrozenCost: 0,\n          action: 'story_to_script_run',\n          modeSnapshot: 'OFF',\n          status: 'quoted',\n        }),\n      }, {\n        textUsage: [{ model: 'gpt-5.2', inputTokens: 1200, outputTokens: 800 }],\n      })\n\n      const settledInfo = settled as Extract<TaskBillingInfo, { billable: true }>\n      expect(settledInfo.status).toBe('settled')\n      expect(settledInfo.chargedCost).toBe(0)\n      expect(ledgerMock.recordShadowUsage).not.toHaveBeenCalled()\n    })\n\n    it('settleTaskBilling skips SHADOW settlement when text model pricing is unknown', async () => {\n      const settled = await settleTaskBilling({\n        id: 'task_shadow_unknown_usage_model',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo({\n          taskType: 'story_to_script_run',\n          apiType: 'text',\n          model: 'gpt-5.2',\n          quantity: 2400,\n          unit: 'token',\n          maxFrozenCost: 0,\n          action: 'story_to_script_run',\n          modeSnapshot: 'SHADOW',\n          status: 'quoted',\n        }),\n      }, {\n        textUsage: [{ model: 'gpt-5.2', inputTokens: 1200, outputTokens: 800 }],\n      })\n\n      const settledInfo = settled as Extract<TaskBillingInfo, { billable: true }>\n      expect(settledInfo.status).toBe('settled')\n      expect(settledInfo.chargedCost).toBe(0)\n      expect(ledgerMock.recordShadowUsage).not.toHaveBeenCalled()\n    })\n\n    it('settleTaskBilling handles ENFORCE success/failure branches', async () => {\n      ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)\n      const settled = await settleTaskBilling({\n        id: 'task_enforce_settle',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_ok' }),\n      })\n      expect((settled as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')\n\n      const missingFreeze = await settleTaskBilling({\n        id: 'task_enforce_no_freeze',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: null }),\n      })\n      expect((missingFreeze as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')\n\n      ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))\n      await expect(\n        settleTaskBilling({\n          id: 'task_enforce_confirm_fail',\n          userId: 'u1',\n          projectId: 'p1',\n          billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_fail' }),\n        }),\n      ).rejects.toThrow('confirm failed')\n    })\n\n    it('settleTaskBilling throws BILLING_CONFIRM_FAILED when confirm and rollback both fail', async () => {\n      ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))\n      ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))\n\n      await expect(\n        settleTaskBilling({\n          id: 'task_confirm_and_rollback_fail',\n          userId: 'u1',\n          projectId: 'p1',\n          billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail_confirm' }),\n        }),\n      ).rejects.toMatchObject({\n        name: 'BillingOperationError',\n        code: 'BILLING_CONFIRM_FAILED',\n      })\n    })\n\n    it('settleTaskBilling rethrows BillingOperationError with task context when rollback succeeds', async () => {\n      ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(\n        new BillingOperationError(\n          'BILLING_INVALID_FREEZE',\n          'invalid freeze',\n          { reason: 'status_mismatch' },\n        ),\n      )\n\n      let thrown: unknown = null\n      try {\n        await settleTaskBilling({\n          id: 'task_confirm_billing_error',\n          userId: 'u1',\n          projectId: 'p1',\n          billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_billing_error' }),\n        })\n      } catch (error) {\n        thrown = error\n      }\n\n      expect(thrown).toBeInstanceOf(BillingOperationError)\n      const billingError = thrown as BillingOperationError\n      expect(billingError.code).toBe('BILLING_INVALID_FREEZE')\n      expect(billingError.details).toMatchObject({\n        reason: 'status_mismatch',\n        taskId: 'task_confirm_billing_error',\n        freezeId: 'freeze_billing_error',\n      })\n    })\n\n    it('settleTaskBilling expands freeze when actual exceeds quoted', async () => {\n      ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)\n      const settled = await settleTaskBilling({\n        id: 'task_enforce_overage',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_overage', quantity: 5 }),\n      }, {\n        result: { actualDurationSeconds: 50 },\n      })\n      expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)\n      expect(ledgerMock.confirmChargeWithRecord).toHaveBeenCalled()\n      expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(calcVoice(50), 8)\n    })\n\n    it('settleTaskBilling keeps quoted charge when text usage has no token counts', async () => {\n      const quoted = calcText('anthropic/claude-sonnet-4', 500, 500)\n      const textBillingInfo: Extract<TaskBillingInfo, { billable: true }> = {\n        billable: true,\n        source: 'task',\n        taskType: 'analyze_novel',\n        apiType: 'text',\n        model: 'anthropic/claude-sonnet-4',\n        quantity: 1000,\n        unit: 'token',\n        maxFrozenCost: quoted,\n        action: 'analyze_novel',\n        modeSnapshot: 'ENFORCE',\n        status: 'frozen',\n        freezeId: 'freeze_text_zero',\n      }\n      ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)\n\n      const settled = await settleTaskBilling({\n        id: 'task_text_zero_usage',\n        userId: 'u1',\n        projectId: 'p1',\n        billingInfo: textBillingInfo,\n      }, {\n        textUsage: [{ model: 'openai/gpt-5', inputTokens: 0, outputTokens: 0 }],\n      })\n\n      expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(quoted, 8)\n      const recordParams = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)?.[1] as { model: string }\n      expect(recordParams.model).toBe('openai/gpt-5')\n    })\n\n    it('rollbackTaskBilling handles success and fallback branches', async () => {\n      const rolledBack = await rollbackTaskBilling({\n        id: 'task_rb_ok',\n        billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_ok' }),\n      })\n      expect((rolledBack as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')\n\n      ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))\n      const rollbackFailed = await rollbackTaskBilling({\n        id: 'task_rb_fail',\n        billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail' }),\n      })\n      expect((rollbackFailed as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/billing/task-policy.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { buildDefaultTaskBillingInfo, isBillableTaskType } from '@/lib/billing/task-policy'\nimport type { TaskBillingInfo } from '@/lib/task/types'\n\nfunction expectBillableInfo(info: TaskBillingInfo | null): Extract<TaskBillingInfo, { billable: true }> {\n  expect(info).toBeTruthy()\n  expect(info?.billable).toBe(true)\n  if (!info || !info.billable) {\n    throw new Error('Expected billable task billing info')\n  }\n  return info\n}\n\ndescribe('billing/task-policy', () => {\n  const billingPayload = {\n    analysisModel: 'anthropic/claude-sonnet-4',\n    imageModel: 'seedream',\n    videoModel: 'doubao-seedance-1-5-pro-251215',\n  } as const\n\n  it('builds TaskBillingInfo for every billable task type', () => {\n    for (const taskType of Object.values(TASK_TYPE)) {\n      if (!isBillableTaskType(taskType)) continue\n      const info = expectBillableInfo(buildDefaultTaskBillingInfo(taskType, billingPayload))\n      expect(info.taskType).toBe(taskType)\n      expect(info.maxFrozenCost).toBeGreaterThanOrEqual(0)\n    }\n  })\n\n  it('returns null for a non-billable task type', () => {\n    const fake = 'not_billable' as unknown as (typeof TASK_TYPE)[keyof typeof TASK_TYPE]\n    expect(isBillableTaskType(fake)).toBe(false)\n    expect(buildDefaultTaskBillingInfo(fake, {})).toBeNull()\n  })\n\n  it('builds text billing info from explicit model payload', () => {\n    const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {\n      analysisModel: 'anthropic/claude-sonnet-4',\n    }))\n    expect(info.apiType).toBe('text')\n    expect(info.model).toBe('anthropic/claude-sonnet-4')\n    expect(info.quantity).toBe(4200)\n  })\n\n  it('returns null for missing required models in text/image/video tasks', () => {\n    expect(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {})).toBeNull()\n    expect(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {})).toBeNull()\n    expect(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {})).toBeNull()\n  })\n\n  it('honors candidateCount/count for image tasks', () => {\n    const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {\n      candidateCount: 4,\n      imageModel: 'seedream4',\n    }))\n    expect(info.apiType).toBe('image')\n    expect(info.quantity).toBe(4)\n    expect(info.model).toBe('seedream4')\n  })\n\n  it('builds video billing info from firstLastFrame.flModel', () => {\n    const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {\n      firstLastFrame: {\n        flModel: 'doubao-seedance-1-0-pro-250528',\n      },\n      duration: 8,\n    }))\n    expect(info.apiType).toBe('video')\n    expect(info.model).toBe('doubao-seedance-1-0-pro-250528')\n    expect(info.quantity).toBe(1)\n  })\n\n  it('uses explicit lip sync model from payload', () => {\n    const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.LIP_SYNC, {\n      lipSyncModel: 'vidu::vidu-lipsync',\n    }))\n    expect(info.apiType).toBe('lip-sync')\n    expect(info.model).toBe('vidu::vidu-lipsync')\n    expect(info.quantity).toBe(1)\n  })\n})\n"
  },
  {
    "path": "tests/unit/components/character-creation-modal.test.ts",
    "content": "import * as React from 'react'\nimport { createElement } from 'react'\nimport type { ComponentProps, ReactElement } from 'react'\nimport { describe, expect, it, vi } from 'vitest'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { NextIntlClientProvider } from 'next-intl'\nimport type { AbstractIntlMessages } from 'next-intl'\nimport { CharacterCreationModal } from '@/components/shared/assets/CharacterCreationModal'\n\nvi.mock('@/lib/query/hooks', () => ({\n  useProjectAssets: vi.fn(() => ({ data: { characters: [] } })),\n}))\n\nvi.mock('@/components/shared/assets/character-creation/hooks/useCharacterCreationSubmit', () => ({\n  useCharacterCreationSubmit: vi.fn(() => ({\n    isSubmitting: false,\n    isAiDesigning: false,\n    isExtracting: false,\n    characterGenerationCount: 3,\n    setCharacterGenerationCount: vi.fn(),\n    referenceCharacterGenerationCount: 3,\n    setReferenceCharacterGenerationCount: vi.fn(),\n    handleExtractDescription: vi.fn(),\n    handleCreateWithReference: vi.fn(),\n    handleAiDesign: vi.fn(),\n    handleSubmit: vi.fn(),\n    handleSubmitAndGenerate: vi.fn(),\n  })),\n}))\n\nconst messages = {\n  assetModal: {\n    character: {\n      title: '新建角色',\n      name: '角色名称',\n      namePlaceholder: '请输入角色名称',\n      modeReference: '参考图模式',\n      modeDescription: '描述模式',\n      uploadReference: '上传参考图',\n      pasteHint: 'Ctrl+V 粘贴',\n      generationMode: '生成方式',\n      directGenerate: '直接生成',\n      extractPrompt: '反推提示词',\n      extractFirst: '先提取描述',\n      description: '角色描述',\n      descPlaceholder: '请输入角色外貌描述...',\n      isSubAppearance: '这是一个子形象',\n      isSubAppearanceHint: '为已有角色添加新的形象状态',\n      selectMainCharacter: '选择主角色',\n      selectCharacterPlaceholder: '请选择角色...',\n      appearancesCount: '{count} 个形象',\n      changeReason: '形象变化原因',\n      changeReasonPlaceholder: '例如',\n      useReferenceGeneratePrefix: '使用参考图生成',\n      generateCountSuffix: '张图片',\n      selectReferenceGenerateCount: '选择参考图生成数量',\n    },\n    artStyle: { title: '画面风格' },\n    aiDesign: {\n      title: 'AI 设计',\n      placeholder: '描述你想要的角色特征...',\n      generating: '设计中...',\n      generate: '生成',\n    },\n    common: {\n      creating: '创建中...',\n      cancel: '取消',\n      adding: '添加中...',\n      add: '添加',\n      addOnly: '仅添加角色',\n      addOnlyToAssetHub: '仅添加人物到资产库',\n      addAndGeneratePrefix: '添加并生成',\n      generateCountSuffix: '张图片',\n      selectGenerateCount: '选择生成数量',\n      optional: '（可选）',\n    },\n    errors: {\n      uploadFailed: '上传失败',\n      extractDescriptionFailed: '提取描述失败',\n      createFailed: '创建失败',\n      aiDesignFailed: 'AI 设计失败',\n      addSubAppearanceFailed: '添加子形象失败',\n      insufficientBalance: '账户余额不足',\n    },\n  },\n} as const\n\nconst renderWithIntl = (node: ReactElement) => {\n  const providerProps: ComponentProps<typeof NextIntlClientProvider> = {\n    locale: 'zh',\n    messages: messages as unknown as AbstractIntlMessages,\n    timeZone: 'Asia/Shanghai',\n    children: node,\n  }\n\n  return renderToStaticMarkup(\n    createElement(NextIntlClientProvider, providerProps),\n  )\n}\n\ndescribe('CharacterCreationModal', () => {\n  it('renders add-only and add-and-generate actions in the fixed footer', () => {\n    Reflect.set(globalThis, 'React', React)\n    const html = renderWithIntl(\n      createElement(CharacterCreationModal, {\n        mode: 'asset-hub',\n        onClose: () => undefined,\n        onSuccess: () => undefined,\n      }),\n    )\n\n    expect(html).toContain('仅添加人物到资产库')\n    expect(html).toContain('添加并生成')\n    expect(html).toContain('取消')\n  })\n})\n"
  },
  {
    "path": "tests/unit/components/image-generation-inline-count-button.test.ts",
    "content": "import * as React from 'react'\nimport { createElement } from 'react'\nimport { describe, expect, it } from 'vitest'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'\n\ndescribe('ImageGenerationInlineCountButton', () => {\n  it('keeps the select enabled when only the action is disabled', () => {\n    Reflect.set(globalThis, 'React', React)\n\n    const html = renderToStaticMarkup(\n      createElement(ImageGenerationInlineCountButton, {\n        prefix: createElement('span', null, '生成'),\n        suffix: createElement('span', null, '张图片'),\n        value: 3,\n        options: [1, 2, 3],\n        onValueChange: () => undefined,\n        onClick: () => undefined,\n        actionDisabled: true,\n        selectDisabled: false,\n        ariaLabel: '选择生成数量',\n      }),\n    )\n\n    expect(html).toContain('aria-disabled=\"true\"')\n    expect(html).toContain('opacity-60 cursor-not-allowed')\n    expect(html).not.toContain('<select disabled=\"\"')\n  })\n})\n"
  },
  {
    "path": "tests/unit/components/llm-stage-stream-card-error.test.ts",
    "content": "import * as React from 'react'\nimport { createElement } from 'react'\nimport type { ComponentProps, ReactElement } from 'react'\nimport { describe, expect, it } from 'vitest'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { NextIntlClientProvider } from 'next-intl'\nimport type { AbstractIntlMessages } from 'next-intl'\nimport LLMStageStreamCard from '@/components/llm-console/LLMStageStreamCard'\n\nconst messages = {\n  progress: {\n    status: {\n      completed: '已完成',\n      failed: '失败',\n      processing: '进行中',\n      queued: '排队中',\n      pending: '未开始',\n    },\n    stageCard: {\n      stage: '阶段',\n      realtimeStream: '实时流',\n      currentStage: '当前阶段',\n      outputTitle: 'AI 实时输出 · {stage}',\n      waitingModelOutput: '等待模型输出...',\n      reasoningNotProvided: '该步骤未返回思考过程',\n    },\n    runtime: {\n      llm: {\n        processing: '模型处理中...',\n      },\n    },\n  },\n} as const\n\nconst renderWithIntl = (node: ReactElement) => {\n  const providerProps: ComponentProps<typeof NextIntlClientProvider> = {\n    locale: 'zh',\n    messages: messages as unknown as AbstractIntlMessages,\n    timeZone: 'Asia/Shanghai',\n    children: node,\n  }\n\n  return renderToStaticMarkup(\n    createElement(NextIntlClientProvider, providerProps),\n  )\n}\n\ndescribe('LLMStageStreamCard error rendering', () => {\n  it('renders the error without any feedback action entry', () => {\n    Reflect.set(globalThis, 'React', React)\n    const html = renderWithIntl(\n      createElement(LLMStageStreamCard, {\n        title: '内容到剧本',\n        stages: [{\n          id: 'story_to_script',\n          title: '内容到剧本',\n          status: 'failed',\n          progress: 0,\n        }],\n        activeStageId: 'story_to_script',\n        outputText: '',\n        errorMessage: 'Failed to fetch',\n      }),\n    )\n\n    expect(html).toContain('Failed to fetch')\n    expect(html).not.toContain('复制错误详情')\n    expect(html).not.toContain('打开问题反馈表单')\n    expect(html).not.toContain('Copy error detail')\n    expect(html).not.toContain('Open feedback form')\n  })\n})\n"
  },
  {
    "path": "tests/unit/components/location-creation-modal.test.ts",
    "content": "import * as React from 'react'\nimport { createElement } from 'react'\nimport type { ComponentProps, ReactElement } from 'react'\nimport { describe, expect, it, vi } from 'vitest'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { NextIntlClientProvider } from 'next-intl'\nimport type { AbstractIntlMessages } from 'next-intl'\nimport { LocationCreationModal } from '@/components/shared/assets/LocationCreationModal'\n\nvi.mock('@/lib/query/hooks', () => ({\n  useAiCreateProjectLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),\n  useAiDesignLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),\n  useCreateAssetHubLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),\n  useGenerateLocationImage: vi.fn(() => ({ mutateAsync: vi.fn() })),\n  useCreateProjectLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),\n  useGenerateProjectLocationImage: vi.fn(() => ({ mutateAsync: vi.fn() })),\n}))\n\nconst messages = {\n  assetModal: {\n    location: {\n      title: '新建场景',\n      name: '场景名称',\n      namePlaceholder: '请输入场景名称',\n      description: '场景描述',\n      descPlaceholder: '请输入场景描述...',\n    },\n    artStyle: { title: '画面风格' },\n    aiDesign: {\n      title: 'AI 设计',\n      placeholderLocation: '描述场景氛围和环境...',\n      generating: '设计中...',\n      generate: '生成',\n      tip: '输入简单描述，AI 帮你生成详细设定',\n    },\n    common: {\n      cancel: '取消',\n      addOnlyLocation: '仅添加场景',\n      addOnlyToAssetHubLocation: '仅添加场景到资产库',\n      addAndGeneratePrefix: '添加并生成',\n      generateCountSuffix: '张图片',\n      selectGenerateCount: '选择生成数量',\n      optional: '（可选）',\n    },\n    errors: {\n      createFailed: '创建失败',\n      aiDesignFailed: 'AI 设计失败',\n      insufficientBalance: '账户余额不足',\n    },\n  },\n} as const\n\nconst renderWithIntl = (node: ReactElement) => {\n  const providerProps: ComponentProps<typeof NextIntlClientProvider> = {\n    locale: 'zh',\n    messages: messages as unknown as AbstractIntlMessages,\n    timeZone: 'Asia/Shanghai',\n    children: node,\n  }\n\n  return renderToStaticMarkup(\n    createElement(NextIntlClientProvider, providerProps),\n  )\n}\n\ndescribe('LocationCreationModal', () => {\n  it('renders add-only and add-and-generate actions in the fixed footer', () => {\n    Reflect.set(globalThis, 'React', React)\n    const html = renderWithIntl(\n      createElement(LocationCreationModal, {\n        mode: 'asset-hub',\n        onClose: () => undefined,\n        onSuccess: () => undefined,\n      }),\n    )\n\n    expect(html).toContain('仅添加场景到资产库')\n    expect(html).toContain('添加并生成')\n    expect(html).toContain('取消')\n  })\n})\n"
  },
  {
    "path": "tests/unit/components/navbar-download-logs.test.ts",
    "content": "import * as React from 'react'\nimport { createElement } from 'react'\nimport type { ComponentProps, ReactElement } from 'react'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { NextIntlClientProvider } from 'next-intl'\nimport type { AbstractIntlMessages } from 'next-intl'\nimport Navbar from '@/components/Navbar'\n\nconst useSessionMock = vi.fn()\n\nvi.mock('next-auth/react', () => ({\n  useSession: () => useSessionMock(),\n}))\n\nvi.mock('next/image', () => ({\n  default: ({ alt, ...props }: { alt: string } & Record<string, unknown>) => createElement('img', { alt, ...props }),\n}))\n\nvi.mock('@/components/LanguageSwitcher', () => ({\n  default: () => createElement('div', null, 'LanguageSwitcher'),\n}))\n\nvi.mock('@/hooks/common/useGithubReleaseUpdate', () => ({\n  useGithubReleaseUpdate: () => ({\n    currentVersion: '0.3.0',\n    update: null,\n    shouldPulse: false,\n    showModal: false,\n    openModal: () => undefined,\n    dismissCurrentUpdate: () => undefined,\n    checkNow: async () => undefined,\n  }),\n}))\n\nvi.mock('@/i18n/navigation', () => ({\n  Link: ({\n    href,\n    children,\n    ...props\n  }: {\n    href: string | { pathname: string }\n    children: React.ReactNode\n  } & Record<string, unknown>) => {\n    const resolvedHref = typeof href === 'string' ? href : href.pathname\n    return createElement('a', { href: resolvedHref, ...props }, children)\n  },\n}))\n\nconst messages = {\n  nav: {\n    workspace: '工作区',\n    assetHub: '资产中心',\n    profile: '设置中心',\n    downloadLogs: '下载日志',\n    signin: '登录',\n    signup: '注册',\n  },\n  common: {\n    appName: 'waoowaoo',\n    betaVersion: 'Beta v{version}',\n    updateNotice: {\n      openDialog: '打开更新弹窗',\n      updateTag: '更新',\n      checkUpdate: '检查更新',\n      upToDate: '已是最新版本',\n    },\n  },\n} as const\n\nconst renderWithIntl = (node: ReactElement) => {\n  const providerProps: ComponentProps<typeof NextIntlClientProvider> = {\n    locale: 'zh',\n    messages: messages as unknown as AbstractIntlMessages,\n    timeZone: 'Asia/Shanghai',\n    children: node,\n  }\n\n  return renderToStaticMarkup(\n    createElement(NextIntlClientProvider, providerProps),\n  )\n}\n\ndescribe('Navbar download logs entry', () => {\n  beforeEach(() => {\n    useSessionMock.mockReset()\n  })\n\n  it('renders the download logs entry on the far-right action group for signed-in users', () => {\n    Reflect.set(globalThis, 'React', React)\n    useSessionMock.mockReturnValue({\n      data: { user: { name: 'Earth' } },\n      status: 'authenticated',\n    })\n\n    const html = renderWithIntl(createElement(Navbar))\n\n    expect(html).toContain('下载日志')\n    expect(html).toContain('href=\"/api/admin/download-logs\"')\n    expect(html).toContain('download=\"\"')\n  })\n\n  it('does not render the download logs entry for signed-out users', () => {\n    Reflect.set(globalThis, 'React', React)\n    useSessionMock.mockReturnValue({\n      data: null,\n      status: 'unauthenticated',\n    })\n\n    const html = renderWithIntl(createElement(Navbar))\n\n    expect(html).not.toContain('下载日志')\n    expect(html).not.toContain('/api/admin/download-logs')\n  })\n})\n"
  },
  {
    "path": "tests/unit/components/voice-design-shared.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest'\nimport {\n  DEFAULT_VOICE_SCHEME_COUNT,\n  MAX_VOICE_SCHEME_COUNT,\n  MIN_VOICE_SCHEME_COUNT,\n  generateVoiceDesignOptions,\n  normalizeVoiceSchemeCount,\n} from '@/components/voice/voice-design-shared'\n\ndescribe('voice-design-shared', () => {\n  it('clamps scheme count into the supported range', () => {\n    expect(normalizeVoiceSchemeCount(undefined)).toBe(DEFAULT_VOICE_SCHEME_COUNT)\n    expect(normalizeVoiceSchemeCount('not-a-number')).toBe(DEFAULT_VOICE_SCHEME_COUNT)\n    expect(normalizeVoiceSchemeCount(0)).toBe(MIN_VOICE_SCHEME_COUNT)\n    expect(normalizeVoiceSchemeCount(99)).toBe(MAX_VOICE_SCHEME_COUNT)\n    expect(normalizeVoiceSchemeCount('5')).toBe(5)\n  })\n\n  it('generates the requested number of voice options with default preview text fallback', async () => {\n    const onDesignVoice = vi\n      .fn<(_: {\n        voicePrompt: string\n        previewText: string\n        preferredName: string\n        language: 'zh'\n      }) => Promise<{ voiceId: string; audioBase64: string }>>()\n      .mockResolvedValueOnce({ voiceId: 'voice-1', audioBase64: 'audio-1' })\n      .mockResolvedValueOnce({ voiceId: 'voice-2', audioBase64: 'audio-2' })\n      .mockResolvedValueOnce({ voiceId: 'voice-3', audioBase64: 'audio-3' })\n      .mockResolvedValueOnce({ voiceId: 'voice-4', audioBase64: 'audio-4' })\n\n    const result = await generateVoiceDesignOptions({\n      count: '4',\n      voicePrompt: ' 温柔女声 ',\n      previewText: '   ',\n      defaultPreviewText: '默认试听文案',\n      onDesignVoice,\n      createPreferredName: (index) => `preferred-${index + 1}`,\n    })\n\n    expect(result).toEqual([\n      { voiceId: 'voice-1', audioBase64: 'audio-1', audioUrl: 'data:audio/wav;base64,audio-1' },\n      { voiceId: 'voice-2', audioBase64: 'audio-2', audioUrl: 'data:audio/wav;base64,audio-2' },\n      { voiceId: 'voice-3', audioBase64: 'audio-3', audioUrl: 'data:audio/wav;base64,audio-3' },\n      { voiceId: 'voice-4', audioBase64: 'audio-4', audioUrl: 'data:audio/wav;base64,audio-4' },\n    ])\n    expect(onDesignVoice.mock.calls).toEqual([\n      [{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-1', language: 'zh' }],\n      [{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-2', language: 'zh' }],\n      [{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-3', language: 'zh' }],\n      [{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-4', language: 'zh' }],\n    ])\n  })\n\n  it('fails explicitly when a designed voice is missing voiceId', async () => {\n    const onDesignVoice = vi.fn(async () => ({ voiceId: '', audioBase64: 'audio-only' }))\n\n    await expect(\n      generateVoiceDesignOptions({\n        count: 1,\n        voicePrompt: '旁白',\n        previewText: '测试',\n        defaultPreviewText: '默认试听文案',\n        onDesignVoice,\n      }),\n    ).rejects.toThrow('VOICE_DESIGN_INVALID_RESPONSE: missing voiceId')\n  })\n})\n"
  },
  {
    "path": "tests/unit/generator-api-openai-template-required.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst resolveModelSelectionMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    provider: 'openai-compatible:oa-1',\n    modelId: 'gpt-image-1',\n    modelKey: 'openai-compatible:oa-1::gpt-image-1',\n    mediaType: 'image',\n    compatMediaTemplate: undefined,\n  })),\n)\nconst getProviderConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'openai-compatible:oa-1',\n    name: 'OpenAI Compat',\n    apiKey: 'oa-key',\n    gatewayRoute: 'openai-compat' as const,\n  })),\n)\nconst resolveModelGatewayRouteMock = vi.hoisted(() => vi.fn(() => 'openai-compat'))\nconst generateImageViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'image' })))\nconst generateVideoViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'video' })))\nconst generateImageViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'image' })))\nconst generateVideoViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'video' })))\n\nvi.mock('@/lib/api-config', () => ({\n  resolveModelSelection: resolveModelSelectionMock,\n  getProviderConfig: getProviderConfigMock,\n  getProviderKey: (providerId: string) => providerId.split(':')[0] || providerId,\n}))\n\nvi.mock('@/lib/model-gateway', () => ({\n  resolveModelGatewayRoute: resolveModelGatewayRouteMock,\n  generateImageViaOpenAICompat: generateImageViaOpenAICompatMock,\n  generateVideoViaOpenAICompat: generateVideoViaOpenAICompatMock,\n  generateImageViaOpenAICompatTemplate: generateImageViaOpenAICompatTemplateMock,\n  generateVideoViaOpenAICompatTemplate: generateVideoViaOpenAICompatTemplateMock,\n}))\n\nvi.mock('@/lib/generators/factory', () => ({\n  createImageGenerator: vi.fn(() => ({ generate: vi.fn() })),\n  createVideoGenerator: vi.fn(() => ({ generate: vi.fn() })),\n  createAudioGenerator: vi.fn(() => ({ generate: vi.fn() })),\n}))\n\nvi.mock('@/lib/providers/bailian', () => ({\n  generateBailianImage: vi.fn(),\n  generateBailianVideo: vi.fn(),\n  generateBailianAudio: vi.fn(),\n}))\n\nvi.mock('@/lib/providers/siliconflow', () => ({\n  generateSiliconFlowImage: vi.fn(),\n  generateSiliconFlowVideo: vi.fn(),\n  generateSiliconFlowAudio: vi.fn(),\n}))\n\nimport { generateImage, generateVideo } from '@/lib/generator-api'\n\ndescribe('generator-api requires compat media template for openai-compatible media', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    resolveModelGatewayRouteMock.mockReturnValue('openai-compat')\n    getProviderConfigMock.mockResolvedValue({\n      id: 'openai-compatible:oa-1',\n      name: 'OpenAI Compat',\n      apiKey: 'oa-key',\n      gatewayRoute: 'openai-compat',\n    })\n  })\n\n  it('throws for image model without compatMediaTemplate', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'openai-compatible:oa-1',\n      modelId: 'gpt-image-1',\n      modelKey: 'openai-compatible:oa-1::gpt-image-1',\n      mediaType: 'image',\n      compatMediaTemplate: undefined,\n    })\n\n    await expect(\n      generateImage('user-1', 'openai-compatible:oa-1::gpt-image-1', 'draw cat'),\n    ).rejects.toThrow('MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED')\n\n    expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()\n    expect(generateImageViaOpenAICompatTemplateMock).not.toHaveBeenCalled()\n  })\n\n  it('throws for video model without compatMediaTemplate', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'openai-compatible:oa-1',\n      modelId: 'veo3.1',\n      modelKey: 'openai-compatible:oa-1::veo3.1',\n      mediaType: 'video',\n      compatMediaTemplate: undefined,\n    })\n\n    await expect(\n      generateVideo('user-1', 'openai-compatible:oa-1::veo3.1', 'https://example.com/a.png', { prompt: 'animate' }),\n    ).rejects.toThrow('MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED')\n\n    expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()\n    expect(generateVideoViaOpenAICompatTemplateMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/unit/generator-api.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst resolveModelSelectionMock = vi.hoisted(() =>\n  vi.fn<typeof import('@/lib/api-config').resolveModelSelection>(async () => ({\n    provider: 'google',\n    modelId: 'gemini-3.1',\n    modelKey: 'google::gemini-3.1',\n    mediaType: 'image',\n  })),\n)\nconst getProviderConfigMock = vi.hoisted(() =>\n  vi.fn<typeof import('@/lib/api-config').getProviderConfig>(async () => ({\n    id: 'google',\n    name: 'Google',\n    apiKey: 'google-key',\n    apiMode: undefined,\n    gatewayRoute: undefined,\n  })),\n)\n\nconst generateImageViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'compat-image' })))\nconst generateVideoViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'compat-video' })))\nconst generateImageViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'compat-template-image' })))\nconst generateVideoViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'compat-template-video' })))\nconst resolveModelGatewayRouteMock = vi.hoisted(() => vi.fn(() => 'official'))\n\nconst imageGeneratorGenerateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'official-image' })))\nconst videoGeneratorGenerateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'official-video' })))\nconst audioGeneratorGenerateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, audioUrl: 'audio' })))\n\nconst createImageGeneratorMock = vi.hoisted(() => vi.fn(() => ({ generate: imageGeneratorGenerateMock })))\nconst createVideoGeneratorMock = vi.hoisted(() => vi.fn(() => ({ generate: videoGeneratorGenerateMock })))\nconst createAudioGeneratorMock = vi.hoisted(() => vi.fn(() => ({ generate: audioGeneratorGenerateMock })))\nconst generateBailianImageMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'bailian-image' })))\nconst generateBailianVideoMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'bailian-video' })))\nconst generateBailianAudioMock = vi.hoisted(() => vi.fn(async () => ({ success: true, audioUrl: 'bailian-audio' })))\nconst generateSiliconFlowImageMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'siliconflow-image' })))\nconst generateSiliconFlowVideoMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'siliconflow-video' })))\nconst generateSiliconFlowAudioMock = vi.hoisted(() => vi.fn(async () => ({ success: true, audioUrl: 'siliconflow-audio' })))\n\nvi.mock('@/lib/api-config', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('@/lib/api-config')>()\n  return {\n    ...actual,\n    resolveModelSelection: resolveModelSelectionMock,\n    getProviderConfig: getProviderConfigMock,\n  }\n})\n\nvi.mock('@/lib/model-gateway', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('@/lib/model-gateway')>()\n  return {\n    ...actual,\n    generateImageViaOpenAICompat: generateImageViaOpenAICompatMock,\n    generateVideoViaOpenAICompat: generateVideoViaOpenAICompatMock,\n    generateImageViaOpenAICompatTemplate: generateImageViaOpenAICompatTemplateMock,\n    generateVideoViaOpenAICompatTemplate: generateVideoViaOpenAICompatTemplateMock,\n    resolveModelGatewayRoute: resolveModelGatewayRouteMock,\n  }\n})\n\nvi.mock('@/lib/generators/factory', () => ({\n  createImageGenerator: createImageGeneratorMock,\n  createVideoGenerator: createVideoGeneratorMock,\n  createAudioGenerator: createAudioGeneratorMock,\n}))\n\nvi.mock('@/lib/providers/bailian', () => ({\n  generateBailianImage: generateBailianImageMock,\n  generateBailianVideo: generateBailianVideoMock,\n  generateBailianAudio: generateBailianAudioMock,\n}))\n\nvi.mock('@/lib/providers/siliconflow', () => ({\n  generateSiliconFlowImage: generateSiliconFlowImageMock,\n  generateSiliconFlowVideo: generateSiliconFlowVideoMock,\n  generateSiliconFlowAudio: generateSiliconFlowAudioMock,\n}))\n\nimport { generateAudio, generateImage, generateVideo } from '@/lib/generator-api'\n\ndescribe('generator-api gateway routing', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    resolveModelGatewayRouteMock.mockReset()\n    resolveModelGatewayRouteMock.mockReturnValue('official')\n    getProviderConfigMock.mockResolvedValue({\n      id: 'google',\n      name: 'Google',\n      apiKey: 'google-key',\n      apiMode: undefined,\n      gatewayRoute: undefined,\n    })\n  })\n\n  it('routes openai-compatible image requests to openai-compat gateway', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'openai-compatible:oa-1',\n      modelId: 'gpt-image-1',\n      modelKey: 'openai-compatible:oa-1::gpt-image-1',\n      mediaType: 'image',\n      compatMediaTemplate: {\n        version: 1,\n        mediaType: 'image',\n        mode: 'sync',\n        create: { method: 'POST', path: '/v1/images/generations' },\n        response: { outputUrlPath: 'data[0].url' },\n      },\n    })\n    resolveModelGatewayRouteMock.mockReturnValueOnce('openai-compat')\n\n    const result = await generateImage('user-1', 'openai-compatible:oa-1::gpt-image-1', 'draw cat', {\n      size: '1024x1024',\n    })\n\n    expect(generateImageViaOpenAICompatTemplateMock).toHaveBeenCalledTimes(1)\n    expect(createImageGeneratorMock).not.toHaveBeenCalled()\n    expect(result).toEqual({ success: true, imageUrl: 'compat-template-image' })\n  })\n\n  it('routes official image requests to provider generator', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'google',\n      modelId: 'imagen-4.0',\n      modelKey: 'google::imagen-4.0',\n      mediaType: 'image',\n    })\n    resolveModelGatewayRouteMock.mockReturnValueOnce('official')\n\n    const result = await generateImage('user-1', 'google::imagen-4.0', 'draw house')\n\n    expect(createImageGeneratorMock).toHaveBeenCalledWith('google', 'imagen-4.0')\n    expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()\n    expect(result).toEqual({ success: true, imageUrl: 'official-image' })\n  })\n\n  it('routes gemini-compatible image to official generator', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'gemini-compatible:gm-1',\n      modelId: 'gemini-2.5-flash-image-preview',\n      modelKey: 'gemini-compatible:gm-1::gemini-2.5-flash-image-preview',\n      mediaType: 'image',\n    })\n    getProviderConfigMock.mockResolvedValueOnce({\n      id: 'gemini-compatible:gm-1',\n      name: 'Gemini Compatible',\n      apiKey: 'gm-key',\n      baseUrl: 'https://gm.test',\n      apiMode: 'gemini-sdk',\n      gatewayRoute: 'official',\n    })\n\n    const result = await generateImage(\n      'user-1',\n      'gemini-compatible:gm-1::gemini-2.5-flash-image-preview',\n      'draw cat',\n      { aspectRatio: '3:4' },\n    )\n\n    expect(createImageGeneratorMock).toHaveBeenCalledWith('gemini-compatible:gm-1', 'gemini-2.5-flash-image-preview')\n    expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()\n    expect(result).toEqual({ success: true, imageUrl: 'official-image' })\n  })\n\n  it('routes openai-compatible video requests to openai-compat gateway', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'openai-compatible:oa-1',\n      modelId: 'sora-2',\n      modelKey: 'openai-compatible:oa-1::sora-2',\n      mediaType: 'video',\n      compatMediaTemplate: {\n        version: 1,\n        mediaType: 'video',\n        mode: 'async',\n        create: { method: 'POST', path: '/v1/videos/generations' },\n        response: { taskIdPath: 'id' },\n      },\n    })\n    resolveModelGatewayRouteMock.mockReturnValueOnce('openai-compat')\n\n    const result = await generateVideo(\n      'user-1',\n      'openai-compatible:oa-1::sora-2',\n      'https://example.com/source.png',\n      { prompt: 'animate' },\n    )\n\n    expect(generateVideoViaOpenAICompatTemplateMock).toHaveBeenCalledTimes(1)\n    expect(createVideoGeneratorMock).not.toHaveBeenCalled()\n    expect(result).toEqual({ success: true, videoUrl: 'compat-template-video' })\n  })\n\n  it('routes gemini-compatible video to official provider generator', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'gemini-compatible:gm-1',\n      modelId: 'veo-3.1-generate-preview',\n      modelKey: 'gemini-compatible:gm-1::veo-3.1-generate-preview',\n      mediaType: 'video',\n    })\n    resolveModelGatewayRouteMock.mockReturnValueOnce('official')\n\n    const result = await generateVideo('user-1', 'gemini-compatible:gm-1::veo-3.1-generate-preview', 'https://example.com/source.png')\n\n    expect(createVideoGeneratorMock).toHaveBeenCalledWith('gemini-compatible:gm-1')\n    expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()\n    expect(result).toEqual({ success: true, videoUrl: 'official-video' })\n  })\n\n  it('routes official video requests to provider generator', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'fal',\n      modelId: 'kling',\n      modelKey: 'fal::kling',\n      mediaType: 'video',\n    })\n    resolveModelGatewayRouteMock.mockReturnValueOnce('official')\n\n    const result = await generateVideo('user-1', 'fal::kling', 'https://example.com/source.png')\n\n    expect(createVideoGeneratorMock).toHaveBeenCalledWith('fal')\n    expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()\n    expect(result).toEqual({ success: true, videoUrl: 'official-video' })\n  })\n\n  it('keeps audio generation on provider generator path', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'fal',\n      modelId: 'tts-1',\n      modelKey: 'fal::tts-1',\n      mediaType: 'audio',\n    })\n\n    const result = await generateAudio('user-1', 'fal::tts-1', 'hello')\n\n    expect(createAudioGeneratorMock).toHaveBeenCalledWith('fal')\n    expect(result).toEqual({ success: true, audioUrl: 'audio' })\n  })\n\n  it('routes bailian image generation to official provider adapter', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'bailian',\n      modelId: 'wanx-image',\n      modelKey: 'bailian::wanx-image',\n      mediaType: 'image',\n    })\n    getProviderConfigMock.mockResolvedValueOnce({\n      id: 'bailian',\n      name: 'Bailian',\n      apiKey: 'bl-key',\n      gatewayRoute: 'official',\n      apiMode: undefined,\n    })\n\n    const result = await generateImage('user-1', 'bailian::wanx-image', 'draw sky')\n\n    expect(generateBailianImageMock).toHaveBeenCalledTimes(1)\n    expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()\n    expect(createImageGeneratorMock).not.toHaveBeenCalled()\n    expect(result).toEqual({ success: true, imageUrl: 'bailian-image' })\n  })\n\n  it('routes siliconflow video generation to official provider adapter', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'siliconflow',\n      modelId: 'sf-video',\n      modelKey: 'siliconflow::sf-video',\n      mediaType: 'video',\n    })\n    getProviderConfigMock.mockResolvedValueOnce({\n      id: 'siliconflow',\n      name: 'SiliconFlow',\n      apiKey: 'sf-key',\n      gatewayRoute: 'official',\n      apiMode: undefined,\n    })\n\n    const result = await generateVideo('user-1', 'siliconflow::sf-video', 'https://example.com/source.png', {\n      prompt: 'animate',\n    })\n\n    expect(generateSiliconFlowVideoMock).toHaveBeenCalledTimes(1)\n    expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()\n    expect(createVideoGeneratorMock).not.toHaveBeenCalled()\n    expect(result).toEqual({ success: true, videoUrl: 'siliconflow-video' })\n  })\n\n  it('routes bailian audio generation to official provider adapter', async () => {\n    resolveModelSelectionMock.mockResolvedValueOnce({\n      provider: 'bailian',\n      modelId: 'bailian-tts',\n      modelKey: 'bailian::bailian-tts',\n      mediaType: 'audio',\n    })\n\n    const result = await generateAudio('user-1', 'bailian::bailian-tts', 'hello')\n\n    expect(generateBailianAudioMock).toHaveBeenCalledTimes(1)\n    expect(createAudioGeneratorMock).not.toHaveBeenCalled()\n    expect(result).toEqual({ success: true, audioUrl: 'bailian-audio' })\n  })\n})\n"
  },
  {
    "path": "tests/unit/generators/factory.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { createAudioGenerator, createImageGenerator, createVideoGenerator } from '@/lib/generators/factory'\nimport { GoogleVeoVideoGenerator } from '@/lib/generators/video/google'\nimport { OpenAICompatibleVideoGenerator } from '@/lib/generators/video/openai-compatible'\nimport { BailianAudioGenerator, BailianImageGenerator, BailianVideoGenerator, SiliconFlowAudioGenerator } from '@/lib/generators/official'\n\ndescribe('generator factory', () => {\n  it('routes gemini-compatible video provider to Google video generator', () => {\n    const generator = createVideoGenerator('gemini-compatible:gm-1')\n    expect(generator).toBeInstanceOf(GoogleVeoVideoGenerator)\n  })\n\n  it('routes bailian official providers to official generators', () => {\n    expect(createImageGenerator('bailian')).toBeInstanceOf(BailianImageGenerator)\n    expect(createVideoGenerator('bailian')).toBeInstanceOf(BailianVideoGenerator)\n    expect(createAudioGenerator('bailian')).toBeInstanceOf(BailianAudioGenerator)\n  })\n\n  it('routes siliconflow audio provider to official generator', () => {\n    expect(createAudioGenerator('siliconflow')).toBeInstanceOf(SiliconFlowAudioGenerator)\n  })\n})\n"
  },
  {
    "path": "tests/unit/generators/fal-video-kling-presets.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst apiConfigMock = vi.hoisted(() => ({\n  getProviderConfig: vi.fn(async () => ({ apiKey: 'fal-key' })),\n}))\n\nconst asyncSubmitMock = vi.hoisted(() => ({\n  submitFalTask: vi.fn(async () => 'req_kling_1'),\n}))\n\nvi.mock('@/lib/api-config', () => apiConfigMock)\nvi.mock('@/lib/async-submit', () => asyncSubmitMock)\n\nimport { FalVideoGenerator } from '@/lib/generators/fal'\n\ntype KlingModelCase = {\n  modelId: string\n  endpoint: string\n  imageField: 'image_url' | 'start_image_url'\n}\n\nconst KLING_MODEL_CASES: KlingModelCase[] = [\n  {\n    modelId: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',\n    endpoint: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',\n    imageField: 'image_url',\n  },\n  {\n    modelId: 'fal-ai/kling-video/v3/standard/image-to-video',\n    endpoint: 'fal-ai/kling-video/v3/standard/image-to-video',\n    imageField: 'start_image_url',\n  },\n  {\n    modelId: 'fal-ai/kling-video/v3/pro/image-to-video',\n    endpoint: 'fal-ai/kling-video/v3/pro/image-to-video',\n    imageField: 'start_image_url',\n  },\n]\n\ndescribe('FalVideoGenerator kling presets', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    apiConfigMock.getProviderConfig.mockResolvedValue({ apiKey: 'fal-key' })\n    asyncSubmitMock.submitFalTask.mockResolvedValue('req_kling_1')\n  })\n\n  it.each(KLING_MODEL_CASES)('submits $modelId to expected endpoint and payload', async ({ modelId, endpoint, imageField }) => {\n    const generator = new FalVideoGenerator()\n    const result = await generator.generate({\n      userId: 'user-1',\n      imageUrl: 'https://example.com/start.png',\n      prompt: 'test prompt',\n      options: {\n        modelId,\n        duration: 5,\n        aspectRatio: '16:9',\n      },\n    })\n\n    expect(result.success).toBe(true)\n    expect(result.endpoint).toBe(endpoint)\n    expect(result.requestId).toBe('req_kling_1')\n    expect(result.externalId).toBe(`FAL:VIDEO:${endpoint}:req_kling_1`)\n    expect(apiConfigMock.getProviderConfig).toHaveBeenCalledWith('user-1', 'fal')\n\n    const submitCall = asyncSubmitMock.submitFalTask.mock.calls.at(0) as\n      | [string, Record<string, unknown>, string]\n      | undefined\n    expect(submitCall).toBeTruthy()\n    if (!submitCall) {\n      throw new Error('submitFalTask should be called')\n    }\n\n    expect(submitCall[0]).toBe(endpoint)\n    expect(submitCall[2]).toBe('fal-key')\n\n    const payload = submitCall[1]\n    expect(payload.prompt).toBe('test prompt')\n    expect(payload.duration).toBe('5')\n\n    if (imageField === 'image_url') {\n      expect(payload.image_url).toBe('https://example.com/start.png')\n      expect(payload.start_image_url).toBeUndefined()\n      expect(payload.negative_prompt).toBe('blur, distort, and low quality')\n      expect(payload.cfg_scale).toBe(0.5)\n      return\n    }\n\n    expect(payload.start_image_url).toBe('https://example.com/start.png')\n    expect(payload.image_url).toBeUndefined()\n    expect(payload.aspect_ratio).toBe('16:9')\n    expect(payload.generate_audio).toBe(false)\n  })\n})\n"
  },
  {
    "path": "tests/unit/generators/image-provider-smoke.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst googleGenerateContentMock = vi.hoisted(() => vi.fn())\nconst getProviderConfigMock = vi.hoisted(() => vi.fn())\nconst getImageBase64CachedMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,UkVG'))\nconst arkImageGenerationMock = vi.hoisted(() => vi.fn())\nconst normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn(async () => 'UkVG'))\n\nvi.mock('@google/genai', () => ({\n  GoogleGenAI: class GoogleGenAI {\n    models = {\n      generateContent: googleGenerateContentMock,\n    }\n  },\n  HarmCategory: {\n    HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT',\n    HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH',\n    HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',\n    HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT',\n  },\n  HarmBlockThreshold: {\n    BLOCK_NONE: 'BLOCK_NONE',\n  },\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n}))\n\nvi.mock('@/lib/image-cache', () => ({\n  getImageBase64Cached: getImageBase64CachedMock,\n}))\n\nvi.mock('@/lib/ark-api', () => ({\n  arkImageGeneration: arkImageGenerationMock,\n}))\n\nvi.mock('@/lib/media/outbound-image', () => ({\n  normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,\n}))\n\nimport { ArkSeedreamGenerator } from '@/lib/generators/ark'\nimport { GeminiCompatibleImageGenerator } from '@/lib/generators/image/gemini-compatible'\nimport { GoogleGeminiImageGenerator } from '@/lib/generators/image/google'\n\ndescribe('image provider smoke tests', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('Google Gemini 官方文生图可用 -> 返回 data URL', async () => {\n    getProviderConfigMock.mockResolvedValueOnce({\n      id: 'google',\n      apiKey: 'google-key',\n    })\n    googleGenerateContentMock.mockResolvedValueOnce({\n      candidates: [\n        {\n          content: {\n            parts: [\n              {\n                inlineData: {\n                  mimeType: 'image/png',\n                  data: 'R09PR0xF',\n                },\n              },\n            ],\n          },\n        },\n      ],\n    })\n\n    const generator = new GoogleGeminiImageGenerator('gemini-3-pro-image-preview')\n    const result = await generator.generate({\n      userId: 'user-1',\n      prompt: 'draw a mountain',\n      options: {\n        aspectRatio: '3:4',\n      },\n    })\n\n    expect(result).toEqual({\n      success: true,\n      imageBase64: 'R09PR0xF',\n      imageUrl: 'data:image/png;base64,R09PR0xF',\n    })\n    expect(googleGenerateContentMock).toHaveBeenCalledWith({\n      model: 'gemini-3-pro-image-preview',\n      contents: [{ parts: [{ text: 'draw a mountain' }] }],\n      config: expect.objectContaining({\n        responseModalities: ['TEXT', 'IMAGE'],\n        imageConfig: { aspectRatio: '3:4' },\n      }),\n    })\n  })\n\n  it('Seedream 图生图可用 -> 返回 ARK 图片 URL', async () => {\n    getProviderConfigMock.mockResolvedValueOnce({\n      id: 'ark',\n      apiKey: 'ark-key',\n    })\n    arkImageGenerationMock.mockResolvedValueOnce({\n      data: [{ url: 'https://seedream.test/image.png' }],\n    })\n\n    const generator = new ArkSeedreamGenerator()\n    const result = await generator.generate({\n      userId: 'user-1',\n      prompt: 'refine this style',\n      referenceImages: ['https://example.com/ref.png'],\n      options: {\n        modelId: 'doubao-seedream-4-5-251128',\n        aspectRatio: '3:4',\n      },\n    })\n\n    expect(result).toEqual({\n      success: true,\n      imageUrl: 'https://seedream.test/image.png',\n    })\n    expect(arkImageGenerationMock).toHaveBeenCalledWith({\n      model: 'doubao-seedream-4-5-251128',\n      prompt: 'refine this style',\n      sequential_image_generation: 'disabled',\n      response_format: 'url',\n      stream: false,\n      watermark: false,\n      size: '3544x4728',\n      image: ['UkVG'],\n    }, {\n      apiKey: 'ark-key',\n      logPrefix: '[ARK Image]',\n    })\n  })\n\n  it('Seedream 返回多图时 -> 同时返回 imageUrl 和 imageUrls', async () => {\n    getProviderConfigMock.mockResolvedValueOnce({\n      id: 'ark',\n      apiKey: 'ark-key',\n    })\n    arkImageGenerationMock.mockResolvedValueOnce({\n      data: [\n        { url: 'https://seedream.test/image-1.png' },\n        { url: 'https://seedream.test/image-2.png' },\n      ],\n    })\n\n    const generator = new ArkSeedreamGenerator()\n    const result = await generator.generate({\n      userId: 'user-1',\n      prompt: 'refine this style',\n      referenceImages: ['https://example.com/ref.png'],\n      options: {\n        modelId: 'doubao-seedream-4-5-251128',\n        aspectRatio: '3:4',\n      },\n    })\n\n    expect(result).toEqual({\n      success: true,\n      imageUrl: 'https://seedream.test/image-1.png',\n      imageUrls: ['https://seedream.test/image-1.png', 'https://seedream.test/image-2.png'],\n    })\n  })\n\n  it('Gemini 兼容层文生图可用 -> 直连 Gemini SDK 协议返回图片', async () => {\n    getProviderConfigMock.mockResolvedValueOnce({\n      id: 'gemini-compatible:gm-1',\n      apiKey: 'gm-key',\n      baseUrl: 'https://gm.test',\n    })\n    googleGenerateContentMock.mockResolvedValueOnce({\n      candidates: [\n        {\n          content: {\n            parts: [\n              {\n                inlineData: {\n                  mimeType: 'image/webp',\n                  data: 'R01fVEVYVA==',\n                },\n              },\n            ],\n          },\n        },\n      ],\n    })\n\n    const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      prompt: 'draw a cat',\n      options: {\n        aspectRatio: '1:1',\n      },\n    })\n\n    expect(result).toEqual({\n      success: true,\n      imageBase64: 'R01fVEVYVA==',\n      imageUrl: 'data:image/webp;base64,R01fVEVYVA==',\n    })\n    expect(googleGenerateContentMock).toHaveBeenCalledWith({\n      model: 'gemini-2.5-flash-image-preview',\n      contents: [{ parts: [{ text: 'draw a cat' }] }],\n      config: expect.objectContaining({\n        responseModalities: ['TEXT', 'IMAGE'],\n        imageConfig: { aspectRatio: '1:1' },\n      }),\n    })\n  })\n\n  it('Gemini 兼容层图生图可用 -> 参考图会注入 inlineData', async () => {\n    getProviderConfigMock.mockResolvedValueOnce({\n      id: 'gemini-compatible:gm-1',\n      apiKey: 'gm-key',\n      baseUrl: 'https://gm.test',\n    })\n    googleGenerateContentMock.mockResolvedValueOnce({\n      candidates: [\n        {\n          content: {\n            parts: [\n              {\n                inlineData: {\n                  mimeType: 'image/png',\n                  data: 'R01fSTJJPQ==',\n                },\n              },\n            ],\n          },\n        },\n      ],\n    })\n\n    const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      prompt: 'restyle this portrait',\n      referenceImages: ['/api/files/ref-image'],\n      options: {\n        resolution: '2K',\n      },\n    })\n\n    expect(result).toEqual({\n      success: true,\n      imageBase64: 'R01fSTJJPQ==',\n      imageUrl: 'data:image/png;base64,R01fSTJJPQ==',\n    })\n    const call = googleGenerateContentMock.mock.calls[0]\n    expect(call).toBeTruthy()\n    if (!call) {\n      throw new Error('Gemini generateContent should be called')\n    }\n    const content = call[0] as {\n      contents: Array<{ parts: Array<{ inlineData?: { mimeType: string; data: string }; text?: string }> }>\n      config: { imageConfig?: { imageSize?: string } }\n    }\n    expect(content.contents[0].parts[0].inlineData).toEqual({ mimeType: 'image/png', data: 'UkVG' })\n    expect(content.contents[0].parts[1].text).toBe('restyle this portrait')\n    expect(content.config.imageConfig).toEqual({ imageSize: '2K' })\n  })\n})\n"
  },
  {
    "path": "tests/unit/generators/openai-compatible-image.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst openAIState = vi.hoisted(() => ({\n  generate: vi.fn(),\n  edit: vi.fn(),\n  toFile: vi.fn(async () => ({ name: 'mock-file' })),\n}))\n\nconst getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({\n  id: 'openai-compatible:oa-1',\n  apiKey: 'oa-key',\n  baseUrl: 'https://oa.test/v1',\n})))\n\nconst getImageBase64CachedMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,QQ=='))\n\nvi.mock('openai', () => ({\n  default: class OpenAI {\n    images = {\n      generate: openAIState.generate,\n      edit: openAIState.edit,\n    }\n  },\n  toFile: openAIState.toFile,\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n}))\n\nvi.mock('@/lib/image-cache', () => ({\n  getImageBase64Cached: getImageBase64CachedMock,\n}))\n\nimport { OpenAICompatibleImageGenerator } from '@/lib/generators/image/openai-compatible'\n\ndescribe('OpenAICompatibleImageGenerator', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    getProviderConfigMock.mockResolvedValue({\n      id: 'openai-compatible:oa-1',\n      apiKey: 'oa-key',\n      baseUrl: 'https://oa.test/v1',\n    })\n  })\n\n  it('uses official images.generate payload parameters', async () => {\n    openAIState.generate.mockResolvedValueOnce({\n      data: [{ b64_json: 'YmFzZTY0' }],\n    })\n\n    const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      prompt: 'draw a lighthouse',\n      options: {\n        size: '1024x1024',\n        quality: 'high',\n        outputFormat: 'png',\n        responseFormat: 'b64_json',\n      },\n    })\n\n    expect(result.success).toBe(true)\n    expect(result.imageBase64).toBe('YmFzZTY0')\n    expect(result.imageUrl).toBe('data:image/png;base64,YmFzZTY0')\n    expect(openAIState.generate).toHaveBeenCalledWith({\n      model: 'gpt-image-1',\n      prompt: 'draw a lighthouse',\n      response_format: 'b64_json',\n      output_format: 'png',\n      quality: 'high',\n      size: '1024x1024',\n    })\n  })\n\n  it('uses official images.edit payload when reference images are provided', async () => {\n    openAIState.edit.mockResolvedValueOnce({\n      data: [{ b64_json: 'ZWRpdA==' }],\n    })\n\n    const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      prompt: 'edit this image',\n      referenceImages: ['data:image/png;base64,QQ=='],\n      options: {\n        quality: 'medium',\n      },\n    })\n\n    expect(result.success).toBe(true)\n    expect(openAIState.toFile).toHaveBeenCalledTimes(1)\n\n    const call = openAIState.edit.mock.calls[0]\n    expect(call).toBeTruthy()\n    if (!call) {\n      throw new Error('images.edit should be called')\n    }\n    expect(call[0]).toMatchObject({\n      model: 'gpt-image-1',\n      prompt: 'edit this image',\n      response_format: 'b64_json',\n      quality: 'medium',\n    })\n    expect(Array.isArray((call[0] as { image?: unknown }).image)).toBe(true)\n  })\n\n  it('fails explicitly on unsupported option values', async () => {\n    const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      prompt: 'draw',\n      options: {\n        quality: 'ultra',\n      },\n    })\n\n    expect(result.success).toBe(false)\n    expect(result.error).toContain('OPENAI_COMPAT_IMAGE_OPTION_UNSUPPORTED')\n  })\n})\n"
  },
  {
    "path": "tests/unit/generators/openai-compatible-video.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst openAIState = vi.hoisted(() => ({\n  create: vi.fn(),\n  toFile: vi.fn(async () => ({ name: 'reference-file' })),\n}))\n\nconst getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({\n  id: 'openai-compatible:oa-1',\n  apiKey: 'oa-key',\n  baseUrl: 'https://oa.test/v1',\n})))\n\nconst normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,QQ=='))\n\nvi.mock('openai', () => ({\n  default: class OpenAI {\n    videos = {\n      create: openAIState.create,\n    }\n  },\n  toFile: openAIState.toFile,\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n}))\n\nvi.mock('@/lib/media/outbound-image', () => ({\n  normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,\n}))\n\nimport { OpenAICompatibleVideoGenerator } from '@/lib/generators/video/openai-compatible'\n\ndescribe('OpenAICompatibleVideoGenerator', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    getProviderConfigMock.mockResolvedValue({\n      id: 'openai-compatible:oa-1',\n      apiKey: 'oa-key',\n      baseUrl: 'https://oa.test/v1',\n    })\n  })\n\n  it('submits official videos.create payload and returns OPENAI externalId', async () => {\n    openAIState.create.mockResolvedValueOnce({ id: 'vid_123' })\n\n    const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      imageUrl: 'https://example.com/seed.png',\n      prompt: 'animate this character',\n      options: {\n        modelId: 'sora-2',\n        duration: 8,\n        resolution: '720p',\n        aspectRatio: '16:9',\n      },\n    })\n\n    expect(result.success).toBe(true)\n    expect(result.async).toBe(true)\n    expect(result.requestId).toBe('vid_123')\n\n    const expectedProviderToken = Buffer.from('openai-compatible:oa-1', 'utf8').toString('base64url')\n    expect(result.externalId).toBe(`OPENAI:VIDEO:${expectedProviderToken}:vid_123`)\n\n    const createCall = openAIState.create.mock.calls[0]\n    expect(createCall).toBeTruthy()\n    if (!createCall) {\n      throw new Error('videos.create should be called')\n    }\n\n    expect(createCall[0]).toMatchObject({\n      prompt: 'animate this character',\n      model: 'sora-2',\n      seconds: '8',\n      size: '1280x720',\n    })\n    expect((createCall[0] as { input_reference?: unknown }).input_reference).toBeDefined()\n  })\n\n  it('allows custom model ids for openai-compatible gateways', async () => {\n    openAIState.create.mockResolvedValueOnce({ id: 'vid_custom' })\n\n    const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      imageUrl: 'https://example.com/seed.png',\n      prompt: 'animate',\n      options: {\n        modelId: 'veo_3_1-fast-4K',\n      },\n    })\n\n    expect(result.success).toBe(true)\n    const createCall = openAIState.create.mock.calls.at(0)\n    expect(createCall).toBeTruthy()\n    if (!createCall) {\n      throw new Error('videos.create should be called')\n    }\n    expect((createCall[0] as { model?: string }).model).toBe('veo_3_1-fast-4K')\n  })\n\n  it('maps 3:2 to landscape size explicitly', async () => {\n    openAIState.create.mockResolvedValueOnce({ id: 'vid_32' })\n\n    const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      imageUrl: 'https://example.com/seed.png',\n      prompt: 'animate',\n      options: {\n        resolution: '1080p',\n        aspectRatio: '3:2',\n      },\n    })\n\n    expect(result.success).toBe(true)\n    const createCall = openAIState.create.mock.calls.at(0)\n    expect(createCall).toBeTruthy()\n    if (!createCall) {\n      throw new Error('videos.create should be called')\n    }\n    expect((createCall[0] as { size?: string }).size).toBe('1792x1024')\n  })\n\n  it('maps 2:3 to portrait size explicitly', async () => {\n    openAIState.create.mockResolvedValueOnce({ id: 'vid_23' })\n\n    const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      imageUrl: 'https://example.com/seed.png',\n      prompt: 'animate',\n      options: {\n        resolution: '720p',\n        aspectRatio: '2:3',\n      },\n    })\n\n    expect(result.success).toBe(true)\n    const createCall = openAIState.create.mock.calls.at(0)\n    expect(createCall).toBeTruthy()\n    if (!createCall) {\n      throw new Error('videos.create should be called')\n    }\n    expect((createCall[0] as { size?: string }).size).toBe('720x1280')\n  })\n\n  it('fails explicitly on unsupported aspect ratios', async () => {\n    const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')\n    const result = await generator.generate({\n      userId: 'user-1',\n      imageUrl: 'https://example.com/seed.png',\n      prompt: 'animate',\n      options: {\n        resolution: '720p',\n        aspectRatio: '5:4',\n      },\n    })\n\n    expect(result.success).toBe(false)\n    expect(result.error).toContain('OPENAI_COMPAT_VIDEO_ASPECT_RATIO_UNSUPPORTED')\n  })\n})\n"
  },
  {
    "path": "tests/unit/guards/api-route-contract-guard.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  API_HANDLER_ALLOWLIST,\n  PUBLIC_ROUTE_ALLOWLIST,\n  inspectRouteContract,\n} from '../../../scripts/guards/api-route-contract-guard.mjs'\n\ndescribe('api route contract guard', () => {\n  it('allows explicit public and framework-managed exceptions', () => {\n    expect(API_HANDLER_ALLOWLIST.has('src/app/api/auth/[...nextauth]/route.ts')).toBe(true)\n    expect(PUBLIC_ROUTE_ALLOWLIST.has('src/app/api/system/boot-id/route.ts')).toBe(true)\n    expect(\n      inspectRouteContract(\n        'src/app/api/system/boot-id/route.ts',\n        'export async function GET() { return Response.json({ bootId: \"x\" }) }',\n      ),\n    ).toEqual([])\n  })\n\n  it('passes protected routes that use apiHandler and explicit auth', () => {\n    const content = `\n      import { requireUserAuth } from '@/lib/api-auth'\n      import { apiHandler } from '@/lib/api-errors'\n      export const GET = apiHandler(async () => {\n        await requireUserAuth()\n        return Response.json({ ok: true })\n      })\n    `\n\n    expect(inspectRouteContract('src/app/api/user/secure/route.ts', content)).toEqual([])\n  })\n\n  it('flags protected routes that skip apiHandler or auth', () => {\n    const missingApiHandler = `\n      import { requireUserAuth } from '@/lib/api-auth'\n      export async function GET() {\n        await requireUserAuth()\n        return Response.json({ ok: true })\n      }\n    `\n    const missingAuth = `\n      import { apiHandler } from '@/lib/api-errors'\n      export const GET = apiHandler(async () => Response.json({ ok: true }))\n    `\n\n    expect(inspectRouteContract('src/app/api/user/secure/route.ts', missingApiHandler)).toEqual([\n      'src/app/api/user/secure/route.ts missing apiHandler wrapper',\n    ])\n    expect(inspectRouteContract('src/app/api/user/secure/route.ts', missingAuth)).toEqual([\n      'src/app/api/user/secure/route.ts missing requireUserAuth/requireProjectAuth/requireProjectAuthLight',\n    ])\n  })\n})\n"
  },
  {
    "path": "tests/unit/guards/changed-file-test-impact-guard.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { inspectChangedFiles } from '../../../scripts/guards/changed-file-test-impact-guard.mjs'\n\ndescribe('changed-file-test-impact-guard', () => {\n  it('requires api changes to be paired with contract, system, or regression tests', () => {\n    const violations = inspectChangedFiles([\n      'src/app/api/novel-promotion/[projectId]/generate-image/route.ts',\n    ])\n    expect(violations).toEqual([\n      'api: changing src/app/api/** requires a matching contract, system, or regression test change; sources=src/app/api/novel-promotion/[projectId]/generate-image/route.ts',\n    ])\n  })\n\n  it('accepts worker changes when system tests are updated together', () => {\n    const violations = inspectChangedFiles([\n      'src/lib/workers/image.worker.ts',\n      'tests/system/generate-image.system.test.ts',\n    ])\n    expect(violations).toEqual([])\n  })\n\n  it('accepts provider changes when provider contract coverage is updated', () => {\n    const violations = inspectChangedFiles([\n      'src/lib/model-gateway/openai-compat/image.ts',\n      'tests/unit/model-gateway/openai-compat-template-image-output-urls.test.ts',\n    ])\n    expect(violations).toEqual([])\n  })\n})\n"
  },
  {
    "path": "tests/unit/guards/image-reference-normalization-guard.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  NORMALIZATION_HELPER_ALLOWLIST,\n  inspectImageReferenceNormalization,\n} from '../../../scripts/guards/image-reference-normalization-guard.mjs'\n\ndescribe('image reference normalization guard', () => {\n  it('allows shared helper exceptions explicitly', () => {\n    expect(NORMALIZATION_HELPER_ALLOWLIST.has('src/lib/workers/handlers/image-task-handler-shared.ts')).toBe(true)\n    expect(\n      inspectImageReferenceNormalization(\n        'src/lib/workers/handlers/image-task-handler-shared.ts',\n        'resolveImageSourceFromGeneration(job, { options: params.options })\\nreferenceImages?: string[]',\n      ),\n    ).toEqual([])\n  })\n\n  it('passes handlers that normalize reference images before generation', () => {\n    const content = `\n      import { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'\n      async function run() {\n        const normalizedRefs = await normalizeReferenceImagesForGeneration(refs)\n        return await resolveImageSourceFromGeneration(job, {\n          options: {\n            referenceImages: normalizedRefs,\n          },\n        })\n      }\n    `\n\n    expect(\n      inspectImageReferenceNormalization('src/lib/workers/handlers/panel-image-task-handler.ts', content),\n    ).toEqual([])\n  })\n\n  it('flags handlers that send referenceImages without normalization markers', () => {\n    const content = `\n      async function run() {\n        return await resolveImageSourceFromGeneration(job, {\n          options: {\n            referenceImages: refs,\n          },\n        })\n      }\n    `\n\n    expect(\n      inspectImageReferenceNormalization('src/lib/workers/handlers/bad-handler.ts', content),\n    ).toEqual([\n      'src/lib/workers/handlers/bad-handler.ts uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateLabeledImageToCos',\n    ])\n  })\n})\n"
  },
  {
    "path": "tests/unit/guards/task-submit-compensation-guard.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { inspectTaskSubmitCompensation } from '../../../scripts/guards/task-submit-compensation-guard.mjs'\n\ndescribe('task submit compensation guard', () => {\n  it('passes routes that create data before submitTask and define rollback handling', () => {\n    const content = `\n      async function rollbackCreatedRecord() {}\n      export const POST = apiHandler(async () => {\n        await prisma.panel.create({ data: {} })\n        try {\n          return await submitTask({})\n        } catch (error) {\n          await rollbackCreatedRecord()\n          throw error\n        }\n      })\n    `\n\n    expect(\n      inspectTaskSubmitCompensation('src/app/api/novel-promotion/[projectId]/panel-variant/route.ts', content),\n    ).toEqual([])\n  })\n\n  it('ignores routes that do not combine create and submitTask', () => {\n    expect(inspectTaskSubmitCompensation('src/app/api/user/api-config/route.ts', 'await submitTask({})')).toEqual([])\n    expect(inspectTaskSubmitCompensation('src/app/api/projects/route.ts', 'await prisma.project.create({ data: {} })')).toEqual([])\n  })\n\n  it('flags routes that create data before submitTask without compensation marker', () => {\n    const content = `\n      export const POST = apiHandler(async () => {\n        await prisma.panel.create({ data: {} })\n        return await submitTask({})\n      })\n    `\n\n    expect(\n      inspectTaskSubmitCompensation('src/app/api/example/route.ts', content),\n    ).toEqual([\n      'src/app/api/example/route.ts creates data before submitTask without explicit rollback/compensation marker',\n    ])\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/api-fetch.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest'\nimport { apiFetch } from '@/lib/api-fetch'\n\ndescribe('apiFetch locale header injection', () => {\n  const originalFetch = globalThis.fetch\n\n  afterEach(() => {\n    globalThis.fetch = originalFetch\n    vi.unstubAllGlobals()\n    vi.clearAllMocks()\n  })\n\n  it('injects Accept-Language for internal /api requests', async () => {\n    const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))\n    globalThis.fetch = fetchMock\n\n    await apiFetch('/api/tasks?status=running', { method: 'GET' })\n\n    const init = fetchMock.mock.calls[0]?.[1]\n    const headers = new Headers(init?.headers)\n    expect(headers.get('Accept-Language')).toBe('zh')\n  })\n\n  it('uses pathname locale and does not override explicit Accept-Language', async () => {\n    vi.stubGlobal('window', {\n      location: {\n        pathname: '/en/workspace',\n      },\n    })\n\n    const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))\n    globalThis.fetch = fetchMock\n\n    await apiFetch('/api/projects', {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'Accept-Language': 'ja',\n      },\n      body: JSON.stringify({ ok: true }),\n    })\n\n    const init = fetchMock.mock.calls[0]?.[1]\n    const headers = new Headers(init?.headers)\n    expect(headers.get('Accept-Language')).toBe('ja')\n  })\n\n  it('does not inject locale header for non-internal URLs', async () => {\n    const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))\n    globalThis.fetch = fetchMock\n\n    await apiFetch('https://example.com/health', { method: 'GET' })\n\n    const init = fetchMock.mock.calls[0]?.[1]\n    const headers = new Headers(init?.headers)\n    expect(headers.has('Accept-Language')).toBe(false)\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/json-repair.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { safeParseJson, safeParseJsonObject, safeParseJsonArray } from '@/lib/json-repair'\n\n// ─── safeParseJson ───────────────────────────────────────────────────\n\ndescribe('safeParseJson', () => {\n    it('正常 JSON 字符串 -> 直接解析成功', () => {\n        const result = safeParseJson('{\"name\":\"孙悟空\",\"age\":500}')\n        expect(result).toEqual({ name: '孙悟空', age: 500 })\n    })\n\n    it('包含 markdown 代码块 -> 剥离后解析成功', () => {\n        const input = '```json\\n{\"key\":\"value\"}\\n```'\n        const result = safeParseJson(input)\n        expect(result).toEqual({ key: 'value' })\n    })\n\n    it('包含大写 JSON 标记的 markdown 代码块 -> 剥离后解析成功', () => {\n        const input = '```JSON\\n{\"key\":\"value\"}\\n```'\n        const result = safeParseJson(input)\n        expect(result).toEqual({ key: 'value' })\n    })\n\n    it('尾部逗号 -> jsonrepair 修复后解析成功', () => {\n        const input = '{\"a\":1,\"b\":2,}'\n        const result = safeParseJson(input)\n        expect(result).toEqual({ a: 1, b: 2 })\n    })\n\n    it('单引号包裹字符串 -> jsonrepair 修复后解析成功', () => {\n        const input = \"{'name':'张三','age':25}\"\n        const result = safeParseJson(input)\n        expect(result).toEqual({ name: '张三', age: 25 })\n    })\n\n    it('JSON 前后有多余文字 -> jsonrepair 修复后解析成功', () => {\n        const input = '以下是分析结果：\\n{\"result\":\"success\"}\\n以上是所有内容。'\n        const result = safeParseJson(input)\n        expect(result).toEqual({ result: 'success' })\n    })\n\n    it('完全无效内容（无任何 JSON 结构字符）-> jsonrepair 将其视为字符串', () => {\n        // jsonrepair 会把纯文本修复为 JSON 字符串\n        const result = safeParseJson('这不是JSON')\n        expect(result).toBe('这不是JSON')\n    })\n})\n\n// ─── safeParseJsonObject ─────────────────────────────────────────────\n\ndescribe('safeParseJsonObject', () => {\n    it('正常 JSON 对象 -> 返回对象', () => {\n        const result = safeParseJsonObject('{\"characters\":[],\"locations\":[]}')\n        expect(result).toEqual({ characters: [], locations: [] })\n    })\n\n    it('markdown 包裹的 JSON 对象 -> 剥离后返回对象', () => {\n        const input = '```json\\n{\"episodes\":[{\"number\":1}]}\\n```'\n        const result = safeParseJsonObject(input)\n        expect(result).toHaveProperty('episodes')\n        expect((result.episodes as unknown[])[0]).toEqual({ number: 1 })\n    })\n\n    it('包含中文角引号「」的内容 -> 正常解析保留', () => {\n        const input = '{\"lines\":\"孙悟空怒道，「一个冒牌货，也敢拦你孙爷爷的路！」\"}'\n        const result = safeParseJsonObject(input)\n        expect(result.lines).toBe('孙悟空怒道，「一个冒牌货，也敢拦你孙爷爷的路！」')\n    })\n\n    it('LLM 输出数组而非对象 -> 抛出 Expected JSON object 错误', () => {\n        expect(() => safeParseJsonObject('[1,2,3]')).toThrow('Expected JSON object')\n    })\n\n    it('尾部逗号 + markdown 包裹 -> 修复后返回正确对象', () => {\n        const input = '```json\\n{\"a\":1,\"b\":\"hello\",}\\n```'\n        const result = safeParseJsonObject(input)\n        expect(result).toEqual({ a: 1, b: 'hello' })\n    })\n})\n\n// ─── safeParseJsonArray ──────────────────────────────────────────────\n\ndescribe('safeParseJsonArray', () => {\n    it('正常 JSON 数组 -> 返回对象数组', () => {\n        const input = '[{\"id\":1,\"name\":\"角色A\"},{\"id\":2,\"name\":\"角色B\"}]'\n        const result = safeParseJsonArray(input)\n        expect(result).toHaveLength(2)\n        expect(result[0]).toEqual({ id: 1, name: '角色A' })\n        expect(result[1]).toEqual({ id: 2, name: '角色B' })\n    })\n\n    it('对象包裹数组 + fallbackKey -> 提取内部数组', () => {\n        const input = '{\"clips\":[{\"id\":1},{\"id\":2}]}'\n        const result = safeParseJsonArray(input, 'clips')\n        expect(result).toHaveLength(2)\n        expect(result[0]).toEqual({ id: 1 })\n    })\n\n    it('对象包裹数组 + 无 fallbackKey -> 自动发现第一个数组字段', () => {\n        const input = '{\"episodes\":[{\"number\":1},{\"number\":2}]}'\n        const result = safeParseJsonArray(input)\n        expect(result).toHaveLength(2)\n        expect(result[0]).toEqual({ number: 1 })\n    })\n\n    it('markdown 包裹 + 尾部逗号 -> 修复后返回正确数组', () => {\n        const input = '```json\\n[{\"a\":1},{\"b\":2},]\\n```'\n        const result = safeParseJsonArray(input)\n        expect(result).toHaveLength(2)\n        expect(result[0]).toEqual({ a: 1 })\n        expect(result[1]).toEqual({ b: 2 })\n    })\n\n    it('过滤非对象元素（数字、字符串等）-> 只保留对象', () => {\n        const input = '[{\"valid\":true}, 42, \"string\", null, {\"also\":true}]'\n        const result = safeParseJsonArray(input)\n        expect(result).toHaveLength(2)\n        expect(result[0]).toEqual({ valid: true })\n        expect(result[1]).toEqual({ also: true })\n    })\n\n    it('空数组 -> 返回空数组', () => {\n        const result = safeParseJsonArray('[]')\n        expect(result).toHaveLength(0)\n    })\n\n    it('非数组非对象 -> 抛出错误', () => {\n        expect(() => safeParseJsonArray('\"just a string\"')).toThrow('Expected JSON array')\n    })\n\n    it('对象不含数组字段 -> 抛出错误', () => {\n        expect(() => safeParseJsonArray('{\"key\":\"value\"}')).toThrow('Expected JSON array')\n    })\n})\n\n// ─── 真实 LLM 畸形输出回归测试 ───────────────────────────────────────\n\ndescribe('LLM 畸形 JSON 输出回归测试', () => {\n    it('中文弯引号嵌套在 JSON 值中 -> jsonrepair 修复成功', () => {\n        // 这是导致 \"Invalid clip JSON format\" 的典型场景\n        const llmOutput = '```json\\n[{\"description\":\"孙悟空怒道，\\\\u201c一个冒牌货！\\\\u201d\"}]\\n```'\n        const result = safeParseJsonArray(llmOutput)\n        expect(result).toHaveLength(1)\n        expect(result[0].description).toContain('孙悟空')\n    })\n\n    it('LLM 输出前后带解释文字 -> 提取并解析 JSON', () => {\n        const llmOutput = `好的，以下是分析结果：\n\n{\"locations\":[{\"name\":\"客厅_白天\",\"summary\":\"主角居住的客厅\"}]}\n\n以上是所有场景分析。`\n        const result = safeParseJsonObject(llmOutput)\n        expect(result.locations).toBeDefined()\n        const locations = result.locations as unknown[]\n        expect(locations).toHaveLength(1)\n    })\n\n    it('使用「」角引号的台词内容 -> 正确解析不破坏 JSON', () => {\n        // 改造后的提示词要求 LLM 用「」替代引号\n        const llmOutput = '[{\"speaker\":\"孙悟空\",\"content\":\"「你竟敢拦我的路！」\",\"emotionStrength\":0.4}]'\n        const result = safeParseJsonArray(llmOutput)\n        expect(result).toHaveLength(1)\n        expect(result[0].speaker).toBe('孙悟空')\n        expect(result[0].content).toBe('「你竟敢拦我的路！」')\n        expect(result[0].emotionStrength).toBe(0.4)\n    })\n\n    it('带控制字符的 JSON -> jsonrepair 修复成功', () => {\n        // LLM 有时在字符串值中输出真实换行符\n        const llmOutput = '{\"text\":\"第一行\\\\n第二行\",\"count\":2}'\n        const result = safeParseJsonObject(llmOutput)\n        expect(result.text).toBe('第一行\\n第二行')\n        expect(result.count).toBe(2)\n    })\n\n    it('clips 包裹在对象中 -> 正确提取', () => {\n        // clips-build 中常见的 LLM 输出格式\n        const llmOutput = '{\"clips\":[{\"id\":\"clip_1\",\"startText\":\"从前\"},{\"id\":\"clip_2\",\"startText\":\"后来\"}]}'\n        const result = safeParseJsonArray(llmOutput, 'clips')\n        expect(result).toHaveLength(2)\n        expect(result[0].id).toBe('clip_1')\n        expect(result[1].startText).toBe('后来')\n    })\n})\n"
  },
  {
    "path": "tests/unit/helpers/llm-stage-stream-card-output.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { splitStructuredOutput } from '@/components/llm-console/LLMStageStreamCard'\n\ndescribe('LLMStageStreamCard structured output parsing', () => {\n  it('moves think-tagged text from final block into reasoning', () => {\n    const parsed = splitStructuredOutput(`【思考过程】\n已有思考\n\n【最终结果】\n<think>追加思考</think>\n{\"locations\":[]}`)\n\n    expect(parsed.reasoning).toContain('已有思考')\n    expect(parsed.reasoning).toContain('追加思考')\n    expect(parsed.finalText).toBe('{\"locations\":[]}')\n  })\n\n  it('handles unmatched think opening tag during streaming', () => {\n    const parsed = splitStructuredOutput(`【最终结果】\n<think>流式中的思考还没结束`)\n\n    expect(parsed.reasoning).toBe('流式中的思考还没结束')\n    expect(parsed.finalText).toBe('')\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/logging-core.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n\ndescribe('logging core suppression', () => {\n  let originalLogLevel: string | undefined\n  let originalUnifiedEnabled: string | undefined\n\n  beforeEach(() => {\n    vi.resetModules()\n    originalLogLevel = process.env.LOG_LEVEL\n    originalUnifiedEnabled = process.env.LOG_UNIFIED_ENABLED\n    process.env.LOG_LEVEL = 'INFO'\n    process.env.LOG_UNIFIED_ENABLED = 'true'\n  })\n\n  afterEach(() => {\n    if (originalLogLevel === undefined) {\n      delete process.env.LOG_LEVEL\n    } else {\n      process.env.LOG_LEVEL = originalLogLevel\n    }\n    if (originalUnifiedEnabled === undefined) {\n      delete process.env.LOG_UNIFIED_ENABLED\n    } else {\n      process.env.LOG_UNIFIED_ENABLED = originalUnifiedEnabled\n    }\n    vi.restoreAllMocks()\n  })\n\n  it('suppresses worker.progress.stream logs', async () => {\n    const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)\n    const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)\n    const { createScopedLogger } = await import('@/lib/logging/core')\n    const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })\n\n    logger.info({\n      action: 'worker.progress.stream',\n      message: 'worker stream chunk',\n      details: {\n        kind: 'text',\n        seq: 1,\n      },\n    })\n\n    expect(consoleLogSpy).not.toHaveBeenCalled()\n    expect(consoleErrorSpy).not.toHaveBeenCalled()\n  })\n\n  it('keeps non-suppressed logs', async () => {\n    const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)\n    const { createScopedLogger } = await import('@/lib/logging/core')\n    const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })\n\n    logger.info({\n      action: 'worker.progress',\n      message: 'worker progress update',\n    })\n\n    expect(consoleLogSpy).toHaveBeenCalledTimes(1)\n    const payload = JSON.parse(String(consoleLogSpy.mock.calls[0]?.[0])) as { action?: string; message?: string }\n    expect(payload.action).toBe('worker.progress')\n    expect(payload.message).toBe('worker progress update')\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/migrate-gateway-route-openai-compat.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  migrateGatewayRoutePayload,\n  migrateProviderEntry,\n} from '@/lib/migrations/gateway-route-openai-compat'\n\ndescribe('gateway-route openai-compat migration', () => {\n  it('migrates openai-compatible litellm route to openai-compat', () => {\n    const result = migrateProviderEntry({\n      id: 'openai-compatible:oa-1',\n      gatewayRoute: 'litellm',\n    })\n\n    expect(result.changed).toBe(true)\n    expect(result.next).toMatchObject({\n      id: 'openai-compatible:oa-1',\n      gatewayRoute: 'openai-compat',\n    })\n    expect(result.summary.routeLitellmToOpenaiCompat).toBe(1)\n  })\n\n  it('forces gemini-compatible to gemini-sdk + official route', () => {\n    const result = migrateProviderEntry({\n      id: 'gemini-compatible:gm-1',\n      apiMode: 'openai-official',\n      gatewayRoute: 'openai-compat',\n    })\n\n    expect(result.changed).toBe(true)\n    expect(result.next).toMatchObject({\n      id: 'gemini-compatible:gm-1',\n      apiMode: 'gemini-sdk',\n      gatewayRoute: 'official',\n    })\n    expect(result.summary.geminiApiModeCorrected).toBe(1)\n    expect(result.summary.routeForcedOfficial).toBe(1)\n  })\n\n  it('forces non-openai-compatible compat routes to official', () => {\n    const result = migrateProviderEntry({\n      id: 'openrouter',\n      gatewayRoute: 'openai-compat',\n    })\n\n    expect(result.changed).toBe(true)\n    expect(result.next).toMatchObject({\n      id: 'openrouter',\n      gatewayRoute: 'official',\n    })\n    expect(result.summary.routeForcedOfficial).toBe(1)\n  })\n\n  it('returns invalid status for malformed payload json', () => {\n    const result = migrateGatewayRoutePayload('{bad-json')\n    expect(result.status).toBe('invalid')\n    expect(result.summary.invalidPayload).toBe(true)\n  })\n\n  it('migrates mixed provider payload and reports aggregate stats', () => {\n    const result = migrateGatewayRoutePayload(JSON.stringify([\n      {\n        id: 'openai-compatible:oa-1',\n        gatewayRoute: 'litellm',\n      },\n      {\n        id: 'gemini-compatible:gm-1',\n        apiMode: 'openai-official',\n        gatewayRoute: 'openai-compat',\n      },\n      {\n        id: 'google',\n        gatewayRoute: 'official',\n      },\n    ]))\n\n    expect(result.status).toBe('ok')\n    expect(result.changed).toBe(true)\n    expect(result.summary.providersScanned).toBe(3)\n    expect(result.summary.providersChanged).toBe(2)\n    expect(result.summary.routeLitellmToOpenaiCompat).toBe(1)\n    expect(result.summary.routeForcedOfficial).toBe(1)\n    expect(result.summary.geminiApiModeCorrected).toBe(1)\n\n    const nextPayload = JSON.parse(result.nextRaw || '[]') as Array<Record<string, unknown>>\n    expect(nextPayload[0]?.gatewayRoute).toBe('openai-compat')\n    expect(nextPayload[1]?.apiMode).toBe('gemini-sdk')\n    expect(nextPayload[1]?.gatewayRoute).toBe('official')\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/prompt-suffix-regression.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  addCharacterPromptSuffix,\n  CHARACTER_PROMPT_SUFFIX,\n  removeCharacterPromptSuffix,\n} from '@/lib/constants'\n\nfunction countOccurrences(input: string, target: string) {\n  if (!target) return 0\n  return input.split(target).length - 1\n}\n\ndescribe('character prompt suffix regression', () => {\n  it('appends suffix when generating prompt', () => {\n    const basePrompt = 'A brave knight in silver armor'\n    const generated = addCharacterPromptSuffix(basePrompt)\n\n    expect(generated).toContain(CHARACTER_PROMPT_SUFFIX)\n    expect(countOccurrences(generated, CHARACTER_PROMPT_SUFFIX)).toBe(1)\n  })\n\n  it('removes suffix text from prompt', () => {\n    const basePrompt = 'A calm detective with short black hair'\n    const withSuffix = addCharacterPromptSuffix(basePrompt)\n    const removed = removeCharacterPromptSuffix(withSuffix)\n\n    expect(removed).not.toContain(CHARACTER_PROMPT_SUFFIX)\n    expect(removed).toContain(basePrompt)\n  })\n\n  it('uses suffix as full prompt when base prompt is empty', () => {\n    expect(addCharacterPromptSuffix('')).toBe(CHARACTER_PROMPT_SUFFIX)\n    expect(removeCharacterPromptSuffix('')).toBe('')\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/recovered-run-subscription.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest'\nimport { subscribeRecoveredRun } from '@/lib/query/hooks/run-stream/recovered-run-subscription'\n\nfunction jsonResponse(payload: unknown, status = 200) {\n  return {\n    ok: status >= 200 && status < 300,\n    json: async () => payload,\n  }\n}\n\nasync function waitForCondition(condition: () => boolean, timeoutMs = 1000) {\n  const startedAt = Date.now()\n  while (Date.now() - startedAt < timeoutMs) {\n    if (condition()) return\n    await new Promise((resolve) => setTimeout(resolve, 10))\n  }\n  throw new Error('condition not met before timeout')\n}\n\ndescribe('recovered run subscription', () => {\n  const originalFetch = globalThis.fetch\n\n  afterEach(() => {\n    vi.restoreAllMocks()\n    vi.unstubAllGlobals()\n    vi.useRealTimers()\n    if (originalFetch) {\n      globalThis.fetch = originalFetch\n    } else {\n      Reflect.deleteProperty(globalThis, 'fetch')\n    }\n  })\n\n  it('replays run events and keeps recovering when no terminal event is present', async () => {\n    const fetchMock = vi.fn().mockResolvedValue(\n      jsonResponse({\n        events: [\n          {\n            seq: 1,\n            eventType: 'step.start',\n            stepKey: 'clip_1_phase1',\n            attempt: 1,\n            payload: {\n              stepTitle: '分镜规划',\n              stepIndex: 1,\n              stepTotal: 4,\n              message: 'running',\n            },\n            createdAt: '2026-02-28T00:00:01.000Z',\n          },\n        ],\n      }),\n    )\n    globalThis.fetch = fetchMock as unknown as typeof fetch\n\n    const applyAndCapture = vi.fn()\n    const onSettled = vi.fn()\n\n    const cleanup = subscribeRecoveredRun({\n      runId: 'run-1',\n      taskStreamTimeoutMs: 10_000,\n      applyAndCapture,\n      onSettled,\n    })\n\n    await waitForCondition(() => fetchMock.mock.calls.length > 0 && applyAndCapture.mock.calls.length > 0)\n    expect(fetchMock).toHaveBeenCalledWith(\n      '/api/runs/run-1/events?afterSeq=0&limit=500',\n      expect.objectContaining({ method: 'GET', cache: 'no-store' }),\n    )\n    expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({\n      event: 'step.start',\n      runId: 'run-1',\n      stepId: 'clip_1_phase1',\n    }))\n    expect(onSettled).not.toHaveBeenCalled()\n    cleanup()\n  })\n\n  it('settles recovery when replay hits terminal run event', async () => {\n    const fetchMock = vi.fn().mockResolvedValue(\n      jsonResponse({\n        events: [\n          {\n            seq: 1,\n            eventType: 'run.error',\n            payload: {\n              message: 'exception TypeError: fetch failed sending request',\n            },\n            createdAt: '2026-02-28T00:00:02.000Z',\n          },\n        ],\n      }),\n    )\n    globalThis.fetch = fetchMock as unknown as typeof fetch\n\n    const applyAndCapture = vi.fn()\n    const onSettled = vi.fn()\n\n    subscribeRecoveredRun({\n      runId: 'run-1',\n      taskStreamTimeoutMs: 10_000,\n      applyAndCapture,\n      onSettled,\n    })\n\n    await waitForCondition(() => onSettled.mock.calls.length === 1 && applyAndCapture.mock.calls.length > 0)\n    expect(onSettled).toHaveBeenCalledTimes(1)\n    expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({\n      event: 'run.error',\n      runId: 'run-1',\n    }))\n  })\n\n  it('replays step.chunk output so refresh keeps prior text', async () => {\n    const fetchMock = vi.fn().mockResolvedValue(\n      jsonResponse({\n        events: [\n          {\n            seq: 1,\n            eventType: 'step.chunk',\n            stepKey: 'clip_1_phase1',\n            payload: {\n              stream: {\n                kind: 'text',\n                lane: 'main',\n                seq: 1,\n                delta: '旧输出',\n              },\n            },\n            createdAt: '2026-02-28T00:00:03.000Z',\n          },\n        ],\n      }),\n    )\n    globalThis.fetch = fetchMock as unknown as typeof fetch\n\n    const applyAndCapture = vi.fn()\n    const onSettled = vi.fn()\n\n    const cleanup = subscribeRecoveredRun({\n      runId: 'run-1',\n      taskStreamTimeoutMs: 10_000,\n      applyAndCapture,\n      onSettled,\n    })\n\n    await waitForCondition(() => applyAndCapture.mock.calls.some((call) => call[0]?.event === 'step.chunk'))\n    expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({\n      event: 'step.chunk',\n      runId: 'run-1',\n      stepId: 'clip_1_phase1',\n      textDelta: '旧输出',\n    }))\n    cleanup()\n  })\n\n  it('emits run.error and settles when idle timeout is reached', async () => {\n    vi.useFakeTimers()\n    const fetchMock = vi.fn().mockResolvedValue(\n      jsonResponse({\n        events: [],\n      }),\n    )\n    globalThis.fetch = fetchMock as unknown as typeof fetch\n\n    const applyAndCapture = vi.fn()\n    const onSettled = vi.fn()\n\n    subscribeRecoveredRun({\n      runId: 'run-timeout',\n      taskStreamTimeoutMs: 3_000,\n      applyAndCapture,\n      onSettled,\n    })\n\n    await vi.advanceTimersByTimeAsync(3_200)\n\n    expect(onSettled).toHaveBeenCalledTimes(1)\n    expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({\n      event: 'run.error',\n      runId: 'run-timeout',\n      message: 'run stream timeout: run-timeout',\n    }))\n\n    vi.useRealTimers()\n  })\n\n  it('resets idle timeout when a new event arrives during recovery', async () => {\n    vi.useFakeTimers()\n    let eventFetchCount = 0\n    const fetchMock = vi.fn().mockImplementation(async () => {\n      eventFetchCount += 1\n      if (eventFetchCount === 2) {\n        return jsonResponse({\n          events: [\n            {\n              seq: 1,\n              eventType: 'run.start',\n              payload: { message: 'resumed' },\n              createdAt: '2026-02-28T00:00:01.500Z',\n            },\n          ],\n        })\n      }\n      return jsonResponse({ events: [] })\n    })\n    globalThis.fetch = fetchMock as unknown as typeof fetch\n\n    const applyAndCapture = vi.fn()\n    const onSettled = vi.fn()\n\n    subscribeRecoveredRun({\n      runId: 'run-recover',\n      taskStreamTimeoutMs: 3_000,\n      applyAndCapture,\n      onSettled,\n    })\n\n    await vi.advanceTimersByTimeAsync(3_200)\n    expect(onSettled).not.toHaveBeenCalled()\n\n    await vi.advanceTimersByTimeAsync(2_000)\n    expect(onSettled).toHaveBeenCalledTimes(1)\n    expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({\n      event: 'run.start',\n      runId: 'run-recover',\n    }))\n\n    vi.useRealTimers()\n  })\n\n  it('reconciles run snapshot to failed when event polling stays empty', async () => {\n    vi.useFakeTimers()\n    const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {\n      const url = String(input)\n      if (url.includes('/api/runs/run-reconcile/events')) {\n        return jsonResponse({ events: [] })\n      }\n      if (url === '/api/runs/run-reconcile') {\n        return jsonResponse({\n          run: {\n            id: 'run-reconcile',\n            status: 'failed',\n            errorMessage: 'Ark Responses 调用失败',\n          },\n        })\n      }\n      return jsonResponse({ events: [] })\n    })\n    globalThis.fetch = fetchMock as unknown as typeof fetch\n\n    const applyAndCapture = vi.fn()\n    const onSettled = vi.fn()\n\n    subscribeRecoveredRun({\n      runId: 'run-reconcile',\n      taskStreamTimeoutMs: 20_000,\n      applyAndCapture,\n      onSettled,\n    })\n\n    await vi.advanceTimersByTimeAsync(3_500)\n\n    expect(onSettled).toHaveBeenCalledTimes(1)\n    expect(fetchMock).toHaveBeenCalledWith(\n      '/api/runs/run-reconcile',\n      expect.objectContaining({ method: 'GET', cache: 'no-store' }),\n    )\n    expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({\n      event: 'run.error',\n      runId: 'run-reconcile',\n      message: 'Ark Responses 调用失败',\n    }))\n\n    vi.useRealTimers()\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/reference-to-character-helpers.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { parseReferenceImages, readBoolean, readString } from '@/lib/workers/handlers/reference-to-character-helpers'\n\ndescribe('reference-to-character helpers', () => {\n  it('parses and trims single reference image', () => {\n    expect(parseReferenceImages({ referenceImageUrl: ' https://x/a.png ' })).toEqual(['https://x/a.png'])\n  })\n\n  it('parses multi reference images and truncates to max 5', () => {\n    expect(\n      parseReferenceImages({\n        referenceImageUrls: [\n          'https://x/1.png',\n          'https://x/2.png',\n          'https://x/3.png',\n          'https://x/4.png',\n          'https://x/5.png',\n          'https://x/6.png',\n        ],\n      }),\n    ).toEqual([\n      'https://x/1.png',\n      'https://x/2.png',\n      'https://x/3.png',\n      'https://x/4.png',\n      'https://x/5.png',\n    ])\n  })\n\n  it('filters empty values', () => {\n    expect(\n      parseReferenceImages({\n        referenceImageUrls: [' ', '\\n', 'https://x/ok.png'],\n      }),\n    ).toEqual(['https://x/ok.png'])\n  })\n\n  it('readString trims and normalizes invalid values', () => {\n    expect(readString(' abc ')).toBe('abc')\n    expect(readString(1)).toBe('')\n    expect(readString(null)).toBe('')\n  })\n\n  it('readBoolean supports boolean/number/string flags', () => {\n    expect(readBoolean(true)).toBe(true)\n    expect(readBoolean(1)).toBe(true)\n    expect(readBoolean('true')).toBe(true)\n    expect(readBoolean('YES')).toBe(true)\n    expect(readBoolean('on')).toBe(true)\n    expect(readBoolean('0')).toBe(false)\n    expect(readBoolean(false)).toBe(false)\n    expect(readBoolean(0)).toBe(false)\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/route-task-helpers.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { NextRequest } from 'next/server'\nimport {\n  parseSyncFlag,\n  resolveDisplayMode,\n  resolvePositiveInteger,\n  shouldRunSyncTask,\n} from '@/lib/llm-observe/route-task'\n\nfunction buildRequest(path: string, headers?: Record<string, string>) {\n  return new NextRequest(new URL(path, 'http://localhost'), {\n    method: 'POST',\n    headers: headers || {},\n  })\n}\n\ndescribe('route-task helpers', () => {\n  it('parseSyncFlag supports boolean-like values', () => {\n    expect(parseSyncFlag(true)).toBe(true)\n    expect(parseSyncFlag(1)).toBe(true)\n    expect(parseSyncFlag('1')).toBe(true)\n    expect(parseSyncFlag('true')).toBe(true)\n    expect(parseSyncFlag('yes')).toBe(true)\n    expect(parseSyncFlag('on')).toBe(true)\n    expect(parseSyncFlag('false')).toBe(false)\n    expect(parseSyncFlag(0)).toBe(false)\n  })\n\n  it('shouldRunSyncTask true when internal task header exists', () => {\n    const req = buildRequest('/api/test', { 'x-internal-task-id': 'task-1' })\n    expect(shouldRunSyncTask(req, {})).toBe(true)\n  })\n\n  it('shouldRunSyncTask true when body sync flag exists', () => {\n    const req = buildRequest('/api/test')\n    expect(shouldRunSyncTask(req, { sync: 'true' })).toBe(true)\n  })\n\n  it('shouldRunSyncTask true when query sync flag exists', () => {\n    const req = buildRequest('/api/test?sync=1')\n    expect(shouldRunSyncTask(req, {})).toBe(true)\n  })\n\n  it('resolveDisplayMode falls back to default on invalid value', () => {\n    expect(resolveDisplayMode('detail', 'loading')).toBe('detail')\n    expect(resolveDisplayMode('loading', 'detail')).toBe('loading')\n    expect(resolveDisplayMode('invalid', 'loading')).toBe('loading')\n  })\n\n  it('resolvePositiveInteger returns safe integer fallback', () => {\n    expect(resolvePositiveInteger(2.9, 1)).toBe(2)\n    expect(resolvePositiveInteger('9', 1)).toBe(9)\n    expect(resolvePositiveInteger('0', 7)).toBe(7)\n    expect(resolvePositiveInteger('abc', 7)).toBe(7)\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/run-request-executor.run-events.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest'\nimport { executeRunRequest } from '@/lib/query/hooks/run-stream/run-request-executor'\nimport type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'\n\nfunction jsonResponse(payload: unknown, status = 200) {\n  return new Response(JSON.stringify(payload), {\n    status,\n    headers: {\n      'content-type': 'application/json',\n    },\n  })\n}\n\ndescribe('run-request-executor run events path', () => {\n  it('uses /api/runs/:runId/events when async response includes runId', async () => {\n    const fetchMock = vi.fn<typeof fetch>()\n    fetchMock\n      .mockResolvedValueOnce(jsonResponse({\n        success: true,\n        async: true,\n        taskId: 'task_1',\n        runId: 'run_1',\n      }))\n      .mockResolvedValueOnce(jsonResponse({\n        runId: 'run_1',\n        afterSeq: 0,\n        events: [\n          {\n            seq: 1,\n            eventType: 'run.start',\n            payload: { message: 'started' },\n            createdAt: '2026-02-28T00:00:00.000Z',\n          },\n          {\n            seq: 2,\n            eventType: 'step.start',\n            stepKey: 'step_a',\n            attempt: 1,\n            payload: {\n              stepTitle: 'Step A',\n              stepIndex: 1,\n              stepTotal: 1,\n            },\n            createdAt: '2026-02-28T00:00:01.000Z',\n          },\n          {\n            seq: 3,\n            eventType: 'step.chunk',\n            stepKey: 'step_a',\n            attempt: 1,\n            lane: 'text',\n            payload: {\n              stream: {\n                delta: 'hello',\n                seq: 1,\n              },\n            },\n            createdAt: '2026-02-28T00:00:01.100Z',\n          },\n          {\n            seq: 4,\n            eventType: 'step.complete',\n            stepKey: 'step_a',\n            attempt: 1,\n            payload: {\n              text: 'hello',\n            },\n            createdAt: '2026-02-28T00:00:02.000Z',\n          },\n          {\n            seq: 5,\n            eventType: 'run.complete',\n            payload: {\n              summary: { ok: true },\n            },\n            createdAt: '2026-02-28T00:00:03.000Z',\n          },\n        ],\n      }))\n\n    const originalFetch = globalThis.fetch\n    globalThis.fetch = fetchMock\n\n    try {\n      const captured: RunStreamEvent[] = []\n      const controller = new AbortController()\n      const result = await executeRunRequest({\n        endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',\n        requestBody: { episodeId: 'episode_1' },\n        controller,\n        taskStreamTimeoutMs: 30_000,\n        applyAndCapture: (event) => {\n          captured.push(event)\n        },\n        finalResultRef: { current: null },\n      })\n\n      expect(result.status).toBe('completed')\n      expect(result.runId).toBe('run_1')\n      expect(captured.some((event) => event.event === 'step.chunk' && event.textDelta === 'hello')).toBe(true)\n      expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/runs/run_1/events?afterSeq=0&limit=500')\n    } finally {\n      globalThis.fetch = originalFetch\n    }\n  })\n\n  it('surfaces run-events fetch errors instead of swallowing them', async () => {\n    const fetchMock = vi.fn<typeof fetch>()\n    fetchMock\n      .mockResolvedValueOnce(jsonResponse({\n        success: true,\n        async: true,\n        taskId: 'task_1',\n        runId: 'run_1',\n      }))\n      .mockResolvedValueOnce(jsonResponse({\n        error: {\n          message: 'events backend unavailable',\n        },\n      }, 503))\n\n    const originalFetch = globalThis.fetch\n    globalThis.fetch = fetchMock\n\n    try {\n      const controller = new AbortController()\n      await expect(executeRunRequest({\n        endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',\n        requestBody: { episodeId: 'episode_1' },\n        controller,\n        taskStreamTimeoutMs: 30_000,\n        applyAndCapture: () => undefined,\n        finalResultRef: { current: null },\n      })).rejects.toThrow('run events fetch failed (HTTP 503): events backend unavailable')\n    } finally {\n      globalThis.fetch = originalFetch\n    }\n  })\n\n  it('uses idle timeout and resets the timer when new events arrive', async () => {\n    vi.useFakeTimers()\n    const fetchMock = vi.fn<typeof fetch>()\n    let eventsRequestCount = 0\n    fetchMock.mockImplementation(async (input: RequestInfo | URL) => {\n      const url = String(input)\n      if (url.includes('/story-to-script-stream')) {\n        return jsonResponse({\n          success: true,\n          async: true,\n          taskId: 'task_1',\n          runId: 'run_1',\n        })\n      }\n\n      if (url === '/api/runs/run_1') {\n        return jsonResponse({\n          run: {\n            id: 'run_1',\n            status: 'running',\n          },\n        })\n      }\n\n      if (!url.includes('/api/runs/run_1/events')) {\n        return jsonResponse({ events: [] })\n      }\n\n      eventsRequestCount += 1\n      if (eventsRequestCount === 3) {\n        return jsonResponse({\n          events: [\n            {\n              seq: 1,\n              eventType: 'run.start',\n              payload: { message: 'started' },\n              createdAt: '2026-02-28T00:00:03.000Z',\n            },\n          ],\n        })\n      }\n\n      return jsonResponse({ events: [] })\n    })\n\n    const originalFetch = globalThis.fetch\n    globalThis.fetch = fetchMock\n\n    try {\n      const controller = new AbortController()\n      let settled = false\n      const request = executeRunRequest({\n        endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',\n        requestBody: { episodeId: 'episode_1' },\n        controller,\n        taskStreamTimeoutMs: 3_000,\n        applyAndCapture: () => undefined,\n        finalResultRef: { current: null },\n      }).finally(() => {\n        settled = true\n      })\n\n      await vi.advanceTimersByTimeAsync(5_000)\n      expect(settled).toBe(false)\n\n      await vi.advanceTimersByTimeAsync(3_000)\n      await expect(request).resolves.toEqual(expect.objectContaining({\n        runId: 'run_1',\n        status: 'failed',\n        errorMessage: 'run stream timeout: run_1',\n      }))\n    } finally {\n      vi.useRealTimers()\n      globalThis.fetch = originalFetch\n    }\n  })\n\n  it('reconciles terminal failed run status when events stream has no new rows', async () => {\n    vi.useFakeTimers()\n    const fetchMock = vi.fn<typeof fetch>()\n    fetchMock.mockImplementation(async (input: RequestInfo | URL) => {\n      const url = String(input)\n      if (url.includes('/story-to-script-stream')) {\n        return jsonResponse({\n          success: true,\n          async: true,\n          taskId: 'task_2',\n          runId: 'run_2',\n        })\n      }\n      if (url.includes('/api/runs/run_2/events')) {\n        return jsonResponse({ events: [] })\n      }\n      if (url === '/api/runs/run_2') {\n        return jsonResponse({\n          run: {\n            id: 'run_2',\n            status: 'failed',\n            errorMessage: 'Ark Responses 调用失败',\n          },\n        })\n      }\n      return jsonResponse({ events: [] })\n    })\n\n    const originalFetch = globalThis.fetch\n    globalThis.fetch = fetchMock\n\n    try {\n      const captured: RunStreamEvent[] = []\n      const controller = new AbortController()\n      const request = executeRunRequest({\n        endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',\n        requestBody: { episodeId: 'episode_1' },\n        controller,\n        taskStreamTimeoutMs: 30_000,\n        applyAndCapture: (event) => {\n          captured.push(event)\n        },\n        finalResultRef: { current: null },\n      })\n\n      await vi.advanceTimersByTimeAsync(3_500)\n      await expect(request).resolves.toEqual(expect.objectContaining({\n        runId: 'run_2',\n        status: 'failed',\n        errorMessage: 'Ark Responses 调用失败',\n      }))\n      expect(fetchMock).toHaveBeenCalledWith(\n        '/api/runs/run_2',\n        expect.objectContaining({ method: 'GET', cache: 'no-store' }),\n      )\n      expect(captured.some((event) => event.event === 'run.error' && event.message === 'Ark Responses 调用失败')).toBe(true)\n    } finally {\n      vi.useRealTimers()\n      globalThis.fetch = originalFetch\n    }\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/run-stream-state-machine.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'\nimport { applyRunStreamEvent, getStageOutput } from '@/lib/query/hooks/run-stream/state-machine'\n\nfunction applySequence(events: RunStreamEvent[]) {\n  let state = null\n  for (const event of events) {\n    state = applyRunStreamEvent(state, event)\n  }\n  return state\n}\n\ndescribe('run stream state-machine', () => {\n  it('marks unfinished steps as failed when run.error arrives', () => {\n    const runId = 'run-1'\n    const state = applySequence([\n      { runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:01.000Z',\n        status: 'running',\n        stepId: 'step-a',\n        stepTitle: 'A',\n        stepIndex: 1,\n        stepTotal: 2,\n      },\n      {\n        runId,\n        event: 'step.complete',\n        ts: '2026-02-26T23:00:02.000Z',\n        status: 'completed',\n        stepId: 'step-b',\n        stepTitle: 'B',\n        stepIndex: 2,\n        stepTotal: 2,\n        text: 'ok',\n      },\n      {\n        runId,\n        event: 'run.error',\n        ts: '2026-02-26T23:00:03.000Z',\n        status: 'failed',\n        message: 'exception TypeError: fetch failed sending request',\n      },\n    ])\n\n    expect(state?.status).toBe('failed')\n    expect(state?.stepsById['step-a']?.status).toBe('failed')\n    expect(state?.stepsById['step-a']?.errorMessage).toContain('fetch failed')\n    expect(state?.stepsById['step-b']?.status).toBe('completed')\n  })\n\n  it('returns readable error output for failed step without stream text', () => {\n    const output = getStageOutput({\n      id: 'step-failed',\n      attempt: 1,\n      title: 'failed',\n      stepIndex: 1,\n      stepTotal: 1,\n      status: 'failed',\n      dependsOn: [],\n      blockedBy: [],\n      groupId: null,\n      parallelKey: null,\n      retryable: true,\n      textOutput: '',\n      reasoningOutput: '',\n      textLength: 0,\n      reasoningLength: 0,\n      message: '',\n      errorMessage: 'exception TypeError: fetch failed sending request',\n      updatedAt: Date.now(),\n      seqByLane: {\n        text: 0,\n        reasoning: 0,\n      },\n    })\n\n    expect(output).toContain('【错误】')\n    expect(output).toContain('fetch failed sending request')\n  })\n\n  it('merges retry attempts into one step instead of duplicating stage entries', () => {\n    const runId = 'run-2'\n    const state = applySequence([\n      { runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:01.000Z',\n        status: 'running',\n        stepId: 'clip_x_phase1',\n        stepTitle: 'A',\n        stepIndex: 1,\n        stepTotal: 1,\n      },\n      {\n        runId,\n        event: 'step.chunk',\n        ts: '2026-02-26T23:00:01.100Z',\n        status: 'running',\n        stepId: 'clip_x_phase1',\n        lane: 'text',\n        seq: 1,\n        textDelta: 'first-attempt',\n      },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:02.000Z',\n        status: 'running',\n        stepId: 'clip_x_phase1_r2',\n        stepTitle: 'A',\n        stepIndex: 1,\n        stepTotal: 1,\n      },\n      {\n        runId,\n        event: 'step.chunk',\n        ts: '2026-02-26T23:00:02.100Z',\n        status: 'running',\n        stepId: 'clip_x_phase1_r2',\n        lane: 'text',\n        seq: 1,\n        textDelta: 'retry-output',\n      },\n    ])\n\n    expect(state?.stepOrder).toEqual(['clip_x_phase1'])\n    expect(state?.stepsById['clip_x_phase1']?.attempt).toBe(2)\n    expect(state?.stepsById['clip_x_phase1']?.textOutput).toBe('retry-output')\n  })\n\n  it('resets step output when a higher stepAttempt starts and ignores stale lower attempt chunks', () => {\n    const runId = 'run-3'\n    const state = applySequence([\n      { runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:01.000Z',\n        status: 'running',\n        stepId: 'clip_y_phase1',\n        stepAttempt: 1,\n        stepTitle: 'A',\n        stepIndex: 1,\n        stepTotal: 1,\n      },\n      {\n        runId,\n        event: 'step.chunk',\n        ts: '2026-02-26T23:00:01.100Z',\n        status: 'running',\n        stepId: 'clip_y_phase1',\n        stepAttempt: 1,\n        lane: 'text',\n        seq: 1,\n        textDelta: 'old-output',\n      },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:02.000Z',\n        status: 'running',\n        stepId: 'clip_y_phase1',\n        stepAttempt: 2,\n        stepTitle: 'A',\n        stepIndex: 1,\n        stepTotal: 1,\n      },\n      {\n        runId,\n        event: 'step.chunk',\n        ts: '2026-02-26T23:00:02.100Z',\n        status: 'running',\n        stepId: 'clip_y_phase1',\n        stepAttempt: 1,\n        lane: 'text',\n        seq: 2,\n        textDelta: 'should-be-ignored',\n      },\n      {\n        runId,\n        event: 'step.chunk',\n        ts: '2026-02-26T23:00:02.200Z',\n        status: 'running',\n        stepId: 'clip_y_phase1',\n        stepAttempt: 2,\n        lane: 'text',\n        seq: 1,\n        textDelta: 'new-output',\n      },\n    ])\n\n    expect(state?.stepsById['clip_y_phase1']?.attempt).toBe(2)\n    expect(state?.stepsById['clip_y_phase1']?.textOutput).toBe('new-output')\n  })\n\n  it('reopens completed step when late chunk arrives, then finalizes on run.complete', () => {\n    const runId = 'run-4'\n    const state = applySequence([\n      { runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:01.000Z',\n        status: 'running',\n        stepId: 'analyze_characters',\n        stepTitle: 'characters',\n        stepIndex: 1,\n        stepTotal: 2,\n      },\n      {\n        runId,\n        event: 'step.complete',\n        ts: '2026-02-26T23:00:02.000Z',\n        status: 'completed',\n        stepId: 'analyze_characters',\n        stepTitle: 'characters',\n        stepIndex: 1,\n        stepTotal: 2,\n        text: 'partial',\n      },\n      {\n        runId,\n        event: 'step.chunk',\n        ts: '2026-02-26T23:00:02.100Z',\n        status: 'running',\n        stepId: 'analyze_characters',\n        lane: 'text',\n        seq: 2,\n        textDelta: '-tail',\n      },\n      {\n        runId,\n        event: 'run.complete',\n        ts: '2026-02-26T23:00:03.000Z',\n        status: 'completed',\n        payload: { ok: true },\n      },\n    ])\n\n    expect(state?.status).toBe('completed')\n    expect(state?.stepsById['analyze_characters']?.status).toBe('completed')\n    expect(state?.stepsById['analyze_characters']?.textOutput).toBe('partial-tail')\n  })\n\n  it('moves activeStepId to the latest step when no step is running', () => {\n    const runId = 'run-5'\n    const state = applySequence([\n      { runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },\n      {\n        runId,\n        event: 'step.complete',\n        ts: '2026-02-26T23:00:01.000Z',\n        status: 'completed',\n        stepId: 'step-1',\n        stepTitle: 'step 1',\n        stepIndex: 1,\n        stepTotal: 2,\n        text: 'a',\n      },\n      {\n        runId,\n        event: 'step.complete',\n        ts: '2026-02-26T23:00:02.000Z',\n        status: 'completed',\n        stepId: 'step-2',\n        stepTitle: 'step 2',\n        stepIndex: 2,\n        stepTotal: 2,\n        text: 'b',\n      },\n    ])\n\n    expect(state?.activeStepId).toBe('step-2')\n  })\n\n  it('marks step as blocked when blockedBy is present', () => {\n    const runId = 'run-6'\n    const state = applySequence([\n      { runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:01.000Z',\n        status: 'running',\n        stepId: 'step-b',\n        stepTitle: 'B',\n        stepIndex: 2,\n        stepTotal: 2,\n        blockedBy: ['step-a'],\n      },\n    ])\n\n    expect(state?.stepsById['step-b']?.status).toBe('blocked')\n    expect(state?.stepsById['step-b']?.blockedBy).toEqual(['step-a'])\n  })\n\n  it('auto-follows active step when selected step was not manually pinned', () => {\n    const runId = 'run-7'\n    const state = applySequence([\n      { runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:01.000Z',\n        status: 'running',\n        stepId: 'step-1',\n        stepTitle: 'step 1',\n        stepIndex: 1,\n        stepTotal: 2,\n      },\n      {\n        runId,\n        event: 'step.complete',\n        ts: '2026-02-26T23:00:02.000Z',\n        status: 'completed',\n        stepId: 'step-1',\n        stepTitle: 'step 1',\n        stepIndex: 1,\n        stepTotal: 2,\n      },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:03.000Z',\n        status: 'running',\n        stepId: 'step-2',\n        stepTitle: 'step 2',\n        stepIndex: 2,\n        stepTotal: 2,\n      },\n    ])\n\n    expect(state?.activeStepId).toBe('step-2')\n    expect(state?.selectedStepId).toBe('step-2')\n  })\n\n  it('moves think-tagged text chunks into reasoning output', () => {\n    const runId = 'run-8'\n    const state = applySequence([\n      { runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },\n      {\n        runId,\n        event: 'step.start',\n        ts: '2026-02-26T23:00:01.000Z',\n        status: 'running',\n        stepId: 'analyze_locations',\n        stepTitle: 'locations',\n        stepIndex: 2,\n        stepTotal: 2,\n      },\n      {\n        runId,\n        event: 'step.chunk',\n        ts: '2026-02-26T23:00:01.200Z',\n        status: 'running',\n        stepId: 'analyze_locations',\n        lane: 'text',\n        seq: 1,\n        textDelta: '<think>先分析文本</think>{\"locations\":[]}',\n      },\n    ])\n\n    expect(state?.stepsById['analyze_locations']?.reasoningOutput).toBe('先分析文本')\n    expect(state?.stepsById['analyze_locations']?.textOutput).toBe('{\"locations\":[]}')\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/run-stream-view.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { deriveRunStreamView } from '@/lib/query/hooks/run-stream/run-stream-view'\nimport type { RunState, RunStepState } from '@/lib/query/hooks/run-stream/types'\n\nfunction buildStep(overrides: Partial<RunStepState> = {}): RunStepState {\n  return {\n    id: 'step-1',\n    attempt: 1,\n    title: 'step',\n    stepIndex: 1,\n    stepTotal: 1,\n    status: 'running',\n    dependsOn: [],\n    blockedBy: [],\n    groupId: null,\n    parallelKey: null,\n    retryable: true,\n    textOutput: '',\n    reasoningOutput: '',\n    textLength: 0,\n    reasoningLength: 0,\n    message: '',\n    errorMessage: '',\n    updatedAt: Date.now(),\n    seqByLane: {\n      text: 0,\n      reasoning: 0,\n    },\n    ...overrides,\n  }\n}\n\nfunction buildRunState(overrides: Partial<RunState> = {}): RunState {\n  const baseStep = buildStep()\n  return {\n    runId: 'run-1',\n    status: 'running',\n    startedAt: Date.now(),\n    updatedAt: Date.now(),\n    terminalAt: null,\n    errorMessage: '',\n    summary: null,\n    payload: null,\n    stepsById: {\n      [baseStep.id]: baseStep,\n    },\n    stepOrder: [baseStep.id],\n    activeStepId: baseStep.id,\n    selectedStepId: baseStep.id,\n    ...overrides,\n  }\n}\n\ndescribe('run stream view', () => {\n  it('keeps console visible for recovered running state', () => {\n    const state = buildRunState({\n      status: 'running',\n      terminalAt: null,\n    })\n\n    const view = deriveRunStreamView({\n      runState: state,\n      isLiveRunning: false,\n      clock: Date.now(),\n    })\n\n    expect(view.isVisible).toBe(true)\n  })\n\n  it('shows run error in output when run failed and selected step has no output', () => {\n    const state = buildRunState({\n      status: 'failed',\n      errorMessage: 'exception TypeError: fetch failed sending request',\n      stepsById: {\n        'step-1': buildStep({ status: 'running' }),\n      },\n    })\n\n    const view = deriveRunStreamView({\n      runState: state,\n      isLiveRunning: false,\n      clock: Date.now(),\n    })\n\n    expect(view.outputText).toContain('【错误】')\n    expect(view.outputText).toContain('fetch failed sending request')\n  })\n\n  it('shows run error in output when run failed before any step starts', () => {\n    const state = buildRunState({\n      status: 'failed',\n      errorMessage: 'NETWORK_ERROR',\n      stepsById: {},\n      stepOrder: [],\n      activeStepId: null,\n      selectedStepId: null,\n    })\n\n    const view = deriveRunStreamView({\n      runState: state,\n      isLiveRunning: false,\n      clock: Date.now(),\n    })\n\n    expect(view.outputText).toBe('【错误】\\nNETWORK_ERROR')\n  })\n\n  it('keeps failed run visible until user reset', () => {\n    const state = buildRunState({\n      status: 'failed',\n      terminalAt: Date.now() - 60_000,\n      errorMessage: 'failed',\n    })\n\n    const view = deriveRunStreamView({\n      runState: state,\n      isLiveRunning: false,\n      clock: Date.now(),\n    })\n\n    expect(view.isVisible).toBe(true)\n  })\n\n  it('hides completed run console after stream settles', () => {\n    const state = buildRunState({\n      status: 'completed',\n      terminalAt: Date.now() - 30_000,\n    })\n\n    const view = deriveRunStreamView({\n      runState: state,\n      isLiveRunning: false,\n      clock: Date.now(),\n    })\n\n    expect(view.isVisible).toBe(false)\n  })\n\n  it('uses active step message instead of selected completed step message', () => {\n    const completedStep = buildStep({\n      id: 'step-1',\n      title: 'step 1',\n      status: 'completed',\n      message: 'progress.runtime.llm.completed',\n      updatedAt: Date.now() - 1000,\n    })\n    const runningStep = buildStep({\n      id: 'step-2',\n      title: 'step 2',\n      stepIndex: 2,\n      stepTotal: 2,\n      status: 'running',\n      message: 'progress.runtime.stage.llmStreaming',\n      updatedAt: Date.now(),\n    })\n    const state = buildRunState({\n      stepsById: {\n        'step-1': completedStep,\n        'step-2': runningStep,\n      },\n      stepOrder: ['step-1', 'step-2'],\n      activeStepId: 'step-2',\n      selectedStepId: 'step-1',\n    })\n\n    const view = deriveRunStreamView({\n      runState: state,\n      isLiveRunning: false,\n      clock: Date.now(),\n    })\n\n    expect(view.activeMessage).toBe('progress.runtime.stage.llmStreaming')\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/task-state-service.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  asBoolean,\n  asNonEmptyString,\n  asObject,\n  buildIdleState,\n  pairKey,\n  resolveTargetState,\n  toProgress,\n} from '@/lib/task/state-service'\n\ndescribe('task state service helpers', () => {\n  it('normalizes primitive parsing helpers', () => {\n    expect(pairKey('A', 'B')).toBe('A:B')\n    expect(asObject({ ok: true })).toEqual({ ok: true })\n    expect(asObject(['x'])).toBeNull()\n    expect(asNonEmptyString(' x ')).toBe('x')\n    expect(asNonEmptyString('  ')).toBeNull()\n    expect(asBoolean(true)).toBe(true)\n    expect(asBoolean('true')).toBeNull()\n    expect(toProgress(101)).toBe(100)\n    expect(toProgress(-5)).toBe(0)\n    expect(toProgress(Number.NaN)).toBeNull()\n  })\n\n  it('builds idle state when no tasks found', () => {\n    const idle = buildIdleState({ targetType: 'GlobalCharacter', targetId: 'c1' })\n    expect(idle.phase).toBe('idle')\n    expect(idle.runningTaskId).toBeNull()\n    expect(idle.lastError).toBeNull()\n  })\n\n  it('resolves processing state from active task', () => {\n    const state = resolveTargetState(\n      { targetType: 'GlobalCharacter', targetId: 'c1' },\n      [\n        {\n          id: 'task-1',\n          type: 'asset_hub_image',\n          status: 'processing',\n          progress: 42,\n          payload: {\n            stage: 'image_generating',\n            stageLabel: 'Generating',\n            ui: { intent: 'create', hasOutputAtStart: false },\n          },\n          errorCode: null,\n          errorMessage: null,\n          updatedAt: new Date('2026-02-25T00:00:00.000Z'),\n        },\n      ],\n    )\n\n    expect(state.phase).toBe('processing')\n    expect(state.runningTaskId).toBe('task-1')\n    expect(state.progress).toBe(42)\n    expect(state.stage).toBe('image_generating')\n    expect(state.stageLabel).toBe('Generating')\n  })\n\n  it('resolves failed state and normalizes error', () => {\n    const state = resolveTargetState(\n      { targetType: 'GlobalCharacter', targetId: 'c1' },\n      [\n        {\n          id: 'task-2',\n          type: 'asset_hub_image',\n          status: 'failed',\n          progress: 100,\n          payload: { ui: { intent: 'modify', hasOutputAtStart: true } },\n          errorCode: 'INVALID_PARAMS',\n          errorMessage: 'bad input',\n          updatedAt: new Date('2026-02-25T00:00:00.000Z'),\n        },\n      ],\n    )\n\n    expect(state.phase).toBe('failed')\n    expect(state.runningTaskId).toBeNull()\n    expect(state.lastError?.code).toBe('INVALID_PARAMS')\n    expect(state.lastError?.message).toBe('bad input')\n  })\n\n  it('treats canceled task as failed presentation state', () => {\n    const state = resolveTargetState(\n      { targetType: 'GlobalCharacter', targetId: 'c1' },\n      [\n        {\n          id: 'task-3',\n          type: 'asset_hub_image',\n          status: 'canceled',\n          progress: 100,\n          payload: { ui: { intent: 'modify', hasOutputAtStart: true } },\n          errorCode: 'TASK_CANCELLED',\n          errorMessage: 'Task cancelled by user',\n          updatedAt: new Date('2026-02-25T00:00:00.000Z'),\n        },\n      ],\n    )\n\n    expect(state.phase).toBe('failed')\n    expect(state.lastError?.code).toBe('CONFLICT')\n    expect(state.lastError?.message).toBe('Task cancelled by user')\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/task-submitter-helpers.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { getTaskFlowMeta } from '@/lib/llm-observe/stage-pipeline'\nimport { normalizeTaskPayload } from '@/lib/task/submitter'\n\ndescribe('task submitter helpers', () => {\n  it('fills default flow metadata when payload misses flow fields', () => {\n    const type = TASK_TYPE.AI_CREATE_CHARACTER\n    const flow = getTaskFlowMeta(type)\n    const normalized = normalizeTaskPayload(type, {})\n\n    expect(normalized.flowId).toBe(flow.flowId)\n    expect(normalized.flowStageIndex).toBe(flow.flowStageIndex)\n    expect(normalized.flowStageTotal).toBe(flow.flowStageTotal)\n    expect(normalized.flowStageTitle).toBe(flow.flowStageTitle)\n    expect(normalized.meta).toMatchObject({\n      flowId: flow.flowId,\n      flowStageIndex: flow.flowStageIndex,\n      flowStageTotal: flow.flowStageTotal,\n      flowStageTitle: flow.flowStageTitle,\n    })\n  })\n\n  it('normalizes negative stage values', () => {\n    const normalized = normalizeTaskPayload(TASK_TYPE.ANALYZE_NOVEL, {\n      flowId: 'flow-a',\n      flowStageIndex: -9,\n      flowStageTotal: -1,\n      flowStageTitle: ' title ',\n      meta: {},\n    })\n\n    expect(normalized.flowId).toBe('flow-a')\n    expect(normalized.flowStageIndex).toBeGreaterThanOrEqual(1)\n    expect(normalized.flowStageTotal).toBeGreaterThanOrEqual(normalized.flowStageIndex)\n    expect(normalized.flowStageTitle).toBe('title')\n  })\n\n  it('prefers payload meta flow values when valid', () => {\n    const normalized = normalizeTaskPayload(TASK_TYPE.ANALYZE_NOVEL, {\n      flowId: 'outer-flow',\n      flowStageIndex: 1,\n      flowStageTotal: 2,\n      flowStageTitle: 'Outer',\n      meta: {\n        flowId: 'meta-flow',\n        flowStageIndex: 3,\n        flowStageTotal: 7,\n        flowStageTitle: 'Meta',\n      },\n    })\n\n    const meta = normalized.meta as Record<string, unknown>\n    expect(meta.flowId).toBe('meta-flow')\n    expect(meta.flowStageIndex).toBe(3)\n    expect(meta.flowStageTotal).toBe(7)\n    expect(meta.flowStageTitle).toBe('Meta')\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/update-check.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest'\nimport {\n  checkGithubReleaseUpdate,\n  compareSemver,\n  normalizeSemverTag,\n  shouldPulseUpdate,\n} from '@/lib/update-check'\n\ndescribe('update-check semver helpers', () => {\n  it('normalizes semver tag with v prefix', () => {\n    expect(normalizeSemverTag('v0.3.0')).toBe('0.3.0')\n  })\n\n  it('supports prerelease suffix while comparing base semver', () => {\n    expect(normalizeSemverTag('v0.3.0-rc.1')).toBe('0.3.0')\n    expect(compareSemver('0.3.0-rc.1', '0.2.9')).toBe(1)\n  })\n\n  it('throws for malformed semver', () => {\n    expect(() => normalizeSemverTag('0.3')).toThrowError('Invalid semver tag: 0.3')\n  })\n\n  it('compares semver in numeric order', () => {\n    expect(compareSemver('0.3.0', '0.2.9')).toBe(1)\n    expect(compareSemver('0.2.0', '0.2.0')).toBe(0)\n    expect(compareSemver('0.1.9', '0.2.0')).toBe(-1)\n  })\n\n  it('pulses only when this version was not muted', () => {\n    expect(shouldPulseUpdate('0.3.0', null)).toBe(true)\n    expect(shouldPulseUpdate('0.3.0', '0.2.9')).toBe(true)\n    expect(shouldPulseUpdate('0.3.0', '0.3.0')).toBe(false)\n  })\n})\n\ndescribe('checkGithubReleaseUpdate', () => {\n  it('returns no-release when GitHub has no releases yet', async () => {\n    const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 404 }))\n\n    const result = await checkGithubReleaseUpdate({\n      repository: 'owner/repo',\n      currentVersion: '0.2.0',\n      fetchImpl: fetchMock,\n    })\n\n    expect(result).toEqual({ kind: 'no-release' })\n  })\n\n  it('returns update-available when latest release is newer', async () => {\n    const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(\n      JSON.stringify({\n        tag_name: 'v0.3.0',\n        html_url: 'https://github.com/owner/repo/releases/tag/v0.3.0',\n        name: 'v0.3.0',\n        published_at: '2026-03-03T10:00:00Z',\n      }),\n      { status: 200 },\n    ))\n\n    const result = await checkGithubReleaseUpdate({\n      repository: 'owner/repo',\n      currentVersion: '0.2.0',\n      fetchImpl: fetchMock,\n    })\n\n    expect(result.kind).toBe('update-available')\n    if (result.kind !== 'update-available') {\n      throw new Error('expected update-available result')\n    }\n\n    expect(result.latestVersion).toBe('0.3.0')\n    expect(result.release.tagName).toBe('v0.3.0')\n  })\n\n  it('returns no-update when latest release equals current version', async () => {\n    const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(\n      JSON.stringify({\n        tag_name: 'v0.2.0',\n        html_url: 'https://github.com/owner/repo/releases/tag/v0.2.0',\n        name: 'v0.2.0',\n        published_at: '2026-03-03T10:00:00Z',\n      }),\n      { status: 200 },\n    ))\n\n    const result = await checkGithubReleaseUpdate({\n      repository: 'owner/repo',\n      currentVersion: '0.2.0',\n      fetchImpl: fetchMock,\n    })\n\n    expect(result.kind).toBe('no-update')\n    if (result.kind !== 'no-update') {\n      throw new Error('expected no-update result')\n    }\n\n    expect(result.latestVersion).toBe('0.2.0')\n  })\n\n  it('returns error when release tag is not valid semver', async () => {\n    const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(\n      JSON.stringify({\n        tag_name: 'release-2026-03-03',\n        html_url: 'https://github.com/owner/repo/releases/tag/release-2026-03-03',\n      }),\n      { status: 200 },\n    ))\n\n    const result = await checkGithubReleaseUpdate({\n      repository: 'owner/repo',\n      currentVersion: '0.2.0',\n      fetchImpl: fetchMock,\n    })\n\n    expect(result.kind).toBe('error')\n    if (result.kind !== 'error') {\n      throw new Error('expected error result')\n    }\n\n    expect(result.reason).toBe('invalid-version')\n  })\n})\n"
  },
  {
    "path": "tests/unit/helpers/workspace-model-setup.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { hasConfiguredAnalysisModel, readConfiguredAnalysisModel, shouldGuideToModelSetup } from '@/lib/workspace/model-setup'\n\ndescribe('workspace model setup guidance', () => {\n  it('有 analysisModel -> 不需要引导设置', () => {\n    const payload = {\n      preference: {\n        analysisModel: 'openai::gpt-4.1',\n      },\n    }\n\n    expect(hasConfiguredAnalysisModel(payload)).toBe(true)\n    expect(readConfiguredAnalysisModel(payload)).toBe('openai::gpt-4.1')\n    expect(shouldGuideToModelSetup(payload)).toBe(false)\n  })\n\n  it('analysisModel 为空 -> 需要引导设置', () => {\n    const payload = {\n      preference: {\n        analysisModel: '   ',\n      },\n    }\n\n    expect(hasConfiguredAnalysisModel(payload)).toBe(false)\n    expect(readConfiguredAnalysisModel(payload)).toBeNull()\n    expect(shouldGuideToModelSetup(payload)).toBe(true)\n  })\n\n  it('payload 非法 -> 需要引导设置', () => {\n    expect(hasConfiguredAnalysisModel(null)).toBe(false)\n    expect(readConfiguredAnalysisModel(null)).toBeNull()\n    expect(hasConfiguredAnalysisModel({})).toBe(false)\n    expect(readConfiguredAnalysisModel({})).toBeNull()\n    expect(shouldGuideToModelSetup({})).toBe(true)\n  })\n})\n"
  },
  {
    "path": "tests/unit/image-generation/count.test.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest'\nimport {\n  getImageGenerationCountConfig,\n  getImageGenerationCountOptions,\n  normalizeImageGenerationCount,\n} from '@/lib/image-generation/count'\nimport {\n  getImageGenerationCount,\n  setImageGenerationCount,\n} from '@/lib/image-generation/count-preference'\n\ndescribe('image generation count helpers', () => {\n  afterEach(() => {\n    vi.unstubAllGlobals()\n  })\n\n  it('normalizes values within each scope range', () => {\n    expect(normalizeImageGenerationCount('character', 0)).toBe(1)\n    expect(normalizeImageGenerationCount('character', 8)).toBe(6)\n    expect(normalizeImageGenerationCount('storyboard-candidates', 0)).toBe(1)\n    expect(normalizeImageGenerationCount('storyboard-candidates', 9)).toBe(4)\n  })\n\n  it('returns ordered options for each scope', () => {\n    expect(getImageGenerationCountOptions('character')).toEqual([1, 2, 3, 4, 5, 6])\n    expect(getImageGenerationCountOptions('storyboard-candidates')).toEqual([1, 2, 3, 4])\n  })\n\n  it('reads and writes client preference with scope isolation', () => {\n    const localStorageMock = {\n      getItem: vi.fn((key: string) => {\n        if (key === getImageGenerationCountConfig('character').storageKey) return '5'\n        if (key === getImageGenerationCountConfig('location').storageKey) return '2'\n        return null\n      }),\n      setItem: vi.fn(),\n    }\n    vi.stubGlobal('window', { localStorage: localStorageMock })\n\n    expect(getImageGenerationCount('character')).toBe(5)\n    expect(getImageGenerationCount('location')).toBe(2)\n    expect(setImageGenerationCount('storyboard-candidates', 8)).toBe(4)\n    expect(localStorageMock.setItem).toHaveBeenCalledWith(\n      getImageGenerationCountConfig('storyboard-candidates').storageKey,\n      '4',\n    )\n  })\n})\n"
  },
  {
    "path": "tests/unit/image-generation/slot-state.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  countGeneratedImageSlots,\n  resolveDisplayImageSlots,\n  resolveGroupedImageSlotPhase,\n  resolveImageSlotPhase,\n  shouldShowImageSlotGrid,\n} from '@/lib/image-generation/slot-state'\n\ndescribe('image slot state', () => {\n  it('counts only slots with image urls', () => {\n    expect(countGeneratedImageSlots([\n      { imageUrl: 'a.png' },\n      { imageUrl: null },\n      { imageUrl: 'b.png' },\n    ])).toBe(2)\n  })\n\n  it('distinguishes generate and regenerate phases', () => {\n    expect(resolveImageSlotPhase({ imageUrl: null }, true)).toBe('generating')\n    expect(resolveImageSlotPhase({ imageUrl: 'a.png' }, true)).toBe('regenerating')\n    expect(resolveImageSlotPhase({ imageUrl: null }, false)).toBe('idle-empty')\n    expect(resolveImageSlotPhase({ imageUrl: 'a.png' }, false)).toBe('idle-filled')\n  })\n\n  it('keeps completed filled slots idle while the group still has empty pending slots', () => {\n    expect(resolveGroupedImageSlotPhase(\n      { imageUrl: 'a.png' },\n      { isGroupRunning: true, isSlotRunning: false, hasPendingEmptySlots: true },\n    )).toBe('idle-filled')\n\n    expect(resolveGroupedImageSlotPhase(\n      { imageUrl: null },\n      { isGroupRunning: true, isSlotRunning: true, hasPendingEmptySlots: true },\n    )).toBe('generating')\n  })\n\n  it('hides legacy empty slots when the location is idle', () => {\n    const displaySlots = resolveDisplayImageSlots([\n      { imageUrl: 'a.png' },\n      { imageUrl: null },\n      { imageUrl: null },\n    ], {\n      hasRunningTask: false,\n      requestedCount: 1,\n    })\n\n    expect(displaySlots).toHaveLength(1)\n    expect(displaySlots[0]?.imageUrl).toBe('a.png')\n  })\n\n  it('shows only one slot while running a single-image location generation', () => {\n    const displaySlots = resolveDisplayImageSlots([\n      { imageUrl: null },\n      { imageUrl: null },\n      { imageUrl: null },\n    ], {\n      hasRunningTask: true,\n      requestedCount: 1,\n    })\n\n    expect(displaySlots).toHaveLength(1)\n  })\n\n  it('shows requested placeholders while running a multi-image location generation', () => {\n    const displaySlots = resolveDisplayImageSlots([\n      { imageUrl: 'a.png' },\n      { imageUrl: null },\n      { imageUrl: null },\n      { imageUrl: null },\n    ], {\n      hasRunningTask: true,\n      requestedCount: 4,\n    })\n\n    expect(displaySlots).toHaveLength(4)\n  })\n\n  it('shows slot grid only after generation is active or meaningful', () => {\n    expect(shouldShowImageSlotGrid({\n      totalSlotCount: 3,\n      generatedCount: 0,\n      hasRunningTask: false,\n      hasAnyError: false,\n    })).toBe(false)\n\n    expect(shouldShowImageSlotGrid({\n      totalSlotCount: 3,\n      generatedCount: 0,\n      hasRunningTask: true,\n      hasAnyError: false,\n    })).toBe(true)\n\n    expect(shouldShowImageSlotGrid({\n      totalSlotCount: 3,\n      generatedCount: 1,\n      hasRunningTask: false,\n      hasAnyError: false,\n    })).toBe(true)\n  })\n})\n"
  },
  {
    "path": "tests/unit/lipsync-bailian.test.ts",
    "content": "import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst resolveModelSelectionOrSingleMock = vi.hoisted(() => vi.fn())\nconst getProviderConfigMock = vi.hoisted(() => vi.fn())\nconst getProviderKeyMock = vi.hoisted(() => vi.fn((providerId: string) => {\n  const marker = providerId.indexOf(':')\n  return marker === -1 ? providerId : providerId.slice(0, marker)\n}))\nconst submitFalTaskMock = vi.hoisted(() => vi.fn())\nconst normalizeToOriginalMediaUrlMock = vi.hoisted(() => vi.fn(async (input: string) => {\n  if (input.startsWith('/')) {\n    return `http://localhost:3000${input}`\n  }\n  return input\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  resolveModelSelectionOrSingle: resolveModelSelectionOrSingleMock,\n  getProviderConfig: getProviderConfigMock,\n  getProviderKey: getProviderKeyMock,\n}))\n\nvi.mock('@/lib/async-submit', () => ({\n  submitFalTask: submitFalTaskMock,\n}))\n\nvi.mock('@/lib/media/outbound-image', () => ({\n  normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),\n  normalizeToOriginalMediaUrl: normalizeToOriginalMediaUrlMock,\n}))\n\nvi.mock('@/lib/logging/core', () => ({\n  logInfo: vi.fn(),\n  logError: vi.fn(),\n  createScopedLogger: vi.fn(() => ({\n    info: vi.fn(),\n    warn: vi.fn(),\n    error: vi.fn(),\n    debug: vi.fn(),\n  })),\n}))\n\nimport { generateLipSync } from '@/lib/lipsync'\n\nconst POLICY_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/uploads'\nconst SUBMIT_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis'\nconst UPLOAD_HOST = 'https://upload.example.com'\n\nfunction buildJsonResponse(payload: unknown, status = 200): Response {\n  return {\n    ok: status >= 200 && status < 300,\n    status,\n    headers: new Headers({\n      'content-type': 'application/json',\n    }),\n    text: async () => JSON.stringify(payload),\n  } as unknown as Response\n}\n\nfunction buildBinaryResponse(contentType: string, data: string): Response {\n  const bytes = new TextEncoder().encode(data)\n  return {\n    ok: true,\n    status: 200,\n    headers: new Headers({\n      'content-type': contentType,\n    }),\n    arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),\n    text: async () => '',\n  } as unknown as Response\n}\n\ndescribe('lip-sync bailian submit', () => {\n  const originalNextauthUrl = process.env.NEXTAUTH_URL\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n    process.env.NEXTAUTH_URL = originalNextauthUrl\n    resolveModelSelectionOrSingleMock.mockResolvedValue({\n      provider: 'bailian',\n      modelId: 'videoretalk',\n      modelKey: 'bailian::videoretalk',\n      mediaType: 'lipsync',\n    })\n    getProviderConfigMock.mockResolvedValue({\n      id: 'bailian',\n      apiKey: 'bl-key',\n    })\n  })\n\n  afterAll(() => {\n    process.env.NEXTAUTH_URL = originalNextauthUrl\n  })\n\n  it('uploads local media to bailian temp storage then submits oss urls', async () => {\n    const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n      const url = String(input)\n      if (url.startsWith(`${POLICY_ENDPOINT}?action=getPolicy&model=videoretalk`)) {\n        return buildJsonResponse({\n          data: {\n            upload_host: UPLOAD_HOST,\n            upload_dir: 'dashscope-instant/upload-dir',\n            oss_access_key_id: 'ak',\n            policy: 'policy',\n            signature: 'sig',\n          },\n        })\n      }\n      if (url === 'http://localhost:3000/api/storage/sign?key=images%2Fdemo.mp4') {\n        return buildBinaryResponse('video/mp4', 'video-bytes')\n      }\n      if (url === 'http://localhost:3000/api/storage/sign?key=voice%2Fdemo.wav') {\n        return buildBinaryResponse('audio/wav', 'audio-bytes')\n      }\n      if (url === UPLOAD_HOST) {\n        return {\n          ok: true,\n          status: 200,\n          text: async () => '',\n        } as unknown as Response\n      }\n      if (url === SUBMIT_ENDPOINT) {\n        return buildJsonResponse({\n          output: {\n            task_id: 'task-123',\n            task_status: 'PENDING',\n          },\n        })\n      }\n      throw new Error(`unexpected fetch: ${url}`)\n    })\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await generateLipSync(\n      {\n        videoUrl: '/api/storage/sign?key=images%2Fdemo.mp4',\n        audioUrl: '/api/storage/sign?key=voice%2Fdemo.wav',\n        audioDurationMs: 3000,\n        videoDurationMs: 5000,\n      },\n      'user-1',\n      'bailian::videoretalk',\n    )\n\n    expect(resolveModelSelectionOrSingleMock).toHaveBeenCalledWith('user-1', 'bailian::videoretalk', 'lipsync')\n    expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')\n    expect(normalizeToOriginalMediaUrlMock).toHaveBeenCalledWith('/api/storage/sign?key=images%2Fdemo.mp4')\n    expect(normalizeToOriginalMediaUrlMock).toHaveBeenCalledWith('/api/storage/sign?key=voice%2Fdemo.wav')\n\n    const submitCall = fetchMock.mock.calls.find(([input]) => String(input) === SUBMIT_ENDPOINT) as\n      | [RequestInfo | URL, RequestInit?]\n      | undefined\n    expect(submitCall).toBeDefined()\n    const submitInit = submitCall?.[1]\n    expect(submitInit).toBeDefined()\n    if (!submitInit) throw new Error('missing submit init')\n    expect(submitInit.method).toBe('POST')\n    expect(submitInit.headers).toEqual({\n      Authorization: 'Bearer bl-key',\n      'Content-Type': 'application/json',\n      'X-DashScope-Async': 'enable',\n      'X-DashScope-OssResourceResolve': 'enable',\n    })\n    const submitBody = JSON.parse(String(submitInit.body)) as {\n      model: string\n      input: { video_url: string; audio_url: string }\n    }\n    expect(submitBody.model).toBe('videoretalk')\n    expect(submitBody.input.video_url).toMatch(/^oss:\\/\\/dashscope-instant\\/upload-dir\\/video-/)\n    expect(submitBody.input.audio_url).toMatch(/^oss:\\/\\/dashscope-instant\\/upload-dir\\/audio-/)\n\n    const uploadCalls = fetchMock.mock.calls.filter(([input]) => String(input) === UPLOAD_HOST)\n    expect(uploadCalls.length).toBe(2)\n    expect(result).toEqual({\n      requestId: 'task-123',\n      externalId: 'BAILIAN:VIDEO:task-123',\n      async: true,\n    })\n  })\n\n  it('throws explicit error when bailian task id is missing', async () => {\n    const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n      const url = String(input)\n      if (url.startsWith(`${POLICY_ENDPOINT}?action=getPolicy&model=videoretalk`)) {\n        return buildJsonResponse({\n          data: {\n            upload_host: UPLOAD_HOST,\n            upload_dir: 'dashscope-instant/upload-dir',\n            oss_access_key_id: 'ak',\n            policy: 'policy',\n            signature: 'sig',\n          },\n        })\n      }\n      if (url === UPLOAD_HOST) {\n        return {\n          ok: true,\n          status: 200,\n          text: async () => '',\n        } as unknown as Response\n      }\n      if (url === SUBMIT_ENDPOINT) {\n        return buildJsonResponse({\n          output: {\n            task_status: 'PENDING',\n          },\n        })\n      }\n      throw new Error(`unexpected fetch: ${url}`)\n    })\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    await expect(generateLipSync(\n      {\n        videoUrl: 'data:video/mp4;base64,dmk=',\n        audioUrl: 'data:audio/wav;base64,YXU=',\n        audioDurationMs: 3000,\n        videoDurationMs: 5000,\n      },\n      'user-1',\n      'bailian::videoretalk',\n    )).rejects.toThrow('BAILIAN_LIPSYNC_TASK_ID_MISSING')\n  })\n})\n"
  },
  {
    "path": "tests/unit/lipsync-preprocess.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst normalizeToOriginalMediaUrlMock = vi.hoisted(() => vi.fn(async (input: string) => input))\nconst uploadObjectMock = vi.hoisted(() => vi.fn(async () => 'voice/temp/lip-sync-preprocessed/test.wav'))\nconst getSignedUrlMock = vi.hoisted(() => vi.fn(() => '/api/storage/sign?key=voice%2Ftemp%2Flip-sync-preprocessed%2Ftest.wav'))\nconst toFetchableUrlMock = vi.hoisted(() => vi.fn((input: string) => {\n  if (input.startsWith('http://') || input.startsWith('https://') || input.startsWith('data:')) return input\n  if (input.startsWith('/')) return `https://public.example.com${input}`\n  return input\n}))\n\nvi.mock('@/lib/media/outbound-image', () => ({\n  normalizeToOriginalMediaUrl: normalizeToOriginalMediaUrlMock,\n}))\n\nvi.mock('@/lib/storage', () => ({\n  uploadObject: uploadObjectMock,\n  getSignedUrl: getSignedUrlMock,\n}))\n\nvi.mock('@/lib/storage/utils', () => ({\n  toFetchableUrl: toFetchableUrlMock,\n}))\n\nvi.mock('@/lib/logging/core', () => ({\n  logInfo: vi.fn(),\n}))\n\nimport {\n  LIPSYNC_PREPROCESS_AUDIO_MIN_MS,\n  preprocessLipSyncParams,\n} from '@/lib/lipsync/preprocess'\n\nfunction buildWav(durationMs: number, sampleRate = 16000): Buffer {\n  const numChannels = 1\n  const bitsPerSample = 16\n  const blockAlign = (numChannels * bitsPerSample) / 8\n  const byteRate = sampleRate * blockAlign\n  const dataSize = Math.max(blockAlign, Math.round((durationMs / 1000) * byteRate))\n  const buffer = Buffer.alloc(44 + dataSize)\n  buffer.write('RIFF', 0, 'ascii')\n  buffer.writeUInt32LE(36 + dataSize, 4)\n  buffer.write('WAVE', 8, 'ascii')\n  buffer.write('fmt ', 12, 'ascii')\n  buffer.writeUInt32LE(16, 16)\n  buffer.writeUInt16LE(1, 20)\n  buffer.writeUInt16LE(numChannels, 22)\n  buffer.writeUInt32LE(sampleRate, 24)\n  buffer.writeUInt32LE(byteRate, 28)\n  buffer.writeUInt16LE(blockAlign, 32)\n  buffer.writeUInt16LE(bitsPerSample, 34)\n  buffer.write('data', 36, 'ascii')\n  buffer.writeUInt32LE(dataSize, 40)\n  return buffer\n}\n\nfunction buildMp4WithDuration(durationMs: number): Buffer {\n  const timescale = 1000\n  const duration = Math.max(1, Math.round(durationMs))\n  const mvhdPayload = Buffer.alloc(4 + 4 + 4 + 4 + 4)\n  mvhdPayload.writeUInt8(0, 0)\n  mvhdPayload.writeUInt32BE(0, 4)\n  mvhdPayload.writeUInt32BE(0, 8)\n  mvhdPayload.writeUInt32BE(timescale, 12)\n  mvhdPayload.writeUInt32BE(duration, 16)\n  const mvhdSize = 8 + mvhdPayload.length\n  const mvhd = Buffer.alloc(mvhdSize)\n  mvhd.writeUInt32BE(mvhdSize, 0)\n  mvhd.write('mvhd', 4, 'ascii')\n  mvhdPayload.copy(mvhd, 8)\n\n  const moovSize = 8 + mvhd.length\n  const moov = Buffer.alloc(moovSize)\n  moov.writeUInt32BE(moovSize, 0)\n  moov.write('moov', 4, 'ascii')\n  mvhd.copy(moov, 8)\n\n  const ftyp = Buffer.alloc(24)\n  ftyp.writeUInt32BE(24, 0)\n  ftyp.write('ftyp', 4, 'ascii')\n  ftyp.write('isom', 8, 'ascii')\n  ftyp.writeUInt32BE(0x200, 12)\n  ftyp.write('isom', 16, 'ascii')\n  ftyp.write('mp41', 20, 'ascii')\n\n  return Buffer.concat([ftyp, moov])\n}\n\nfunction readWavDurationMs(buffer: Buffer): number {\n  const byteRate = buffer.readUInt32LE(28)\n  const dataSize = buffer.readUInt32LE(40)\n  return Math.round((dataSize / byteRate) * 1000)\n}\n\nfunction buildBinaryResponse(buffer: Buffer, contentType: string): Response {\n  return {\n    ok: true,\n    status: 200,\n    headers: new Headers({\n      'content-type': contentType,\n    }),\n    arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),\n    text: async () => '',\n  } as unknown as Response\n}\n\ndescribe('lipsync preprocess', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('pads short audio to minimum duration for fal', async () => {\n    const shortAudio = buildWav(1000)\n    const video = buildMp4WithDuration(5000)\n\n    const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n      const url = String(input)\n      if (url.includes('video.mp4')) return buildBinaryResponse(video, 'video/mp4')\n      if (url.includes('audio.wav')) return buildBinaryResponse(shortAudio, 'audio/wav')\n      throw new Error(`unexpected fetch: ${url}`)\n    })\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await preprocessLipSyncParams(\n      {\n        videoUrl: 'https://assets.example.com/video.mp4',\n        audioUrl: 'https://assets.example.com/audio.wav',\n        audioDurationMs: 1000,\n      },\n      { providerKey: 'fal' },\n    )\n\n    expect(result.paddedAudio).toBe(true)\n    expect(result.trimmedAudio).toBe(false)\n    expect(result.params.audioUrl.startsWith('data:audio/wav;base64,')).toBe(true)\n    const base64 = result.params.audioUrl.slice('data:audio/wav;base64,'.length)\n    const paddedBuffer = Buffer.from(base64, 'base64')\n    expect(readWavDurationMs(paddedBuffer)).toBeGreaterThanOrEqual(LIPSYNC_PREPROCESS_AUDIO_MIN_MS)\n    expect(uploadObjectMock).not.toHaveBeenCalled()\n  })\n\n  it('trims audio to video duration for vidu and uploads processed audio', async () => {\n    const longAudio = buildWav(7000)\n    const video = buildMp4WithDuration(5000)\n\n    const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n      const url = String(input)\n      if (url.includes('video.mp4')) return buildBinaryResponse(video, 'video/mp4')\n      if (url.includes('audio.wav')) return buildBinaryResponse(longAudio, 'audio/wav')\n      throw new Error(`unexpected fetch: ${url}`)\n    })\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await preprocessLipSyncParams(\n      {\n        videoUrl: 'https://assets.example.com/video.mp4',\n        audioUrl: 'https://assets.example.com/audio.wav',\n        audioDurationMs: 7000,\n      },\n      { providerKey: 'vidu' },\n    )\n\n    expect(result.paddedAudio).toBe(false)\n    expect(result.trimmedAudio).toBe(true)\n    expect(uploadObjectMock).toHaveBeenCalledTimes(1)\n    const uploadCall = uploadObjectMock.mock.calls[0] as unknown as [Buffer] | undefined\n    expect(uploadCall).toBeTruthy()\n    if (!uploadCall) throw new Error('expected uploadObject call')\n    const uploadedBuffer = uploadCall[0]\n    expect(readWavDurationMs(uploadedBuffer)).toBeLessThanOrEqual(5000)\n    expect(result.params.audioUrl).toBe('https://public.example.com/api/storage/sign?key=voice%2Ftemp%2Flip-sync-preprocessed%2Ftest.wav')\n  })\n\n  it('probes durations and keeps audio unchanged when no adjustment is needed', async () => {\n    const audio = buildWav(3000)\n    const video = buildMp4WithDuration(5000)\n    const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n      const url = String(input)\n      if (url.includes('video.mp4')) return buildBinaryResponse(video, 'video/mp4')\n      if (url.includes('audio.wav')) return buildBinaryResponse(audio, 'audio/wav')\n      throw new Error(`unexpected fetch: ${url}`)\n    })\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await preprocessLipSyncParams(\n      {\n        videoUrl: 'https://assets.example.com/video.mp4',\n        audioUrl: 'https://assets.example.com/audio.wav',\n      },\n      { providerKey: 'bailian' },\n    )\n\n    expect(result.paddedAudio).toBe(false)\n    expect(result.trimmedAudio).toBe(false)\n    expect(result.params.audioUrl).toBe('https://assets.example.com/audio.wav')\n    expect(fetchMock).toHaveBeenCalled()\n    expect(uploadObjectMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/unit/llm/ark-llm-thinking.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { buildArkThinkingParam } from '@/lib/ark-llm'\n\ndescribe('ark thinking param builder', () => {\n  it('builds enabled thinking param without reasoning_effort', () => {\n    const params = buildArkThinkingParam('doubao-seed-2-0-lite-260215', true)\n    expect(params).toEqual({\n      thinking: {\n        type: 'enabled',\n      },\n    })\n  })\n\n  it('builds disabled thinking param without reasoning_effort', () => {\n    const params = buildArkThinkingParam('doubao-seed-2-0-lite-260215', false)\n    expect(params).toEqual({\n      thinking: {\n        type: 'disabled',\n      },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/llm/chat-completion-official-provider.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst resolveLlmRuntimeModelMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    provider: 'bailian',\n    modelId: 'qwen3.5-flash',\n    modelKey: 'bailian::qwen3.5-flash',\n  })),\n)\n\nconst completeBailianLlmMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'chatcmpl_mock',\n    object: 'chat.completion',\n    created: 1,\n    model: 'qwen3.5-flash',\n    choices: [\n      {\n        index: 0,\n        message: { role: 'assistant', content: 'ok' },\n        finish_reason: 'stop',\n      },\n    ],\n    usage: {\n      prompt_tokens: 1,\n      completion_tokens: 1,\n      total_tokens: 2,\n    },\n  })),\n)\n\nconst completeSiliconFlowLlmMock = vi.hoisted(() =>\n  vi.fn(async () => {\n    throw new Error('siliconflow should not be called')\n  }),\n)\n\nconst runOpenAICompatChatCompletionMock = vi.hoisted(() =>\n  vi.fn(async () => {\n    throw new Error('openai-compat should not be called')\n  }),\n)\n\nconst getProviderConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'bailian',\n    name: 'Alibaba Bailian',\n    apiKey: 'bl-key',\n    baseUrl: undefined,\n    gatewayRoute: 'official' as const,\n  })),\n)\n\nconst llmLoggerInfoMock = vi.hoisted(() => vi.fn())\nconst llmLoggerWarnMock = vi.hoisted(() => vi.fn())\nconst logLlmRawInputMock = vi.hoisted(() => vi.fn())\nconst logLlmRawOutputMock = vi.hoisted(() => vi.fn())\nconst recordCompletionUsageMock = vi.hoisted(() => vi.fn())\n\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  getInternalLLMStreamCallbacks: vi.fn(() => null),\n}))\n\nvi.mock('@/lib/model-gateway', () => ({\n  resolveModelGatewayRoute: vi.fn(() => 'official'),\n  runOpenAICompatChatCompletion: runOpenAICompatChatCompletionMock,\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n  getProviderKey: vi.fn((providerId: string) => providerId),\n}))\n\nvi.mock('@/lib/providers/bailian', () => ({\n  completeBailianLlm: completeBailianLlmMock,\n}))\n\nvi.mock('@/lib/providers/siliconflow', () => ({\n  completeSiliconFlowLlm: completeSiliconFlowLlmMock,\n}))\n\nvi.mock('@/lib/llm/runtime-shared', () => ({\n  _ulogError: vi.fn(),\n  _ulogWarn: vi.fn(),\n  completionUsageSummary: vi.fn(() => ({ promptTokens: 1, completionTokens: 1 })),\n  isRetryableError: vi.fn(() => false),\n  llmLogger: {\n    info: llmLoggerInfoMock,\n    warn: llmLoggerWarnMock,\n  },\n  logLlmRawInput: logLlmRawInputMock,\n  logLlmRawOutput: logLlmRawOutputMock,\n  recordCompletionUsage: recordCompletionUsageMock,\n  resolveLlmRuntimeModel: resolveLlmRuntimeModelMock,\n}))\n\nimport { chatCompletion } from '@/lib/llm/chat-completion'\n\ndescribe('llm chatCompletion official provider branch', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('returns completion from bailian official provider without falling through to baseUrl checks', async () => {\n    const result = await chatCompletion(\n      'user-1',\n      'bailian::qwen3.5-flash',\n      [{ role: 'user', content: 'hello' }],\n      { temperature: 0.1 },\n    )\n\n    expect(completeBailianLlmMock).toHaveBeenCalledWith({\n      modelId: 'qwen3.5-flash',\n      messages: [{ role: 'user', content: 'hello' }],\n      apiKey: 'bl-key',\n      baseUrl: undefined,\n      temperature: 0.1,\n    })\n    expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()\n    expect(completeSiliconFlowLlmMock).not.toHaveBeenCalled()\n    expect(result.choices[0]?.message?.content).toBe('ok')\n    expect(recordCompletionUsageMock).toHaveBeenCalledTimes(1)\n  })\n})\n"
  },
  {
    "path": "tests/unit/llm/chat-completion-openai-compatible-protocol.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\ntype MockRuntimeModel = {\n  provider: string\n  modelId: string\n  modelKey: string\n  llmProtocol: 'responses' | 'chat-completions' | undefined\n}\n\nconst resolveLlmRuntimeModelMock = vi.hoisted(() =>\n  vi.fn<(...args: unknown[]) => Promise<MockRuntimeModel>>(async () => ({\n    provider: 'openai-compatible:node-1',\n    modelId: 'gpt-4.1-mini',\n    modelKey: 'openai-compatible:node-1::gpt-4.1-mini',\n    llmProtocol: 'responses',\n  })),\n)\n\nconst runOpenAICompatResponsesCompletionMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'chatcmpl_responses_1',\n    object: 'chat.completion',\n    created: 1,\n    model: 'gpt-4.1-mini',\n    choices: [{ index: 0, message: { role: 'assistant', content: 'responses-ok' }, finish_reason: 'stop' }],\n    usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },\n  })),\n)\n\nconst runOpenAICompatChatCompletionMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'chatcmpl_chat_1',\n    object: 'chat.completion',\n    created: 1,\n    model: 'gpt-4.1-mini',\n    choices: [{ index: 0, message: { role: 'assistant', content: 'chat-ok' }, finish_reason: 'stop' }],\n    usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },\n  })),\n)\n\nconst getProviderConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'openai-compatible:node-1',\n    name: 'OpenAI Compatible',\n    apiKey: 'sk-test',\n    baseUrl: 'https://compat.example.com/v1',\n    gatewayRoute: 'openai-compat' as const,\n    apiMode: 'openai-official' as const,\n  })),\n)\n\nconst logLlmRawInputMock = vi.hoisted(() => vi.fn())\nconst logLlmRawOutputMock = vi.hoisted(() => vi.fn())\nconst recordCompletionUsageMock = vi.hoisted(() => vi.fn())\n\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  getInternalLLMStreamCallbacks: vi.fn(() => null),\n}))\n\nvi.mock('@/lib/model-gateway', () => ({\n  resolveModelGatewayRoute: vi.fn(() => 'openai-compat'),\n  runOpenAICompatChatCompletion: runOpenAICompatChatCompletionMock,\n  runOpenAICompatResponsesCompletion: runOpenAICompatResponsesCompletionMock,\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n  getProviderKey: vi.fn((providerId: string) => providerId.split(':')[0] || providerId),\n}))\n\nvi.mock('@/lib/providers/bailian', () => ({\n  completeBailianLlm: vi.fn(async () => {\n    throw new Error('bailian should not be called')\n  }),\n}))\n\nvi.mock('@/lib/providers/siliconflow', () => ({\n  completeSiliconFlowLlm: vi.fn(async () => {\n    throw new Error('siliconflow should not be called')\n  }),\n}))\n\nvi.mock('@/lib/llm/runtime-shared', () => ({\n  _ulogError: vi.fn(),\n  _ulogWarn: vi.fn(),\n  completionUsageSummary: vi.fn(() => ({ promptTokens: 1, completionTokens: 1 })),\n  isRetryableError: vi.fn(() => false),\n  llmLogger: {\n    info: vi.fn(),\n    warn: vi.fn(),\n  },\n  logLlmRawInput: logLlmRawInputMock,\n  logLlmRawOutput: logLlmRawOutputMock,\n  recordCompletionUsage: recordCompletionUsageMock,\n  resolveLlmRuntimeModel: resolveLlmRuntimeModelMock,\n}))\n\nimport { chatCompletion } from '@/lib/llm/chat-completion'\n\ndescribe('llm chatCompletion openai-compatible protocol routing', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('uses responses executor when llmProtocol=responses', async () => {\n    const completion = await chatCompletion(\n      'user-1',\n      'openai-compatible:node-1::gpt-4.1-mini',\n      [{ role: 'user', content: 'hello' }],\n      { temperature: 0.2 },\n    )\n\n    expect(runOpenAICompatResponsesCompletionMock).toHaveBeenCalledTimes(1)\n    expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()\n    expect(completion.choices[0]?.message?.content).toBe('responses-ok')\n  })\n\n  it('uses chat-completions executor when llmProtocol=chat-completions', async () => {\n    resolveLlmRuntimeModelMock.mockResolvedValueOnce({\n      provider: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n      modelKey: 'openai-compatible:node-1::gpt-4.1-mini',\n      llmProtocol: 'chat-completions',\n    })\n\n    const completion = await chatCompletion(\n      'user-1',\n      'openai-compatible:node-1::gpt-4.1-mini',\n      [{ role: 'user', content: 'hello' }],\n      { temperature: 0.2 },\n    )\n\n    expect(runOpenAICompatChatCompletionMock).toHaveBeenCalledTimes(1)\n    expect(runOpenAICompatResponsesCompletionMock).not.toHaveBeenCalled()\n    expect(completion.choices[0]?.message?.content).toBe('chat-ok')\n  })\n\n  it('fails fast when llmProtocol is missing for openai-compatible model', async () => {\n    resolveLlmRuntimeModelMock.mockResolvedValueOnce({\n      provider: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n      modelKey: 'openai-compatible:node-1::gpt-4.1-mini',\n      llmProtocol: undefined,\n    })\n\n    await expect(\n      chatCompletion(\n        'user-1',\n        'openai-compatible:node-1::gpt-4.1-mini',\n        [{ role: 'user', content: 'hello' }],\n        { temperature: 0.2, maxRetries: 0 },\n      ),\n    ).rejects.toThrow('MODEL_LLM_PROTOCOL_REQUIRED')\n\n    expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()\n    expect(runOpenAICompatResponsesCompletionMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/unit/llm/chat-stream-official-provider.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst resolveLlmRuntimeModelMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    provider: 'bailian',\n    modelId: 'qwen3.5-plus',\n    modelKey: 'bailian::qwen3.5-plus',\n  })),\n)\n\nconst completeBailianLlmMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'chatcmpl_stream_mock',\n    object: 'chat.completion',\n    created: 1,\n    model: 'qwen3.5-plus',\n    choices: [\n      {\n        index: 0,\n        message: { role: 'assistant', content: 'stream-ok' },\n        finish_reason: 'stop',\n      },\n    ],\n    usage: {\n      prompt_tokens: 2,\n      completion_tokens: 2,\n      total_tokens: 4,\n    },\n  })),\n)\n\nconst completeSiliconFlowLlmMock = vi.hoisted(() =>\n  vi.fn(async () => {\n    throw new Error('siliconflow should not be called')\n  }),\n)\n\nconst runOpenAICompatChatCompletionMock = vi.hoisted(() =>\n  vi.fn(async () => {\n    throw new Error('openai-compat should not be called')\n  }),\n)\n\nconst getProviderConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'bailian',\n    name: 'Alibaba Bailian',\n    apiKey: 'bl-key',\n    baseUrl: undefined,\n    gatewayRoute: 'official' as const,\n  })),\n)\n\nconst logLlmRawInputMock = vi.hoisted(() => vi.fn())\nconst logLlmRawOutputMock = vi.hoisted(() => vi.fn())\nconst recordCompletionUsageMock = vi.hoisted(() => vi.fn())\n\nvi.mock('@/lib/model-gateway', () => ({\n  resolveModelGatewayRoute: vi.fn(() => 'official'),\n  runOpenAICompatChatCompletion: runOpenAICompatChatCompletionMock,\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n  getProviderKey: vi.fn((providerId: string) => providerId),\n}))\n\nvi.mock('@/lib/providers/bailian', () => ({\n  completeBailianLlm: completeBailianLlmMock,\n}))\n\nvi.mock('@/lib/providers/siliconflow', () => ({\n  completeSiliconFlowLlm: completeSiliconFlowLlmMock,\n}))\n\nvi.mock('@/lib/llm/runtime-shared', () => ({\n  completionUsageSummary: vi.fn(() => ({ promptTokens: 2, completionTokens: 2 })),\n  llmLogger: {\n    info: vi.fn(),\n    warn: vi.fn(),\n  },\n  logLlmRawInput: logLlmRawInputMock,\n  logLlmRawOutput: logLlmRawOutputMock,\n  recordCompletionUsage: recordCompletionUsageMock,\n  resolveLlmRuntimeModel: resolveLlmRuntimeModelMock,\n}))\n\nimport { chatCompletionStream } from '@/lib/llm/chat-stream'\n\ndescribe('llm chatCompletionStream official provider branch', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('streams from bailian completion result and exits early', async () => {\n    const onChunk = vi.fn()\n    const onComplete = vi.fn()\n\n    const completion = await chatCompletionStream(\n      'user-1',\n      'bailian::qwen3.5-plus',\n      [{ role: 'user', content: 'hello' }],\n      {},\n      {\n        onChunk,\n        onComplete,\n      },\n    )\n\n    expect(completeBailianLlmMock).toHaveBeenCalledWith({\n      modelId: 'qwen3.5-plus',\n      messages: [{ role: 'user', content: 'hello' }],\n      apiKey: 'bl-key',\n      baseUrl: undefined,\n      temperature: 0.7,\n    })\n    expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()\n    expect(completeSiliconFlowLlmMock).not.toHaveBeenCalled()\n    expect(onComplete).toHaveBeenCalledWith('stream-ok', undefined)\n    expect(onChunk).toHaveBeenCalledWith(\n      expect.objectContaining({\n        kind: 'text',\n        delta: 'stream-ok',\n      }),\n    )\n    expect(completion.choices[0]?.message?.content).toBe('stream-ok')\n    expect(recordCompletionUsageMock).toHaveBeenCalledTimes(1)\n  })\n})\n"
  },
  {
    "path": "tests/unit/llm/chat-stream-openai-compatible-protocol.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\ntype MockRuntimeModel = {\n  provider: string\n  modelId: string\n  modelKey: string\n  llmProtocol: 'responses' | 'chat-completions' | undefined\n}\n\nconst resolveLlmRuntimeModelMock = vi.hoisted(() =>\n  vi.fn<(...args: unknown[]) => Promise<MockRuntimeModel>>(async () => ({\n    provider: 'openai-compatible:node-1',\n    modelId: 'gpt-4.1-mini',\n    modelKey: 'openai-compatible:node-1::gpt-4.1-mini',\n    llmProtocol: 'responses',\n  })),\n)\n\nconst runOpenAICompatResponsesCompletionMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'chatcmpl_responses_1',\n    object: 'chat.completion',\n    created: 1,\n    model: 'gpt-4.1-mini',\n    choices: [{ index: 0, message: { role: 'assistant', content: 'responses-stream' }, finish_reason: 'stop' }],\n    usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },\n  })),\n)\n\nconst runOpenAICompatChatCompletionMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'chatcmpl_chat_1',\n    object: 'chat.completion',\n    created: 1,\n    model: 'gpt-4.1-mini',\n    choices: [{ index: 0, message: { role: 'assistant', content: 'chat-stream' }, finish_reason: 'stop' }],\n    usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },\n  })),\n)\n\nconst getProviderConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'openai-compatible:node-1',\n    name: 'OpenAI Compatible',\n    apiKey: 'sk-test',\n    baseUrl: 'https://compat.example.com/v1',\n    gatewayRoute: 'openai-compat' as const,\n    apiMode: 'openai-official' as const,\n  })),\n)\n\nconst logLlmRawInputMock = vi.hoisted(() => vi.fn())\nconst logLlmRawOutputMock = vi.hoisted(() => vi.fn())\nconst recordCompletionUsageMock = vi.hoisted(() => vi.fn())\n\nvi.mock('@/lib/model-gateway', () => ({\n  resolveModelGatewayRoute: vi.fn(() => 'openai-compat'),\n  runOpenAICompatChatCompletion: runOpenAICompatChatCompletionMock,\n  runOpenAICompatResponsesCompletion: runOpenAICompatResponsesCompletionMock,\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n  getProviderKey: vi.fn((providerId: string) => providerId.split(':')[0] || providerId),\n}))\n\nvi.mock('@/lib/providers/bailian', () => ({\n  completeBailianLlm: vi.fn(async () => {\n    throw new Error('bailian should not be called')\n  }),\n}))\n\nvi.mock('@/lib/providers/siliconflow', () => ({\n  completeSiliconFlowLlm: vi.fn(async () => {\n    throw new Error('siliconflow should not be called')\n  }),\n}))\n\nvi.mock('@/lib/llm/runtime-shared', () => ({\n  completionUsageSummary: vi.fn(() => ({ promptTokens: 1, completionTokens: 1 })),\n  llmLogger: {\n    info: vi.fn(),\n    warn: vi.fn(),\n  },\n  logLlmRawInput: logLlmRawInputMock,\n  logLlmRawOutput: logLlmRawOutputMock,\n  recordCompletionUsage: recordCompletionUsageMock,\n  resolveLlmRuntimeModel: resolveLlmRuntimeModelMock,\n}))\n\nimport { chatCompletionStream } from '@/lib/llm/chat-stream'\n\ndescribe('llm chatCompletionStream openai-compatible protocol routing', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('uses responses executor when llmProtocol=responses', async () => {\n    const onChunk = vi.fn()\n    const completion = await chatCompletionStream(\n      'user-1',\n      'openai-compatible:node-1::gpt-4.1-mini',\n      [{ role: 'user', content: 'hello' }],\n      { temperature: 0.2 },\n      { onChunk },\n    )\n\n    expect(runOpenAICompatResponsesCompletionMock).toHaveBeenCalledTimes(1)\n    expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()\n    expect(completion.choices[0]?.message?.content).toBe('responses-stream')\n    expect(onChunk).toHaveBeenCalled()\n  })\n\n  it('uses chat-completions executor when llmProtocol=chat-completions', async () => {\n    resolveLlmRuntimeModelMock.mockResolvedValueOnce({\n      provider: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n      modelKey: 'openai-compatible:node-1::gpt-4.1-mini',\n      llmProtocol: 'chat-completions',\n    })\n\n    const completion = await chatCompletionStream(\n      'user-1',\n      'openai-compatible:node-1::gpt-4.1-mini',\n      [{ role: 'user', content: 'hello' }],\n      { temperature: 0.2 },\n      undefined,\n    )\n\n    expect(runOpenAICompatChatCompletionMock).toHaveBeenCalledTimes(1)\n    expect(runOpenAICompatResponsesCompletionMock).not.toHaveBeenCalled()\n    expect(completion.choices[0]?.message?.content).toBe('chat-stream')\n  })\n\n  it('fails fast when llmProtocol is missing for openai-compatible model', async () => {\n    resolveLlmRuntimeModelMock.mockResolvedValueOnce({\n      provider: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n      modelKey: 'openai-compatible:node-1::gpt-4.1-mini',\n      llmProtocol: undefined,\n    })\n\n    await expect(\n      chatCompletionStream(\n        'user-1',\n        'openai-compatible:node-1::gpt-4.1-mini',\n        [{ role: 'user', content: 'hello' }],\n        { temperature: 0.2 },\n        undefined,\n      ),\n    ).rejects.toThrow('MODEL_LLM_PROTOCOL_REQUIRED')\n\n    expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()\n    expect(runOpenAICompatResponsesCompletionMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/unit/llm/completion-parts-think-tag.test.ts",
    "content": "import type OpenAI from 'openai'\nimport { describe, expect, it } from 'vitest'\nimport { getCompletionParts } from '@/lib/llm/completion-parts'\n\nfunction buildCompletion(content: string): OpenAI.Chat.Completions.ChatCompletion {\n  return {\n    id: 'chatcmpl_test',\n    object: 'chat.completion',\n    created: 1,\n    model: 'minimax-m2.5',\n    choices: [\n      {\n        index: 0,\n        message: {\n          role: 'assistant',\n          content,\n        },\n        finish_reason: 'stop',\n      },\n    ],\n  } as OpenAI.Chat.Completions.ChatCompletion\n}\n\ndescribe('llm completion parts think-tag parsing', () => {\n  it('splits think tag content into reasoning and clean text', () => {\n    const completion = buildCompletion(`<think>\n让我分析这段文本，筛选出需要制作画面的场景。\n</think>\n\n{\n  \"locations\": []\n}`)\n\n    const parts = getCompletionParts(completion)\n\n    expect(parts.reasoning).toContain('让我分析这段文本')\n    expect(parts.text).toBe(`{\n  \"locations\": []\n}`)\n  })\n\n  it('keeps plain content untouched when no think tag exists', () => {\n    const completion = buildCompletion('{ \"locations\": [] }')\n\n    const parts = getCompletionParts(completion)\n\n    expect(parts.reasoning).toBe('')\n    expect(parts.text).toBe('{ \"locations\": [] }')\n  })\n})\n"
  },
  {
    "path": "tests/unit/llm/reasoning-capability.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  isLikelyOpenAIReasoningModel,\n  shouldUseOpenAIReasoningProviderOptions,\n} from '@/lib/llm/reasoning-capability'\n\ndescribe('llm/reasoning-capability', () => {\n  it('identifies likely OpenAI reasoning model ids', () => {\n    expect(isLikelyOpenAIReasoningModel('o3-mini')).toBe(true)\n    expect(isLikelyOpenAIReasoningModel('gpt-5.2')).toBe(true)\n    expect(isLikelyOpenAIReasoningModel('claude-sonnet-4-6')).toBe(false)\n  })\n\n  it('enables reasoning provider options for native openai provider', () => {\n    expect(shouldUseOpenAIReasoningProviderOptions({\n      providerKey: 'openai',\n      modelId: 'gpt-5.2',\n    })).toBe(true)\n  })\n\n  it('enables reasoning provider options for openai-compatible only when apiMode is openai-official', () => {\n    expect(shouldUseOpenAIReasoningProviderOptions({\n      providerKey: 'openai-compatible',\n      providerApiMode: 'openai-official',\n      modelId: 'gpt-5.2',\n    })).toBe(true)\n\n    expect(shouldUseOpenAIReasoningProviderOptions({\n      providerKey: 'openai-compatible',\n      modelId: 'gpt-5.2',\n    })).toBe(false)\n  })\n\n  it('disables reasoning provider options for non-openai models even on openai-compatible gateways', () => {\n    expect(shouldUseOpenAIReasoningProviderOptions({\n      providerKey: 'openai-compatible',\n      providerApiMode: 'openai-official',\n      modelId: 'claude-sonnet-4-6',\n    })).toBe(false)\n  })\n})\n"
  },
  {
    "path": "tests/unit/model-capabilities/bailian-video-capabilities.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { findBuiltinCapabilities } from '@/lib/model-capabilities/catalog'\n\ndescribe('bailian video capabilities catalog', () => {\n  it('registers bailian i2v models as normal-mode only', () => {\n    const models = [\n      'wan2.6-i2v-flash',\n      'wan2.6-i2v',\n      'wan2.5-i2v-preview',\n      'wan2.2-i2v-plus',\n    ]\n\n    for (const modelId of models) {\n      const capabilities = findBuiltinCapabilities('video', 'bailian', modelId)\n      expect(capabilities?.video?.generationModeOptions).toEqual(['normal'])\n      expect(capabilities?.video?.firstlastframe).toBe(false)\n    }\n  })\n\n  it('registers bailian kf2v models as firstlastframe-only', () => {\n    const models = [\n      'wan2.2-kf2v-flash',\n      'wanx2.1-kf2v-plus',\n    ]\n\n    for (const modelId of models) {\n      const capabilities = findBuiltinCapabilities('video', 'bailian', modelId)\n      expect(capabilities?.video?.generationModeOptions).toEqual(['firstlastframe'])\n      expect(capabilities?.video?.firstlastframe).toBe(true)\n    }\n  })\n})\n"
  },
  {
    "path": "tests/unit/model-capabilities/image-resolution-default.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  type CapabilitySelections,\n  type ModelCapabilities,\n  type UnifiedModelType,\n} from '@/lib/model-config-contract'\nimport { resolveGenerationOptionsForModel } from '@/lib/model-capabilities/lookup'\n\ndescribe('model-capabilities/lookup - image resolution defaulting', () => {\n  const modelType: UnifiedModelType = 'image'\n  const modelKey = 'google::test-image-model'\n\n  const capabilities: ModelCapabilities = {\n    image: {\n      resolutionOptions: ['0.5K', '1K', '2K'],\n    },\n  }\n\n  it('auto-fills resolution with first option when missing and required', () => {\n    const capabilityDefaults: CapabilitySelections = {}\n\n    const result = resolveGenerationOptionsForModel({\n      modelType,\n      modelKey,\n      capabilities,\n      capabilityDefaults,\n      requireAllFields: true,\n    })\n\n    expect(result.issues).toEqual([])\n    expect(result.options).toEqual({\n      resolution: '0.5K',\n    })\n  })\n\n  it('does not override user-provided resolution', () => {\n    const capabilityDefaults: CapabilitySelections = {\n      [modelKey]: {\n        resolution: '2K',\n      },\n    }\n\n    const result = resolveGenerationOptionsForModel({\n      modelType,\n      modelKey,\n      capabilities,\n      capabilityDefaults,\n      requireAllFields: true,\n    })\n\n    expect(result.issues).toEqual([])\n    expect(result.options).toEqual({\n      resolution: '2K',\n    })\n  })\n})\n\n"
  },
  {
    "path": "tests/unit/model-capabilities/video-effective.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  normalizeVideoGenerationSelections,\n  resolveEffectiveVideoCapabilityDefinitions,\n  resolveEffectiveVideoCapabilityFields,\n} from '@/lib/model-capabilities/video-effective'\nimport type { VideoPricingTier } from '@/lib/model-pricing/video-tier'\n\nconst GOOGLE_VEO_TIERS: VideoPricingTier[] = [\n  { when: { resolution: '720p', duration: 4 } },\n  { when: { resolution: '720p', duration: 6 } },\n  { when: { resolution: '720p', duration: 8 } },\n  { when: { resolution: '1080p', duration: 8 } },\n  { when: { resolution: '4k', duration: 8 } },\n]\n\ndescribe('model-capabilities/video-effective', () => {\n  it('derives capability definitions from pricing tiers', () => {\n    const definitions = resolveEffectiveVideoCapabilityDefinitions({\n      pricingTiers: GOOGLE_VEO_TIERS,\n    })\n    const byField = new Map(definitions.map((item) => [item.field, item.options]))\n\n    expect(byField.get('resolution')).toEqual(['720p', '1080p', '4k'])\n    expect(byField.get('duration')).toEqual([4, 6, 8])\n  })\n\n  it('keeps pinned field and adjusts the linked field to nearest supported combo', () => {\n    const definitions = resolveEffectiveVideoCapabilityDefinitions({\n      pricingTiers: GOOGLE_VEO_TIERS,\n    })\n\n    const normalized = normalizeVideoGenerationSelections({\n      definitions,\n      pricingTiers: GOOGLE_VEO_TIERS,\n      selection: {\n        resolution: '1080p',\n        duration: 4,\n      },\n      pinnedFields: ['resolution'],\n    })\n\n    expect(normalized).toEqual({\n      resolution: '1080p',\n      duration: 8,\n    })\n  })\n\n  it('filters dependent options by current selection', () => {\n    const definitions = resolveEffectiveVideoCapabilityDefinitions({\n      pricingTiers: GOOGLE_VEO_TIERS,\n    })\n    const fields = resolveEffectiveVideoCapabilityFields({\n      definitions,\n      pricingTiers: GOOGLE_VEO_TIERS,\n      selection: {\n        resolution: '1080p',\n      },\n    })\n    const durationField = fields.find((field) => field.field === 'duration')\n\n    expect(durationField?.options).toEqual([8])\n    expect(durationField?.value).toBe(8)\n  })\n})\n\n"
  },
  {
    "path": "tests/unit/model-gateway/llm.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst chatCompletionMock = vi.hoisted(() => vi.fn(async () => ({ id: 'text-completion' })))\nconst chatCompletionWithVisionMock = vi.hoisted(() => vi.fn(async () => ({ id: 'vision-completion' })))\n\nvi.mock('@/lib/llm-client', () => ({\n  chatCompletion: chatCompletionMock,\n  chatCompletionWithVision: chatCompletionWithVisionMock,\n}))\n\nimport {\n  runModelGatewayTextCompletion,\n  runModelGatewayVisionCompletion,\n} from '@/lib/model-gateway/llm'\n\ndescribe('model-gateway llm wrappers', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('delegates text completion to llm-client chatCompletion', async () => {\n    const result = await runModelGatewayTextCompletion({\n      userId: 'user-1',\n      model: 'openai-compatible::gpt-image-1',\n      messages: [{ role: 'user', content: 'hello' }],\n      options: { temperature: 0.2 },\n    })\n\n    expect(chatCompletionMock).toHaveBeenCalledTimes(1)\n    expect(chatCompletionMock).toHaveBeenCalledWith(\n      'user-1',\n      'openai-compatible::gpt-image-1',\n      [{ role: 'user', content: 'hello' }],\n      { temperature: 0.2 },\n    )\n    expect(result).toEqual({ id: 'text-completion' })\n  })\n\n  it('delegates vision completion to llm-client chatCompletionWithVision', async () => {\n    const result = await runModelGatewayVisionCompletion({\n      userId: 'user-1',\n      model: 'google::gemini-3-pro',\n      prompt: 'analyze image',\n      imageUrls: ['https://example.com/a.png'],\n      options: { temperature: 0.4 },\n    })\n\n    expect(chatCompletionWithVisionMock).toHaveBeenCalledTimes(1)\n    expect(chatCompletionWithVisionMock).toHaveBeenCalledWith(\n      'user-1',\n      'google::gemini-3-pro',\n      'analyze image',\n      ['https://example.com/a.png'],\n      { temperature: 0.4 },\n    )\n    expect(result).toEqual({ id: 'vision-completion' })\n  })\n})\n"
  },
  {
    "path": "tests/unit/model-gateway/openai-compat-responses.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst resolveOpenAICompatClientConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    providerId: 'openai-compatible:node-1',\n    baseUrl: 'https://compat.example.com/v1',\n    apiKey: 'sk-test',\n  })),\n)\n\nvi.mock('@/lib/model-gateway/openai-compat/common', () => ({\n  resolveOpenAICompatClientConfig: resolveOpenAICompatClientConfigMock,\n}))\n\nimport { runOpenAICompatResponsesCompletion } from '@/lib/model-gateway/openai-compat/responses'\n\ndescribe('model-gateway openai-compat responses executor', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('converts responses payload to normalized chat completion', async () => {\n    const fetchMock = vi.fn(async () => new Response(JSON.stringify({\n      output: [\n        { type: 'reasoning', text: 'think-' },\n        { type: 'output_text', text: 'hello' },\n      ],\n      usage: {\n        input_tokens: 12,\n        output_tokens: 7,\n      },\n    }), { status: 200 }))\n    vi.stubGlobal('fetch', fetchMock)\n\n    const completion = await runOpenAICompatResponsesCompletion({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n      messages: [{ role: 'user', content: 'hello' }],\n      temperature: 0.2,\n    })\n\n    expect(completion.choices[0]?.message?.content).toEqual([\n      { type: 'reasoning', text: 'think-' },\n      { type: 'text', text: 'hello' },\n    ])\n    expect(completion.usage?.prompt_tokens).toBe(12)\n    expect(completion.usage?.completion_tokens).toBe(7)\n    const firstCall = fetchMock.mock.calls[0] as unknown[] | undefined\n    expect(String(firstCall?.[0])).toBe('https://compat.example.com/v1/responses')\n  })\n\n  it('throws status-bearing error when responses endpoint fails', async () => {\n    const fetchMock = vi.fn(async () => new Response('not supported', { status: 404 }))\n    vi.stubGlobal('fetch', fetchMock)\n\n    await expect(\n      runOpenAICompatResponsesCompletion({\n        userId: 'user-1',\n        providerId: 'openai-compatible:node-1',\n        modelId: 'gpt-4.1-mini',\n        messages: [{ role: 'user', content: 'hello' }],\n        temperature: 0.2,\n      }),\n    ).rejects.toThrow('OPENAI_COMPAT_RESPONSES_FAILED: 404')\n  })\n})\n"
  },
  {
    "path": "tests/unit/model-gateway/openai-compat-template-image-output-urls.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst resolveConfigMock = vi.hoisted(() => vi.fn(async () => ({\n  providerId: 'openai-compatible:test-provider',\n  baseUrl: 'https://compat.example.com/v1',\n  apiKey: 'sk-test',\n})))\n\nvi.mock('@/lib/model-gateway/openai-compat/common', () => ({\n  resolveOpenAICompatClientConfig: resolveConfigMock,\n}))\n\nimport { generateImageViaOpenAICompatTemplate } from '@/lib/model-gateway/openai-compat/template-image'\n\ndescribe('openai-compat template image output urls', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('returns all image urls when outputUrlsPath contains multiple values', async () => {\n    globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({\n      data: [\n        { url: 'https://cdn.test/1.png' },\n        { url: 'https://cdn.test/2.png' },\n      ],\n    }), { status: 200 })) as unknown as typeof fetch\n\n    const result = await generateImageViaOpenAICompatTemplate({\n      userId: 'user-1',\n      providerId: 'openai-compatible:test-provider',\n      modelId: 'gpt-image-1',\n      modelKey: 'openai-compatible:test-provider::gpt-image-1',\n      prompt: 'draw a cat',\n      profile: 'openai-compatible',\n      template: {\n        version: 1,\n        mediaType: 'image',\n        mode: 'sync',\n        create: {\n          method: 'POST',\n          path: '/images/generations',\n          contentType: 'application/json',\n          bodyTemplate: {\n            model: '{{model}}',\n            prompt: '{{prompt}}',\n          },\n        },\n        response: {\n          outputUrlPath: '$.data[0].url',\n          outputUrlsPath: '$.data',\n        },\n      },\n    })\n\n    expect(result).toEqual({\n      success: true,\n      imageUrl: 'https://cdn.test/1.png',\n      imageUrls: ['https://cdn.test/1.png', 'https://cdn.test/2.png'],\n    })\n  })\n\n  it('keeps single-url output compatible when outputUrlsPath has only one image', async () => {\n    globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({\n      data: [{ url: 'https://cdn.test/only.png' }],\n    }), { status: 200 })) as unknown as typeof fetch\n\n    const result = await generateImageViaOpenAICompatTemplate({\n      userId: 'user-1',\n      providerId: 'openai-compatible:test-provider',\n      modelId: 'gpt-image-1',\n      modelKey: 'openai-compatible:test-provider::gpt-image-1',\n      prompt: 'draw a cat',\n      profile: 'openai-compatible',\n      template: {\n        version: 1,\n        mediaType: 'image',\n        mode: 'sync',\n        create: {\n          method: 'POST',\n          path: '/images/generations',\n          contentType: 'application/json',\n          bodyTemplate: {\n            model: '{{model}}',\n            prompt: '{{prompt}}',\n          },\n        },\n        response: {\n          outputUrlsPath: '$.data',\n        },\n      },\n    })\n\n    expect(result).toEqual({\n      success: true,\n      imageUrl: 'https://cdn.test/only.png',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/model-gateway/openai-compat-template-renderer.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  buildRenderedTemplateRequest,\n  buildTemplateVariables,\n  extractTemplateError,\n  readJsonPath,\n  renderTemplateString,\n  renderTemplateValue,\n  resolveTemplateEndpointUrl,\n} from '@/lib/openai-compat-template-runtime'\n\ndescribe('model-gateway openai-compat template renderer', () => {\n  it('renders placeholders in strings and nested body values', () => {\n    const variables = buildTemplateVariables({\n      model: 'veo3.1',\n      prompt: 'a cat running',\n      image: 'https://a.test/cat.png',\n      taskId: 'task_1',\n    })\n\n    expect(renderTemplateString('/videos/{{task_id}}', variables)).toBe('/videos/task_1')\n    expect(renderTemplateValue({\n      model: '{{model}}',\n      prompt: '{{prompt}}',\n      images: '{{images}}',\n      nested: [{ value: '{{task_id}}' }],\n    }, variables)).toEqual({\n      model: 'veo3.1',\n      prompt: 'a cat running',\n      images: [],\n      nested: [{ value: 'task_1' }],\n    })\n  })\n\n  it('resolves relative path against base url and injects auth header', async () => {\n    const request = await buildRenderedTemplateRequest({\n      baseUrl: 'https://compat.example.com/v1/',\n      endpoint: {\n        method: 'POST',\n        path: '/v2/videos/generations',\n        contentType: 'application/json',\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n        },\n      },\n      variables: buildTemplateVariables({\n        model: 'veo3.1',\n        prompt: 'hello',\n      }),\n      defaultAuthHeader: 'Bearer sk-test',\n    })\n\n    expect(resolveTemplateEndpointUrl('https://compat.example.com/v1/', '/v2/videos/generations'))\n      .toBe('https://compat.example.com/v1/v2/videos/generations')\n    expect(request.endpointUrl).toBe('https://compat.example.com/v1/v2/videos/generations')\n    expect(request.headers.Authorization).toBe('Bearer sk-test')\n    expect(request.headers['Content-Type']).toBe('application/json')\n    expect(request.body).toBe(JSON.stringify({\n      model: 'veo3.1',\n      prompt: 'hello',\n    }))\n  })\n\n  it('deduplicates /v1 prefix when base url already ends with /v1', async () => {\n    const request = await buildRenderedTemplateRequest({\n      baseUrl: 'https://yunwu.ai/v1',\n      endpoint: {\n        method: 'GET',\n        path: '/v1/video/query?id={{task_id}}',\n      },\n      variables: buildTemplateVariables({\n        model: 'veo_3_1-fast-4K',\n        prompt: '',\n        taskId: 'task_abc',\n      }),\n      defaultAuthHeader: 'Bearer sk-test',\n    })\n\n    expect(resolveTemplateEndpointUrl('https://yunwu.ai/v1', '/v1/video/create'))\n      .toBe('https://yunwu.ai/v1/video/create')\n    expect(request.endpointUrl).toBe('https://yunwu.ai/v1/video/query?id=task_abc')\n    expect(request.headers.Authorization).toBe('Bearer sk-test')\n  })\n\n  it('builds multipart form data and omits explicit content-type header', async () => {\n    const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO0p6s8AAAAASUVORK5CYII='\n    const request = await buildRenderedTemplateRequest({\n      baseUrl: 'https://compat.example.com/v1',\n      endpoint: {\n        method: 'POST',\n        path: '/videos',\n        contentType: 'multipart/form-data',\n        multipartFileFields: ['input_reference'],\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n          input_reference: '{{image}}',\n        },\n      },\n      variables: buildTemplateVariables({\n        model: 'veo3.1',\n        prompt: 'hello',\n        image: dataUrl,\n      }),\n      defaultAuthHeader: 'Bearer sk-test',\n    })\n\n    expect(request.endpointUrl).toBe('https://compat.example.com/v1/videos')\n    expect(request.headers.Authorization).toBe('Bearer sk-test')\n    expect(request.headers['Content-Type']).toBeUndefined()\n    expect(request.body).toBeInstanceOf(FormData)\n\n    const formData = request.body as FormData\n    expect(formData.get('model')).toBe('veo3.1')\n    expect(formData.get('prompt')).toBe('hello')\n    const fileValue = formData.get('input_reference')\n    expect(fileValue).toBeInstanceOf(File)\n    expect((fileValue as File).name).toBe('reference-0.png')\n  })\n\n  it('builds application/x-www-form-urlencoded bodies', async () => {\n    const request = await buildRenderedTemplateRequest({\n      baseUrl: 'https://compat.example.com/v1',\n      endpoint: {\n        method: 'POST',\n        path: '/videos/query',\n        contentType: 'application/x-www-form-urlencoded',\n        bodyTemplate: {\n          model: '{{model}}',\n          task_id: '{{task_id}}',\n        },\n      },\n      variables: buildTemplateVariables({\n        model: 'veo3.1',\n        prompt: 'hello',\n        taskId: 'task_1',\n      }),\n    })\n\n    expect(request.headers['Content-Type']).toBe('application/x-www-form-urlencoded')\n    expect(request.body).toBeInstanceOf(URLSearchParams)\n    expect((request.body as URLSearchParams).toString()).toBe('model=veo3.1&task_id=task_1')\n  })\n\n  it('reads json path for array/object outputs', () => {\n    const payload = {\n      data: [{ url: 'https://cdn.test/1.png' }],\n      task: {\n        status: 'succeeded',\n      },\n    }\n    expect(readJsonPath(payload, '$.data[0].url')).toBe('https://cdn.test/1.png')\n    expect(readJsonPath(payload, '$.task.status')).toBe('succeeded')\n  })\n\n  it('extracts upstream error message from common payload shape', () => {\n    const message = extractTemplateError({\n      version: 1,\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        method: 'POST',\n        path: '/video/create',\n      },\n      status: {\n        method: 'GET',\n        path: '/video/query?id={{task_id}}',\n      },\n      response: {\n        taskIdPath: '$.id',\n        statusPath: '$.status',\n      },\n      polling: {\n        intervalMs: 5000,\n        timeoutMs: 600000,\n        doneStates: ['completed'],\n        failStates: ['failed'],\n      },\n    }, {\n      error: {\n        message_zh: '当前分组上游负载已饱和，请稍后再试',\n      },\n    }, 500)\n\n    expect(message).toContain('status 500')\n    expect(message).toContain('当前分组上游负载已饱和，请稍后再试')\n  })\n})\n"
  },
  {
    "path": "tests/unit/model-gateway/openai-compat-template-video-external-id.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst resolveConfigMock = vi.hoisted(() => vi.fn(async () => ({\n  providerId: 'openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0',\n  baseUrl: 'https://compat.example.com/v1',\n  apiKey: 'sk-test',\n})))\n\nvi.mock('@/lib/model-gateway/openai-compat/common', () => ({\n  resolveOpenAICompatClientConfig: resolveConfigMock,\n}))\n\nimport { generateVideoViaOpenAICompatTemplate } from '@/lib/model-gateway/openai-compat/template-video'\n\ndescribe('openai-compat template video externalId', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('encodes compact modelId token for OCOMPAT externalId', async () => {\n    globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({\n      id: 'veo3.1-fast:1772734762-6TuDIS8Vvr',\n      status: 'pending',\n    }), { status: 200 })) as unknown as typeof fetch\n\n    const result = await generateVideoViaOpenAICompatTemplate({\n      userId: 'user-1',\n      providerId: 'openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0',\n      modelId: 'veo3.1-fast',\n      modelKey: 'openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::veo3.1-fast',\n      imageUrl: 'https://example.com/seed.png',\n      prompt: 'animate this image',\n      profile: 'openai-compatible',\n      template: {\n        version: 1,\n        mediaType: 'video',\n        mode: 'async',\n        create: {\n          method: 'POST',\n          path: '/video/create',\n          bodyTemplate: {\n            model: '{{model}}',\n            prompt: '{{prompt}}',\n          },\n        },\n        status: {\n          method: 'GET',\n          path: '/video/query?id={{task_id}}',\n        },\n        response: {\n          taskIdPath: '$.id',\n          statusPath: '$.status',\n        },\n        polling: {\n          intervalMs: 5000,\n          timeoutMs: 600000,\n          doneStates: ['completed'],\n          failStates: ['failed'],\n        },\n      },\n    })\n\n    expect(result.success).toBe(true)\n    expect(result.async).toBe(true)\n    expect(result.externalId).toContain(':u_33331fb0-2806-4da6-85ff-cd2433b587d0:')\n    expect(result.externalId).toContain(`:${Buffer.from('veo3.1-fast', 'utf8').toString('base64url')}:`)\n    expect(result.externalId).not.toContain(Buffer.from('openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::veo3.1-fast', 'utf8').toString('base64url'))\n    expect(result.externalId!.length).toBeLessThanOrEqual(128)\n  })\n})\n"
  },
  {
    "path": "tests/unit/model-gateway/router.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { isCompatibleProvider, resolveModelGatewayRoute } from '@/lib/model-gateway'\n\ndescribe('model-gateway router', () => {\n  it('routes openai-compatible providers to openai-compat', () => {\n    expect(isCompatibleProvider('openai-compatible')).toBe(true)\n    expect(isCompatibleProvider('openai-compatible:oa-1')).toBe(true)\n    expect(resolveModelGatewayRoute('openai-compatible:oa-1')).toBe('openai-compat')\n  })\n\n  it('keeps gemini-compatible providers on official route', () => {\n    expect(isCompatibleProvider('gemini-compatible')).toBe(false)\n    expect(isCompatibleProvider('gemini-compatible:gm-1')).toBe(false)\n    expect(resolveModelGatewayRoute('gemini-compatible:gm-1')).toBe('official')\n  })\n\n  it('keeps official providers on official route', () => {\n    expect(isCompatibleProvider('google')).toBe(false)\n    expect(isCompatibleProvider('ark')).toBe(false)\n    expect(isCompatibleProvider('bailian')).toBe(false)\n    expect(isCompatibleProvider('siliconflow')).toBe(false)\n    expect(resolveModelGatewayRoute('google')).toBe('official')\n    expect(resolveModelGatewayRoute('ark')).toBe('official')\n    expect(resolveModelGatewayRoute('bailian')).toBe('official')\n    expect(resolveModelGatewayRoute('siliconflow')).toBe('official')\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/character-voice-mutations.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst {\n  useQueryClientMock,\n  useMutationMock,\n  requestJsonWithErrorMock,\n} = vi.hoisted(() => ({\n  useQueryClientMock: vi.fn(() => ({ invalidateQueries: vi.fn() })),\n  useMutationMock: vi.fn((options: unknown) => options),\n  requestJsonWithErrorMock: vi.fn(),\n}))\n\nvi.mock('@tanstack/react-query', () => ({\n  useQueryClient: () => useQueryClientMock(),\n  useMutation: (options: unknown) => useMutationMock(options),\n}))\n\nvi.mock('@/lib/query/mutations/mutation-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(\n    '@/lib/query/mutations/mutation-shared',\n  )\n  return {\n    ...actual,\n    invalidateQueryTemplates: vi.fn(),\n    requestJsonWithError: requestJsonWithErrorMock,\n  }\n})\n\nimport { useUpdateProjectCharacterVoiceSettings } from '@/lib/query/mutations/character-voice-mutations'\n\ninterface UpdateVoiceMutation {\n  mutationFn: (variables: {\n    characterId: string\n    voiceType: 'qwen-designed' | 'uploaded' | 'custom' | null\n    voiceId?: string\n    customVoiceUrl?: string\n  }) => Promise<unknown>\n}\n\ndescribe('project character voice mutations', () => {\n  beforeEach(() => {\n    useQueryClientMock.mockClear()\n    useMutationMock.mockClear()\n    requestJsonWithErrorMock.mockReset()\n    requestJsonWithErrorMock.mockResolvedValue({ success: true })\n  })\n\n  it('routes voice setting updates to the character-voice endpoint after designed voice save', async () => {\n    const mutation = useUpdateProjectCharacterVoiceSettings('project-1') as unknown as UpdateVoiceMutation\n\n    await mutation.mutationFn({\n      characterId: 'character-1',\n      voiceType: 'qwen-designed',\n      voiceId: 'voice-1',\n      customVoiceUrl: 'https://example.com/audio.wav',\n    })\n\n    expect(requestJsonWithErrorMock).toHaveBeenCalledTimes(1)\n    expect(requestJsonWithErrorMock).toHaveBeenCalledWith(\n      '/api/novel-promotion/project-1/character-voice',\n      {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          characterId: 'character-1',\n          voiceType: 'qwen-designed',\n          voiceId: 'voice-1',\n          customVoiceUrl: 'https://example.com/audio.wav',\n        }),\n      },\n      '更新音色失败',\n    )\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/immediate-video-submission.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  buildVideoSubmissionKey,\n  createVideoSubmissionBaseline,\n  shouldResolveVideoSubmissionLock,\n} from '@/lib/novel-promotion/stages/video-stage-runtime/immediate-video-submission'\n\ndescribe('immediate video submission lock', () => {\n  it('regenerating an existing video -> keeps local lock until task state or output changes', () => {\n    const panel = {\n      panelId: 'panel-1',\n      storyboardId: 'storyboard-1',\n      panelIndex: 0,\n      videoUrl: 'https://example.com/original.mp4',\n      videoErrorMessage: null,\n      videoTaskRunning: false,\n    }\n    const baseline = createVideoSubmissionBaseline(panel)\n\n    expect(buildVideoSubmissionKey(panel)).toBe('panel-1')\n    expect(\n      shouldResolveVideoSubmissionLock(\n        {\n          ...panel,\n          videoTaskRunning: false,\n        },\n        baseline,\n        baseline.startedAt + 1_000,\n      ),\n    ).toBe(false)\n    expect(\n      shouldResolveVideoSubmissionLock(\n        {\n          ...panel,\n          videoTaskRunning: true,\n        },\n        baseline,\n        baseline.startedAt + 1_000,\n      ),\n    ).toBe(true)\n    expect(\n      shouldResolveVideoSubmissionLock(\n        {\n          ...panel,\n          videoUrl: 'https://example.com/regenerated.mp4',\n        },\n        baseline,\n        baseline.startedAt + 1_000,\n      ),\n    ).toBe(true)\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/insert-panel-user-input.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'\n\ndescribe('insert panel user input normalization', () => {\n  it('uses localized default instruction when AI analyze sends empty input', () => {\n    expect(resolveInsertPanelUserInput({ userInput: '' }, 'zh')).toBe(\n      '请根据前后镜头自动分析并插入一个自然衔接的新分镜。',\n    )\n    expect(resolveInsertPanelUserInput({ userInput: '   ' }, 'en')).toBe(\n      'Automatically analyze the surrounding panels and insert a naturally connected new panel.',\n    )\n  })\n\n  it('prefers explicit user input over fallback prompt or default', () => {\n    expect(resolveInsertPanelUserInput({\n      userInput: '  添加一个特写反应镜头  ',\n      prompt: 'unused prompt',\n    }, 'zh')).toBe('添加一个特写反应镜头')\n  })\n\n  it('falls back to prompt when userInput is missing', () => {\n    expect(resolveInsertPanelUserInput({\n      prompt: '  Insert a pause beat between these panels.  ',\n    }, 'en')).toBe('Insert a pause beat between these panels.')\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/panel-task-status-error-code.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { usePanelTaskStatus } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/hooks/usePanelTaskStatus'\n\ndescribe('panel task status error code mapping', () => {\n  it('uses explicit error code for user-facing panel error display', () => {\n    const result = usePanelTaskStatus({\n      panel: {\n        storyboardId: 'sb-1',\n        panelIndex: 0,\n        videoErrorCode: 'EXTERNAL_ERROR',\n        videoErrorMessage: 'raw upstream message',\n      },\n      hasVisibleBaseVideo: false,\n      tCommon: (key) => key,\n    })\n\n    expect(result.panelErrorDisplay?.code).toBe('EXTERNAL_ERROR')\n    expect(result.panelErrorDisplay?.message).toBe('raw upstream message')\n  })\n\n  it('shows fixed unsupported-format message for VIDEO_API_FORMAT_UNSUPPORTED', () => {\n    const result = usePanelTaskStatus({\n      panel: {\n        storyboardId: 'sb-1',\n        panelIndex: 0,\n        videoErrorCode: 'VIDEO_API_FORMAT_UNSUPPORTED',\n        videoErrorMessage: 'VIDEO_API_FORMAT_UNSUPPORTED: OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND',\n      },\n      hasVisibleBaseVideo: false,\n      tCommon: (key) => key,\n    })\n\n    expect(result.panelErrorDisplay?.code).toBe('VIDEO_API_FORMAT_UNSUPPORTED')\n    expect(result.panelErrorDisplay?.message).toBe('当前视频接口格式暂不支持。')\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/use-tts-generation.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst {\n  useStateMock,\n  logErrorMock,\n  refreshAssetsMock,\n  updateVoiceSettingsMutateAsyncMock,\n  saveDesignedVoiceMutateAsyncMock,\n  setVoiceDesignCharacterMock,\n} = vi.hoisted(() => ({\n  useStateMock: vi.fn(),\n  logErrorMock: vi.fn(),\n  refreshAssetsMock: vi.fn(),\n  updateVoiceSettingsMutateAsyncMock: vi.fn(),\n  saveDesignedVoiceMutateAsyncMock: vi.fn(),\n  setVoiceDesignCharacterMock: vi.fn(),\n}))\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useState: useStateMock,\n  }\n})\n\nvi.mock('next-intl', () => ({\n  useTranslations: () => (key: string, values?: Record<string, unknown>) => {\n    if (key === 'tts.voiceDesignSaved') {\n      return `voice saved:${String(values?.name ?? '')}`\n    }\n    if (key === 'tts.saveVoiceDesignFailed') {\n      return `save failed:${String(values?.error ?? '')}`\n    }\n    if (key === 'common.unknownError') {\n      return 'unknown error'\n    }\n    return key\n  },\n}))\n\nvi.mock('@/lib/logging/core', () => ({\n  logError: (...args: unknown[]) => logErrorMock(...args),\n}))\n\nvi.mock('@/lib/query/hooks', () => ({\n  useProjectAssets: () => ({\n    data: {\n      characters: [{\n        id: 'character-1',\n        name: 'Hero',\n        customVoiceUrl: null,\n      }],\n    },\n  }),\n  useRefreshProjectAssets: () => refreshAssetsMock,\n  useUpdateProjectCharacterVoiceSettings: () => ({\n    mutateAsync: updateVoiceSettingsMutateAsyncMock,\n  }),\n  useSaveProjectDesignedVoice: () => ({\n    mutateAsync: saveDesignedVoiceMutateAsyncMock,\n  }),\n}))\n\nimport { useTTSGeneration } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useTTSGeneration'\n\ndescribe('useTTSGeneration', () => {\n  const originalAlert = globalThis.alert\n\n  beforeEach(() => {\n    useStateMock.mockReset()\n    logErrorMock.mockReset()\n    refreshAssetsMock.mockReset()\n    updateVoiceSettingsMutateAsyncMock.mockReset()\n    saveDesignedVoiceMutateAsyncMock.mockReset()\n    setVoiceDesignCharacterMock.mockReset()\n    saveDesignedVoiceMutateAsyncMock.mockResolvedValue({\n      success: true,\n      audioUrl: 'https://signed.example.com/audio.wav',\n    })\n    globalThis.alert = vi.fn()\n    useStateMock.mockReturnValue([\n      {\n        id: 'character-1',\n        name: 'Hero',\n        hasExistingVoice: false,\n      },\n      setVoiceDesignCharacterMock,\n    ])\n  })\n\n  afterEach(() => {\n    globalThis.alert = originalAlert\n  })\n\n  it('does not send a second voice update request after designed voice save succeeds', async () => {\n    const hook = useTTSGeneration({ projectId: 'project-1' })\n\n    await hook.handleVoiceDesignSave('voice-1', 'base64-audio')\n\n    expect(saveDesignedVoiceMutateAsyncMock).toHaveBeenCalledTimes(1)\n    expect(saveDesignedVoiceMutateAsyncMock).toHaveBeenCalledWith({\n      characterId: 'character-1',\n      voiceId: 'voice-1',\n      audioBase64: 'base64-audio',\n    })\n    expect(updateVoiceSettingsMutateAsyncMock).not.toHaveBeenCalled()\n    expect(refreshAssetsMock).toHaveBeenCalledTimes(1)\n    expect(globalThis.alert).toHaveBeenCalledWith('voice saved:Hero')\n    expect(setVoiceDesignCharacterMock).toHaveBeenCalledWith(null)\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/video-model-options.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  filterNormalVideoModelOptions,\n  isFirstLastFrameOnlyModel,\n  supportsFirstLastFrame,\n} from '@/lib/model-capabilities/video-model-options'\nimport type { VideoModelOption } from '@/lib/novel-promotion/stages/video-stage-runtime/types'\n\ndescribe('video model options partition', () => {\n  const models: VideoModelOption[] = [\n    {\n      value: 'p::normal',\n      label: 'normal',\n      capabilities: {\n        video: {\n          generationModeOptions: ['normal'],\n          firstlastframe: false,\n        },\n      },\n    },\n    {\n      value: 'p::firstlast-only',\n      label: 'firstlast-only',\n      capabilities: {\n        video: {\n          generationModeOptions: ['firstlastframe'],\n          firstlastframe: true,\n        },\n      },\n    },\n    {\n      value: 'p::both',\n      label: 'both',\n      capabilities: {\n        video: {\n          generationModeOptions: ['normal', 'firstlastframe'],\n          firstlastframe: true,\n        },\n      },\n    },\n    {\n      value: 'p::custom-no-capability',\n      label: 'custom-no-capability',\n    },\n  ]\n\n  it('detects firstlastframe support and firstlastframe-only capability', () => {\n    expect(supportsFirstLastFrame(models[0])).toBe(false)\n    expect(supportsFirstLastFrame(models[1])).toBe(true)\n    expect(supportsFirstLastFrame(models[2])).toBe(true)\n    expect(supportsFirstLastFrame(models[3])).toBe(false)\n\n    expect(isFirstLastFrameOnlyModel(models[0])).toBe(false)\n    expect(isFirstLastFrameOnlyModel(models[1])).toBe(true)\n    expect(isFirstLastFrameOnlyModel(models[2])).toBe(false)\n    expect(isFirstLastFrameOnlyModel(models[3])).toBe(false)\n  })\n\n  it('filters out firstlastframe-only models from normal video model list', () => {\n    const normalModels = filterNormalVideoModelOptions(models)\n    expect(normalModels.map((item) => item.value)).toEqual([\n      'p::normal',\n      'p::both',\n      'p::custom-no-capability',\n    ])\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/video-panel-card-body.test.ts",
    "content": "import React from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport { describe, expect, it, vi } from 'vitest'\nimport VideoPanelCardBody from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/VideoPanelCardBody'\nimport type { VideoPanelRuntime } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/hooks/useVideoPanelActions'\n\nvi.mock('@/components/task/TaskStatusInline', () => ({\n  default: () => React.createElement('span', null, 'task-status'),\n}))\n\nvi.mock('@/components/ui/config-modals/ModelCapabilityDropdown', () => ({\n  ModelCapabilityDropdown: () => React.createElement('div', null, 'model-dropdown'),\n}))\n\nvi.mock('@/components/ui/icons', () => ({\n  AppIcon: ({ name }: { name: string }) => React.createElement('span', null, name),\n}))\n\nfunction createRuntime(overrides: Partial<VideoPanelRuntime> = {}): VideoPanelRuntime {\n  const translate = (key: string, values?: Record<string, unknown>) => {\n    if (key === 'firstLastFrame.asLastFrameFor') {\n      return `作为镜头 ${String(values?.number ?? '')} 的尾帧`\n    }\n    if (key === 'firstLastFrame.asFirstFrameFor') {\n      return `作为镜头 ${String(values?.number ?? '')} 的首帧`\n    }\n    if (key === 'firstLastFrame.generate') return '生成首尾帧视频'\n    if (key === 'firstLastFrame.generated') return '首尾帧视频已生成'\n    if (key === 'promptModal.promptLabel') return '视频提示词'\n    if (key === 'promptModal.placeholder') return '输入首尾帧视频提示词...'\n    if (key === 'panelCard.clickToEditPrompt') return '点击编辑提示词...'\n    if (key === 'panelCard.selectModel') return '选择模型'\n    if (key === 'panelCard.generateVideo') return '生成视频'\n    if (key === 'panelCard.unknownShotType') return '未知镜头'\n    if (key === 'stage.hasSynced') return '已生成'\n    if (key === 'promptModal.duration') return '秒'\n    return key\n  }\n\n  const runtime = {\n    t: translate,\n    tCommon: (key: string) => key,\n    panel: {\n      storyboardId: 'sb-1',\n      panelIndex: 2,\n      panelId: 'panel-2',\n      imageUrl: 'https://example.com/frame-2.jpg',\n      videoUrl: null,\n      videoGenerationMode: null,\n      lipSyncVideoUrl: null,\n      textPanel: {\n        shot_type: '平视中景',\n        description: '谢俞站在宴席中央',\n        duration: 3,\n      },\n    },\n    panelIndex: 2,\n    panelKey: 'sb-1-2',\n    media: {\n      showLipSyncVideo: true,\n      onToggleLipSyncVideo: () => undefined,\n      onPreviewImage: () => undefined,\n      baseVideoUrl: undefined,\n      currentVideoUrl: undefined,\n    },\n    taskStatus: {\n      isVideoTaskRunning: false,\n      isLipSyncTaskRunning: false,\n      taskRunningVideoLabel: '生成中',\n      lipSyncInlineState: null,\n    },\n    videoModel: {\n      selectedModel: 'veo-3.1',\n      setSelectedModel: () => undefined,\n      capabilityFields: [],\n      generationOptions: {},\n      setCapabilityValue: () => undefined,\n      missingCapabilityFields: [],\n      videoModelOptions: [],\n    },\n    player: {\n      isPlaying: false,\n    },\n    promptEditor: {\n      isEditing: false,\n      editingPrompt: '',\n      setEditingPrompt: () => undefined,\n      handleStartEdit: () => undefined,\n      handleSave: () => undefined,\n      handleCancelEdit: () => undefined,\n      isSavingPrompt: false,\n      localPrompt: '人物从席间回身，接到下一镜头',\n    },\n    voiceManager: {\n      hasMatchedAudio: false,\n      hasMatchedVoiceLines: false,\n      audioGenerateError: null,\n      localVoiceLines: [],\n      isVoiceLineTaskRunning: () => false,\n      handlePlayVoiceLine: () => undefined,\n      handleGenerateAudio: async () => undefined,\n      playingVoiceLineId: null,\n    },\n    lipSync: {\n      handleStartLipSync: () => undefined,\n      executingLipSync: false,\n    },\n    layout: {\n      isLinked: true,\n      isLastFrame: true,\n      nextPanel: {\n        storyboardId: 'sb-1',\n        panelIndex: 3,\n        imageUrl: 'https://example.com/frame-3.jpg',\n      },\n      prevPanel: {\n        storyboardId: 'sb-1',\n        panelIndex: 1,\n        imageUrl: 'https://example.com/frame-1.jpg',\n      },\n      hasNext: true,\n      flModel: 'veo-3.1',\n      flModelOptions: [],\n      flGenerationOptions: {},\n      flCapabilityFields: [],\n      flMissingCapabilityFields: [],\n      flCustomPrompt: '',\n      defaultFlPrompt: '',\n      videoRatio: '9:16',\n    },\n    actions: {\n      onGenerateVideo: () => undefined,\n      onUpdatePanelVideoModel: () => undefined,\n      onToggleLink: () => undefined,\n      onFlModelChange: () => undefined,\n      onFlCapabilityChange: () => undefined,\n      onFlCustomPromptChange: () => undefined,\n      onResetFlPrompt: () => undefined,\n      onGenerateFirstLastFrame: () => undefined,\n    },\n    computed: {\n      showLipSyncSection: false,\n      canLipSync: false,\n      hasVisibleBaseVideo: false,\n    },\n  }\n\n  return {\n    ...runtime,\n    ...overrides,\n  } as unknown as VideoPanelRuntime\n}\n\ndescribe('VideoPanelCardBody', () => {\n  it('renders incoming and outgoing first-last-frame UI for chained panel', () => {\n    const markup = renderToStaticMarkup(\n      React.createElement(VideoPanelCardBody, {\n        runtime: createRuntime(),\n      }),\n    )\n\n    expect(markup).toContain('作为镜头 2 的尾帧')\n    expect(markup).toContain('作为镜头 4 的首帧')\n    expect(markup).toContain('视频提示词')\n    expect(markup).toContain('生成首尾帧视频')\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/video-panels-projection-error-code.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest'\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useMemo: <T,>(factory: () => T) => factory(),\n  }\n})\n\nimport { useVideoPanelsProjection } from '@/lib/novel-promotion/stages/video-stage-runtime/useVideoPanelsProjection'\n\ndescribe('video panels projection error code', () => {\n  it('projects failed task lastError code/message onto panel fields', () => {\n    const result = useVideoPanelsProjection({\n      clips: [{ id: 'clip-1', start: 0, end: 5, summary: 'clip' }],\n      storyboards: [{\n        id: 'sb-1',\n        clipId: 'clip-1',\n        panels: [{\n          id: 'panel-1',\n          panelIndex: 0,\n          description: 'panel',\n        }],\n      }],\n      panelVideoStates: {\n        getTaskState: () => ({\n          phase: 'failed',\n          lastError: {\n            code: 'EXTERNAL_ERROR',\n            message: 'upstream failed',\n          },\n        }),\n      },\n      panelLipStates: {\n        getTaskState: () => null,\n      },\n    })\n\n    expect(result.allPanels).toHaveLength(1)\n    expect(result.allPanels[0]?.videoErrorCode).toBe('EXTERNAL_ERROR')\n    expect(result.allPanels[0]?.videoErrorMessage).toBe('upstream failed')\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/voice-generation-actions.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst {\n  useStateMock,\n  useCallbackMock,\n  useQueryClientMock,\n  upsertTaskTargetOverlayMock,\n} = vi.hoisted(() => ({\n  useStateMock: vi.fn(),\n  useCallbackMock: vi.fn((fn: unknown) => fn),\n  useQueryClientMock: vi.fn(() => ({ id: 'query-client' })),\n  upsertTaskTargetOverlayMock: vi.fn(),\n}))\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useState: useStateMock,\n    useCallback: useCallbackMock,\n  }\n})\n\nvi.mock('@tanstack/react-query', () => ({\n  useQueryClient: () => useQueryClientMock(),\n}))\n\nvi.mock('@/lib/query/task-target-overlay', () => ({\n  upsertTaskTargetOverlay: (...args: unknown[]) => upsertTaskTargetOverlayMock(...args),\n}))\n\nimport { useVoiceGenerationActions } from '@/lib/novel-promotion/stages/voice-stage-runtime/useVoiceGenerationActions'\n\ndescribe('useVoiceGenerationActions', () => {\n  beforeEach(() => {\n    useStateMock.mockReset()\n    useCallbackMock.mockClear()\n    useQueryClientMock.mockClear()\n    upsertTaskTargetOverlayMock.mockReset()\n\n    useStateMock\n      .mockImplementationOnce(() => [false, vi.fn()])\n      .mockImplementationOnce(() => [false, vi.fn()])\n      .mockImplementationOnce(() => [false, vi.fn()])\n  })\n\n  it('adds an optimistic task overlay for async single-line generation', async () => {\n    const setPendingVoiceGenerationByLineId = vi.fn()\n    const notifyVoiceLinesChanged = vi.fn()\n    const generateVoiceMutation = {\n      mutateAsync: vi.fn(async () => ({\n        success: true,\n        async: true,\n        taskId: 'task-voice-1',\n      })),\n    }\n\n    const runtime = useVoiceGenerationActions({\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      t: (key: string) => key,\n      voiceLines: [],\n      linesWithAudio: 0,\n      speakerCharacterMap: {},\n      speakerVoices: {},\n      analyzeVoiceMutation: { mutateAsync: vi.fn() },\n      generateVoiceMutation,\n      downloadVoicesMutation: { mutateAsync: vi.fn() },\n      loadData: vi.fn(),\n      notifyVoiceLinesChanged,\n      setPendingVoiceGenerationByLineId,\n    })\n\n    await runtime.handleGenerateLine('line-1')\n\n    expect(upsertTaskTargetOverlayMock).toHaveBeenCalledWith(\n      { id: 'query-client' },\n      {\n        projectId: 'project-1',\n        targetType: 'NovelPromotionVoiceLine',\n        targetId: 'line-1',\n        phase: 'queued',\n        runningTaskId: 'task-voice-1',\n        runningTaskType: 'voice_line',\n        intent: 'generate',\n        hasOutputAtStart: false,\n      },\n    )\n    expect(notifyVoiceLinesChanged).toHaveBeenCalledTimes(1)\n    expect(setPendingVoiceGenerationByLineId).toHaveBeenCalledTimes(2)\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/voice-runtime-sync.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst { useEffectMock, useRefMock } = vi.hoisted(() => ({\n  useEffectMock: vi.fn(),\n  useRefMock: vi.fn(),\n}))\n\nconst { apiFetchMock } = vi.hoisted(() => ({\n  apiFetchMock: vi.fn(),\n}))\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useEffect: useEffectMock,\n    useRef: useRefMock,\n  }\n})\n\nvi.mock('@/lib/api-fetch', () => ({\n  apiFetch: (...args: unknown[]) => apiFetchMock(...args),\n}))\n\nimport { useVoiceRuntimeSync } from '@/lib/novel-promotion/stages/voice-stage-runtime/useVoiceRuntimeSync'\nimport type { VoiceLine } from '@/lib/novel-promotion/stages/voice-stage-runtime/types'\n\nfunction buildVoiceLine(overrides: Partial<VoiceLine>): VoiceLine {\n  return {\n    id: 'line-1',\n    lineIndex: 1,\n    speaker: '旁白',\n    content: '测试台词',\n    emotionPrompt: null,\n    emotionStrength: null,\n    audioUrl: null,\n    updatedAt: '2026-03-07T12:00:00.000Z',\n    lineTaskRunning: false,\n    ...overrides,\n  }\n}\n\ndescribe('useVoiceRuntimeSync', () => {\n  beforeEach(() => {\n    useEffectMock.mockReset()\n    useRefMock.mockReset()\n    apiFetchMock.mockReset()\n    useRefMock.mockImplementation((initialValue: unknown) => ({\n      current: initialValue,\n    }))\n  })\n\n  it('keeps pending regeneration until the line updatedAt advances', () => {\n    const loadData = vi.fn(async () => undefined)\n    const setPendingVoiceGenerationByLineId = vi.fn()\n    const effectCallbacks: Array<() => void | (() => void)> = []\n\n    useEffectMock.mockImplementation((callback: () => void | (() => void)) => {\n      effectCallbacks.push(callback)\n    })\n\n    const pendingGeneration = {\n      'line-1': {\n        submittedUpdatedAt: '2026-03-07T12:00:00.000Z',\n        startedAt: '2026-03-07T11:59:59.000Z',\n        taskId: 'task-1',\n        taskStatus: 'completed' as const,\n        taskErrorMessage: null,\n      },\n    }\n\n    useVoiceRuntimeSync({\n      loadData,\n      voiceLines: [buildVoiceLine({\n        audioUrl: '/m/voice-old.wav',\n        updatedAt: '2026-03-07T12:00:00.000Z',\n      })],\n      activeVoiceTaskLineIds: new Set(),\n      pendingVoiceGenerationByLineId: pendingGeneration,\n      setPendingVoiceGenerationByLineId,\n    })\n\n    const firstRenderEffects = effectCallbacks.splice(0)\n    firstRenderEffects[2]?.()\n\n    const keepPendingUpdater = setPendingVoiceGenerationByLineId.mock.calls[0]?.[0] as\n      | ((prev: typeof pendingGeneration) => typeof pendingGeneration)\n      | undefined\n    expect(keepPendingUpdater?.(pendingGeneration)).toBe(pendingGeneration)\n\n    useVoiceRuntimeSync({\n      loadData,\n      voiceLines: [buildVoiceLine({\n        audioUrl: '/m/voice-new.wav',\n        updatedAt: '2026-03-07T12:00:03.000Z',\n      })],\n      activeVoiceTaskLineIds: new Set(),\n      pendingVoiceGenerationByLineId: pendingGeneration,\n      setPendingVoiceGenerationByLineId,\n    })\n\n    const secondRenderEffects = effectCallbacks.splice(0)\n    secondRenderEffects[2]?.()\n\n    const settleUpdater = setPendingVoiceGenerationByLineId.mock.calls[1]?.[0] as\n      | ((prev: typeof pendingGeneration) => Record<string, never>)\n      | undefined\n    expect(settleUpdater?.(pendingGeneration)).toEqual({})\n  })\n\n  it('polls task status for pending generations with task ids', async () => {\n    const loadData = vi.fn(async () => undefined)\n    const setPendingVoiceGenerationByLineId = vi.fn()\n    const effectCallbacks: Array<() => void | (() => void)> = []\n    const windowStub = {\n      setInterval: vi.fn(() => 123 as unknown as number),\n      clearInterval: vi.fn(),\n    }\n    vi.stubGlobal('window', windowStub)\n    apiFetchMock.mockResolvedValue({\n      ok: true,\n      json: async () => ({\n        task: {\n          status: 'processing',\n          errorMessage: null,\n        },\n      }),\n    })\n\n    useEffectMock.mockImplementation((callback: () => void | (() => void)) => {\n      effectCallbacks.push(callback)\n    })\n\n    useVoiceRuntimeSync({\n      loadData,\n      voiceLines: [buildVoiceLine({\n        audioUrl: '/m/voice-old.wav',\n        updatedAt: '2026-03-07T12:00:00.000Z',\n      })],\n      activeVoiceTaskLineIds: new Set(),\n      pendingVoiceGenerationByLineId: {\n        'line-1': {\n          submittedUpdatedAt: '2026-03-07T12:00:00.000Z',\n          startedAt: '2026-03-07T12:24:10.000Z',\n          taskId: 'task-1',\n          taskStatus: 'queued',\n          taskErrorMessage: null,\n        },\n      },\n      setPendingVoiceGenerationByLineId,\n    })\n\n    const renderEffects = effectCallbacks.splice(0)\n    const cleanup = renderEffects[3]?.()\n\n    await Promise.resolve()\n\n    expect(apiFetchMock).toHaveBeenCalledWith('/api/tasks/task-1', {\n      method: 'GET',\n      cache: 'no-store',\n    })\n    expect(windowStub.setInterval).toHaveBeenCalledWith(expect.any(Function), 1200)\n\n    cleanup?.()\n    expect(windowStub.clearInterval).toHaveBeenCalledWith(123)\n    vi.unstubAllGlobals()\n  })\n\n  it('notifies task failure with backend error message', () => {\n    const loadData = vi.fn(async () => undefined)\n    const setPendingVoiceGenerationByLineId = vi.fn()\n    const onTaskFailure = vi.fn()\n    const effectCallbacks: Array<() => void | (() => void)> = []\n\n    useEffectMock.mockImplementation((callback: () => void | (() => void)) => {\n      effectCallbacks.push(callback)\n    })\n\n    useVoiceRuntimeSync({\n      loadData,\n      voiceLines: [buildVoiceLine({\n        id: 'line-9',\n        lineIndex: 9,\n      })],\n      activeVoiceTaskLineIds: new Set(),\n      pendingVoiceGenerationByLineId: {\n        'line-9': {\n          submittedUpdatedAt: '2026-03-07T12:00:00.000Z',\n          startedAt: '2026-03-07T12:24:10.000Z',\n          taskId: 'task-failed-1',\n          taskStatus: 'failed',\n          taskErrorMessage: 'QwenTTS voiceId missing',\n        },\n      },\n      setPendingVoiceGenerationByLineId,\n      onTaskFailure,\n    })\n\n    const renderEffects = effectCallbacks.splice(0)\n    renderEffects[1]?.()\n\n    expect(onTaskFailure).toHaveBeenCalledWith({\n      lineId: 'line-9',\n      line: expect.objectContaining({\n        id: 'line-9',\n        lineIndex: 9,\n      }),\n      taskId: 'task-failed-1',\n      errorMessage: 'QwenTTS voiceId missing',\n    })\n  })\n\n  it('treats canceled task as terminal failure for pending voice generation', () => {\n    const loadData = vi.fn(async () => undefined)\n    const setPendingVoiceGenerationByLineId = vi.fn()\n    const onTaskFailure = vi.fn()\n    const effectCallbacks: Array<() => void | (() => void)> = []\n\n    useEffectMock.mockImplementation((callback: () => void | (() => void)) => {\n      effectCallbacks.push(callback)\n    })\n\n    useVoiceRuntimeSync({\n      loadData,\n      voiceLines: [buildVoiceLine({\n        id: 'line-10',\n        lineIndex: 10,\n      })],\n      activeVoiceTaskLineIds: new Set(),\n      pendingVoiceGenerationByLineId: {\n        'line-10': {\n          submittedUpdatedAt: '2026-03-07T12:00:00.000Z',\n          startedAt: '2026-03-07T12:24:10.000Z',\n          taskId: 'task-canceled-1',\n          taskStatus: 'canceled',\n          taskErrorMessage: 'Task cancelled by user',\n        },\n      },\n      setPendingVoiceGenerationByLineId,\n      onTaskFailure,\n    })\n\n    const renderEffects = effectCallbacks.splice(0)\n    renderEffects[1]?.()\n\n    expect(onTaskFailure).toHaveBeenCalledWith({\n      lineId: 'line-10',\n      line: expect.objectContaining({\n        id: 'line-10',\n        lineIndex: 10,\n      }),\n      taskId: 'task-canceled-1',\n      errorMessage: 'Task cancelled by user',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/voice-stage-data-loader.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst {\n  useStateMock,\n  useRefMock,\n  useCallbackMock,\n  useEffectMock,\n  mutateAsyncMock,\n} = vi.hoisted(() => ({\n  useStateMock: vi.fn(),\n  useRefMock: vi.fn((value: unknown) => ({ current: value })),\n  useCallbackMock: vi.fn((fn: unknown) => fn),\n  useEffectMock: vi.fn(),\n  mutateAsyncMock: vi.fn(),\n}))\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useState: useStateMock,\n    useRef: useRefMock,\n    useCallback: useCallbackMock,\n    useEffect: useEffectMock,\n  }\n})\n\nvi.mock('@/lib/query/hooks', () => ({\n  useFetchProjectVoiceStageData: () => ({\n    mutateAsync: mutateAsyncMock,\n  }),\n}))\n\nimport { useVoiceStageDataLoader } from '@/lib/novel-promotion/stages/voice-stage-runtime/useVoiceStageDataLoader'\n\ndescribe('useVoiceStageDataLoader', () => {\n  beforeEach(() => {\n    useStateMock.mockReset()\n    useRefMock.mockClear()\n    useCallbackMock.mockClear()\n    useEffectMock.mockClear()\n    mutateAsyncMock.mockReset()\n  })\n\n  it('keeps background reloads from re-entering blocking loading state', async () => {\n    const setVoiceLines = vi.fn()\n    const setSpeakerVoices = vi.fn()\n    const setProjectSpeakers = vi.fn()\n    const setLoading = vi.fn()\n\n    useStateMock\n      .mockImplementationOnce(() => [[], setVoiceLines])\n      .mockImplementationOnce(() => [{}, setSpeakerVoices])\n      .mockImplementationOnce(() => [[], setProjectSpeakers])\n      .mockImplementationOnce(() => [true, setLoading])\n\n    mutateAsyncMock\n      .mockResolvedValueOnce({\n        voiceLines: [{ id: 'line-1' }],\n        speakerVoices: { Narrator: { voiceType: 'uploaded', voiceId: 'voice-1' } },\n        speakers: ['Narrator'],\n      })\n      .mockResolvedValueOnce({\n        voiceLines: [{ id: 'line-1' }],\n        speakerVoices: { Narrator: { voiceType: 'uploaded', voiceId: 'voice-2' } },\n        speakers: ['Narrator'],\n      })\n\n    const hook = useVoiceStageDataLoader({\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n    })\n\n    await hook.loadData()\n    await hook.loadData()\n\n    expect(\n      setLoading.mock.calls.filter(([value]) => value === true),\n    ).toHaveLength(1)\n    expect(\n      setLoading.mock.calls.filter(([value]) => value === false),\n    ).toHaveLength(2)\n    expect(setVoiceLines).toHaveBeenNthCalledWith(1, [{ id: 'line-1' }])\n    expect(setVoiceLines).toHaveBeenNthCalledWith(2, [{ id: 'line-1' }])\n    expect(mutateAsyncMock).toHaveBeenNthCalledWith(1, { episodeId: 'episode-1' })\n    expect(mutateAsyncMock).toHaveBeenNthCalledWith(2, { episodeId: 'episode-1' })\n  })\n})\n"
  },
  {
    "path": "tests/unit/novel-promotion/workspace-video-actions.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst {\n  generateVideoMutateAsyncMock,\n  batchGenerateVideosMutateAsyncMock,\n  updateProjectPanelVideoPromptMutateAsyncMock,\n  updateProjectClipMutateAsyncMock,\n  updateProjectConfigMutateAsyncMock,\n} = vi.hoisted(() => ({\n  generateVideoMutateAsyncMock: vi.fn(),\n  batchGenerateVideosMutateAsyncMock: vi.fn(),\n  updateProjectPanelVideoPromptMutateAsyncMock: vi.fn(),\n  updateProjectClipMutateAsyncMock: vi.fn(),\n  updateProjectConfigMutateAsyncMock: vi.fn(),\n}))\n\nvi.mock('@/lib/query/hooks/useStoryboards', () => ({\n  useGenerateVideo: () => ({\n    mutateAsync: generateVideoMutateAsyncMock,\n  }),\n  useBatchGenerateVideos: () => ({\n    mutateAsync: batchGenerateVideosMutateAsyncMock,\n  }),\n}))\n\nvi.mock('@/lib/query/hooks', () => ({\n  useUpdateProjectPanelVideoPrompt: () => ({\n    mutateAsync: updateProjectPanelVideoPromptMutateAsyncMock,\n  }),\n  useUpdateProjectClip: () => ({\n    mutateAsync: updateProjectClipMutateAsyncMock,\n  }),\n  useUpdateProjectConfig: () => ({\n    mutateAsync: updateProjectConfigMutateAsyncMock,\n  }),\n}))\n\nimport { useWorkspaceVideoActions } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceVideoActions'\n\ndescribe('useWorkspaceVideoActions', () => {\n  const originalAlert = globalThis.alert\n\n  beforeEach(() => {\n    generateVideoMutateAsyncMock.mockReset()\n    batchGenerateVideosMutateAsyncMock.mockReset()\n    updateProjectPanelVideoPromptMutateAsyncMock.mockReset()\n    updateProjectClipMutateAsyncMock.mockReset()\n    updateProjectConfigMutateAsyncMock.mockReset()\n    globalThis.alert = vi.fn()\n  })\n\n  afterEach(() => {\n    globalThis.alert = originalAlert\n  })\n\n  it('single video mutation fails -> rethrows error for immediate lock cleanup', async () => {\n    generateVideoMutateAsyncMock.mockRejectedValueOnce(new Error('video submit failed'))\n\n    const actions = useWorkspaceVideoActions({\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      t: (key: string) => key,\n    })\n\n    await expect(\n      actions.handleGenerateVideo('storyboard-1', 0, 'veo-3.1'),\n    ).rejects.toThrow('video submit failed')\n\n    expect(globalThis.alert).toHaveBeenCalledWith('execution.generationFailed: video submit failed')\n  })\n})\n"
  },
  {
    "path": "tests/unit/optimistic/ai-data-modal-state.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  createAIDataModalDraftState,\n  mergeAIDataModalDraftStateByDirty,\n} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useAIDataModalState'\n\ndescribe('useAIDataModalState optimistic sync helpers', () => {\n  it('keeps dirty fields when server data refreshes', () => {\n    const localDraft = createAIDataModalDraftState({\n      initialShotType: 'Close-up',\n      initialCameraMove: 'Push in',\n      initialDescription: 'user typing draft',\n      initialVideoPrompt: 'prompt-a',\n      initialPhotographyRules: null,\n      initialActingNotes: null,\n    })\n\n    const serverDraft = createAIDataModalDraftState({\n      initialShotType: 'Wide',\n      initialCameraMove: 'Pan left',\n      initialDescription: 'server-updated-desc',\n      initialVideoPrompt: 'prompt-b',\n      initialPhotographyRules: null,\n      initialActingNotes: null,\n    })\n\n    const merged = mergeAIDataModalDraftStateByDirty(\n      localDraft,\n      serverDraft,\n      new Set(['description']),\n    )\n\n    expect(merged.description).toBe('user typing draft')\n    expect(merged.shotType).toBe('Wide')\n    expect(merged.cameraMove).toBe('Pan left')\n    expect(merged.videoPrompt).toBe('prompt-b')\n  })\n\n  it('syncs non-dirty nested fields from server', () => {\n    const localDraft = createAIDataModalDraftState({\n      initialShotType: 'A',\n      initialCameraMove: 'B',\n      initialDescription: 'C',\n      initialVideoPrompt: 'D',\n      initialPhotographyRules: {\n        scene_summary: 'local scene',\n        lighting: {\n          direction: 'front',\n          quality: 'soft',\n        },\n        characters: [{\n          name: 'hero',\n          screen_position: 'left',\n          posture: 'standing',\n          facing: 'camera',\n        }],\n        depth_of_field: 'deep',\n        color_tone: 'warm',\n      },\n      initialActingNotes: [{\n        name: 'hero',\n        acting: 'smile',\n      }],\n    })\n\n    const serverDraft = createAIDataModalDraftState({\n      initialShotType: 'A2',\n      initialCameraMove: 'B2',\n      initialDescription: 'C2',\n      initialVideoPrompt: 'D2',\n      initialPhotographyRules: {\n        scene_summary: 'server scene',\n        lighting: {\n          direction: 'back',\n          quality: 'hard',\n        },\n        characters: [{\n          name: 'hero',\n          screen_position: 'center',\n          posture: 'running',\n          facing: 'right',\n        }],\n        depth_of_field: 'shallow',\n        color_tone: 'cool',\n      },\n      initialActingNotes: [{\n        name: 'hero',\n        acting: 'angry',\n      }],\n    })\n\n    const merged = mergeAIDataModalDraftStateByDirty(\n      localDraft,\n      serverDraft,\n      new Set(['videoPrompt']),\n    )\n\n    expect(merged.videoPrompt).toBe('D')\n    expect(merged.photographyRules?.scene_summary).toBe('server scene')\n    expect(merged.photographyRules?.lighting.direction).toBe('back')\n    expect(merged.actingNotes[0]?.acting).toBe('angry')\n  })\n})\n"
  },
  {
    "path": "tests/unit/optimistic/asset-hub-mutations.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { GlobalCharacter, GlobalLocation } from '@/lib/query/hooks/useGlobalAssets'\nimport { queryKeys } from '@/lib/query/keys'\nimport { MockQueryClient } from '../../helpers/mock-query-client'\n\nlet queryClient = new MockQueryClient()\nconst useQueryClientMock = vi.fn(() => queryClient)\nconst useMutationMock = vi.fn((options: unknown) => options)\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useRef: <T,>(value: T) => ({ current: value }),\n  }\n})\n\nvi.mock('@tanstack/react-query', () => ({\n  useQueryClient: () => useQueryClientMock(),\n  useMutation: (options: unknown) => useMutationMock(options),\n}))\n\nvi.mock('@/lib/query/mutations/mutation-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(\n    '@/lib/query/mutations/mutation-shared',\n  )\n  return {\n    ...actual,\n    requestJsonWithError: vi.fn(),\n    requestVoidWithError: vi.fn(),\n  }\n})\n\nvi.mock('@/lib/query/mutations/asset-hub-mutations-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/query/mutations/asset-hub-mutations-shared')>(\n    '@/lib/query/mutations/asset-hub-mutations-shared',\n  )\n  return {\n    ...actual,\n    invalidateGlobalCharacters: vi.fn(),\n    invalidateGlobalLocations: vi.fn(),\n  }\n})\n\nimport {\n  useSelectCharacterImage,\n} from '@/lib/query/mutations/asset-hub-character-mutations'\nimport { useDeleteLocation as useDeleteAssetHubLocation } from '@/lib/query/mutations/asset-hub-location-mutations'\n\ninterface SelectCharacterMutation {\n  onMutate: (variables: {\n    characterId: string\n    appearanceIndex: number\n    imageIndex: number | null\n  }) => Promise<unknown>\n  onError: (error: unknown, variables: unknown, context: unknown) => void\n}\n\ninterface DeleteLocationMutation {\n  onMutate: (locationId: string) => Promise<unknown>\n  onError: (error: unknown, locationId: string, context: unknown) => void\n}\n\nfunction buildGlobalCharacter(selectedIndex: number | null): GlobalCharacter {\n  return {\n    id: 'character-1',\n    name: 'Hero',\n    folderId: 'folder-1',\n    customVoiceUrl: null,\n    appearances: [{\n      id: 'appearance-1',\n      appearanceIndex: 0,\n      changeReason: 'default',\n      artStyle: 'realistic',\n      description: null,\n      descriptionSource: null,\n      imageUrl: selectedIndex === null ? null : `img-${selectedIndex}`,\n      imageUrls: ['img-0', 'img-1', 'img-2'],\n      selectedIndex,\n      previousImageUrl: null,\n      previousImageUrls: [],\n      imageTaskRunning: false,\n    }],\n  }\n}\n\nfunction buildGlobalLocation(id: string): GlobalLocation {\n  return {\n    id,\n    name: `Location ${id}`,\n    summary: null,\n    folderId: 'folder-1',\n    artStyle: 'realistic',\n    images: [{\n      id: `${id}-img-0`,\n      imageIndex: 0,\n      description: null,\n      imageUrl: null,\n      previousImageUrl: null,\n      isSelected: true,\n      imageTaskRunning: false,\n    }],\n  }\n}\n\ndescribe('asset hub optimistic mutations', () => {\n  beforeEach(() => {\n    queryClient = new MockQueryClient()\n    useQueryClientMock.mockClear()\n    useMutationMock.mockClear()\n  })\n\n  it('updates all character query caches optimistically and ignores stale rollback', async () => {\n    const allCharactersKey = queryKeys.globalAssets.characters()\n    const folderCharactersKey = queryKeys.globalAssets.characters('folder-1')\n    queryClient.seedQuery(allCharactersKey, [buildGlobalCharacter(0)])\n    queryClient.seedQuery(folderCharactersKey, [buildGlobalCharacter(0)])\n\n    const mutation = useSelectCharacterImage() as unknown as SelectCharacterMutation\n    const firstVariables = {\n      characterId: 'character-1',\n      appearanceIndex: 0,\n      imageIndex: 1,\n    }\n    const secondVariables = {\n      characterId: 'character-1',\n      appearanceIndex: 0,\n      imageIndex: 2,\n    }\n\n    const firstContext = await mutation.onMutate(firstVariables)\n    const afterFirstAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)\n    const afterFirstFolder = queryClient.getQueryData<GlobalCharacter[]>(folderCharactersKey)\n    expect(afterFirstAll?.[0]?.appearances[0]?.selectedIndex).toBe(1)\n    expect(afterFirstFolder?.[0]?.appearances[0]?.selectedIndex).toBe(1)\n\n    const secondContext = await mutation.onMutate(secondVariables)\n    const afterSecondAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)\n    expect(afterSecondAll?.[0]?.appearances[0]?.selectedIndex).toBe(2)\n\n    mutation.onError(new Error('first failed'), firstVariables, firstContext)\n    const afterStaleError = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)\n    expect(afterStaleError?.[0]?.appearances[0]?.selectedIndex).toBe(2)\n\n    mutation.onError(new Error('second failed'), secondVariables, secondContext)\n    const afterLatestRollback = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)\n    expect(afterLatestRollback?.[0]?.appearances[0]?.selectedIndex).toBe(1)\n  })\n\n  it('optimistically removes location and restores on error', async () => {\n    const allLocationsKey = queryKeys.globalAssets.locations()\n    const folderLocationsKey = queryKeys.globalAssets.locations('folder-1')\n    queryClient.seedQuery(allLocationsKey, [buildGlobalLocation('loc-1'), buildGlobalLocation('loc-2')])\n    queryClient.seedQuery(folderLocationsKey, [buildGlobalLocation('loc-1')])\n\n    const mutation = useDeleteAssetHubLocation() as unknown as DeleteLocationMutation\n    const context = await mutation.onMutate('loc-1')\n\n    const afterDeleteAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)\n    const afterDeleteFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)\n    expect(afterDeleteAll?.map((item) => item.id)).toEqual(['loc-2'])\n    expect(afterDeleteFolder).toEqual([])\n\n    mutation.onError(new Error('delete failed'), 'loc-1', context)\n\n    const rolledBackAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)\n    const rolledBackFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)\n    expect(rolledBackAll?.map((item) => item.id)).toEqual(['loc-1', 'loc-2'])\n    expect(rolledBackFolder?.map((item) => item.id)).toEqual(['loc-1'])\n  })\n})\n"
  },
  {
    "path": "tests/unit/optimistic/panel-ai-data-sync.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  serializeStructuredJsonField,\n  syncPanelCharacterDependentJson,\n} from '@/lib/novel-promotion/panel-ai-data-sync'\n\ndescribe('panel ai data sync helpers', () => {\n  it('removes deleted character from acting notes and photography rules', () => {\n    const synced = syncPanelCharacterDependentJson({\n      characters: [\n        { name: '楚江锴/当朝皇帝', appearance: '初始形象' },\n        { name: '燕画乔/魏画乔', appearance: '初始形象' },\n      ],\n      removeIndex: 0,\n      actingNotesJson: JSON.stringify([\n        { name: '楚江锴/当朝皇帝', acting: '紧握手腕' },\n        { name: '燕画乔/魏画乔', acting: '本能后退' },\n      ]),\n      photographyRulesJson: JSON.stringify({\n        lighting: {\n          direction: '侧逆光',\n          quality: '硬光',\n        },\n        characters: [\n          { name: '楚江锴/当朝皇帝', screen_position: 'left' },\n          { name: '燕画乔/魏画乔', screen_position: 'right' },\n        ],\n      }),\n    })\n\n    expect(synced.characters).toEqual([{ name: '燕画乔/魏画乔', appearance: '初始形象' }])\n    expect(JSON.parse(synced.actingNotesJson || 'null')).toEqual([\n      { name: '燕画乔/魏画乔', acting: '本能后退' },\n    ])\n    expect(JSON.parse(synced.photographyRulesJson || 'null')).toEqual({\n      lighting: {\n        direction: '侧逆光',\n        quality: '硬光',\n      },\n      characters: [\n        { name: '燕画乔/魏画乔', screen_position: 'right' },\n      ],\n    })\n  })\n\n  it('keeps notes by character name when another appearance of same name remains', () => {\n    const synced = syncPanelCharacterDependentJson({\n      characters: [\n        { name: '顾娘子/顾盼之', appearance: '素衣' },\n        { name: '顾娘子/顾盼之', appearance: '华服' },\n      ],\n      removeIndex: 1,\n      actingNotesJson: JSON.stringify([\n        { name: '顾娘子/顾盼之', acting: '抬眼看向窗外' },\n      ]),\n      photographyRulesJson: JSON.stringify({\n        characters: [\n          { name: '顾娘子/顾盼之', screen_position: 'center' },\n        ],\n      }),\n    })\n\n    expect(JSON.parse(synced.actingNotesJson || 'null')).toEqual([\n      { name: '顾娘子/顾盼之', acting: '抬眼看向窗外' },\n    ])\n    expect(JSON.parse(synced.photographyRulesJson || 'null')).toEqual({\n      characters: [\n        { name: '顾娘子/顾盼之', screen_position: 'center' },\n      ],\n    })\n  })\n\n  it('supports double-serialized JSON string inputs', () => {\n    const actingNotes = JSON.stringify([{ name: '甲', acting: '动作' }])\n    const doubleSerialized = JSON.stringify(actingNotes)\n    expect(serializeStructuredJsonField(doubleSerialized, 'actingNotes')).toBe(actingNotes)\n  })\n\n  it('throws on malformed acting notes to avoid silent fallback', () => {\n    expect(() => syncPanelCharacterDependentJson({\n      characters: [{ name: '甲', appearance: '初始形象' }],\n      removeIndex: 0,\n      actingNotesJson: '[{\"name\":\"甲\",\"acting\":\"动作\"}, {\"acting\":\"缺少名字\"}]',\n      photographyRulesJson: null,\n    })).toThrowError('actingNotes item.name must be a non-empty string')\n  })\n})\n"
  },
  {
    "path": "tests/unit/optimistic/panel-save-coordinator.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport type { PanelEditData } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PanelEditForm'\nimport {\n  PanelSaveCoordinator,\n  type PanelSaveState,\n} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/panel-save-coordinator'\n\nfunction buildSnapshot(description: string): PanelEditData {\n  return {\n    id: 'panel-1',\n    panelIndex: 0,\n    panelNumber: 1,\n    shotType: 'close-up',\n    cameraMove: 'push',\n    description,\n    location: null,\n    characters: [],\n    srtStart: null,\n    srtEnd: null,\n    duration: null,\n    videoPrompt: null,\n  }\n}\n\ndescribe('PanelSaveCoordinator', () => {\n  it('keeps single-flight and only flushes the latest snapshot after burst edits', async () => {\n    const savedDescriptions: string[] = []\n    let releaseFirstAttempt: () => void = () => {}\n    const firstAttemptGate = new Promise<void>((resolve) => {\n      releaseFirstAttempt = () => resolve()\n    })\n    let attempts = 0\n\n    const coordinator = new PanelSaveCoordinator({\n      onSavingChange: () => {},\n      onStateChange: () => {},\n      runSave: async ({ snapshot }) => {\n        attempts += 1\n        if (attempts === 1) {\n          await firstAttemptGate\n        }\n        savedDescriptions.push(snapshot.description ?? '')\n      },\n      resolveErrorMessage: () => 'save failed',\n    })\n\n    const firstRun = coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v1'))\n    coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v2'))\n    coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v3'))\n\n    releaseFirstAttempt()\n    await firstRun\n\n    expect(savedDescriptions).toEqual(['v1', 'v3'])\n  })\n\n  it('marks error on failure and clears unsaved state after retry success', async () => {\n    const stateByPanel = new Map<string, PanelSaveState>()\n    let attemptCount = 0\n\n    const coordinator = new PanelSaveCoordinator({\n      onSavingChange: () => {},\n      onStateChange: (panelId, state) => {\n        stateByPanel.set(panelId, state)\n      },\n      runSave: async () => {\n        attemptCount += 1\n        if (attemptCount === 1) {\n          throw new Error('network timeout')\n        }\n      },\n      resolveErrorMessage: (error) => (error instanceof Error ? error.message : 'unknown'),\n    })\n\n    const firstRun = coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('draft text'))\n    await firstRun\n    expect(stateByPanel.get('panel-1')).toEqual({\n      status: 'error',\n      errorMessage: 'network timeout',\n    })\n\n    const retryRun = coordinator.retry('panel-1', buildSnapshot('draft text'))\n    await retryRun\n    expect(stateByPanel.get('panel-1')).toEqual({\n      status: 'idle',\n      errorMessage: null,\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/optimistic/project-asset-mutations.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { Character, Location, Project } from '@/types/project'\nimport type { ProjectAssetsData } from '@/lib/query/hooks/useProjectAssets'\nimport { queryKeys } from '@/lib/query/keys'\nimport { MockQueryClient } from '../../helpers/mock-query-client'\n\nlet queryClient = new MockQueryClient()\nconst useQueryClientMock = vi.fn(() => queryClient)\nconst useMutationMock = vi.fn((options: unknown) => options)\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useRef: <T,>(value: T) => ({ current: value }),\n  }\n})\n\nvi.mock('@tanstack/react-query', () => ({\n  useQueryClient: () => useQueryClientMock(),\n  useMutation: (options: unknown) => useMutationMock(options),\n}))\n\nvi.mock('@/lib/query/mutations/mutation-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(\n    '@/lib/query/mutations/mutation-shared',\n  )\n  return {\n    ...actual,\n    requestJsonWithError: vi.fn(),\n    requestVoidWithError: vi.fn(),\n    invalidateQueryTemplates: vi.fn(),\n  }\n})\n\nimport {\n  useDeleteProjectCharacter,\n  useSelectProjectCharacterImage,\n} from '@/lib/query/mutations/character-base-mutations'\n\ninterface SelectProjectCharacterMutation {\n  onMutate: (variables: {\n    characterId: string\n    appearanceId: string\n    imageIndex: number | null\n  }) => Promise<unknown>\n  onError: (error: unknown, variables: unknown, context: unknown) => void\n}\n\ninterface DeleteProjectCharacterMutation {\n  onMutate: (characterId: string) => Promise<unknown>\n  onError: (error: unknown, characterId: string, context: unknown) => void\n}\n\nfunction buildCharacter(selectedIndex: number | null): Character {\n  return {\n    id: 'character-1',\n    name: 'Hero',\n    appearances: [{\n      id: 'appearance-1',\n      appearanceIndex: 0,\n      changeReason: 'default',\n      description: null,\n      descriptions: null,\n      imageUrl: selectedIndex === null ? null : `img-${selectedIndex}`,\n      imageUrls: ['img-0', 'img-1', 'img-2'],\n      previousImageUrl: null,\n      previousImageUrls: [],\n      previousDescription: null,\n      previousDescriptions: null,\n      selectedIndex,\n    }],\n  }\n}\n\nfunction buildAssets(selectedIndex: number | null): ProjectAssetsData {\n  return {\n    characters: [buildCharacter(selectedIndex)],\n    locations: [] as Location[],\n  }\n}\n\nfunction buildProject(selectedIndex: number | null): Project {\n  return {\n    novelPromotionData: {\n      characters: [buildCharacter(selectedIndex)],\n      locations: [],\n    },\n  } as unknown as Project\n}\n\ndescribe('project asset optimistic mutations', () => {\n  beforeEach(() => {\n    queryClient = new MockQueryClient()\n    useQueryClientMock.mockClear()\n    useMutationMock.mockClear()\n  })\n\n  it('optimistically selects project character image and ignores stale rollback', async () => {\n    const projectId = 'project-1'\n    const assetsKey = queryKeys.projectAssets.all(projectId)\n    const projectKey = queryKeys.projectData(projectId)\n    queryClient.seedQuery(assetsKey, buildAssets(0))\n    queryClient.seedQuery(projectKey, buildProject(0))\n\n    const mutation = useSelectProjectCharacterImage(projectId) as unknown as SelectProjectCharacterMutation\n    const firstVariables = {\n      characterId: 'character-1',\n      appearanceId: 'appearance-1',\n      imageIndex: 1,\n    }\n    const secondVariables = {\n      characterId: 'character-1',\n      appearanceId: 'appearance-1',\n      imageIndex: 2,\n    }\n\n    const firstContext = await mutation.onMutate(firstVariables)\n    const afterFirst = queryClient.getQueryData<ProjectAssetsData>(assetsKey)\n    expect(afterFirst?.characters[0]?.appearances[0]?.selectedIndex).toBe(1)\n\n    const secondContext = await mutation.onMutate(secondVariables)\n    const afterSecond = queryClient.getQueryData<ProjectAssetsData>(assetsKey)\n    expect(afterSecond?.characters[0]?.appearances[0]?.selectedIndex).toBe(2)\n\n    mutation.onError(new Error('first failed'), firstVariables, firstContext)\n    const afterStaleError = queryClient.getQueryData<ProjectAssetsData>(assetsKey)\n    expect(afterStaleError?.characters[0]?.appearances[0]?.selectedIndex).toBe(2)\n\n    mutation.onError(new Error('second failed'), secondVariables, secondContext)\n    const afterLatestRollback = queryClient.getQueryData<ProjectAssetsData>(assetsKey)\n    expect(afterLatestRollback?.characters[0]?.appearances[0]?.selectedIndex).toBe(1)\n  })\n\n  it('optimistically deletes project character and restores on error', async () => {\n    const projectId = 'project-1'\n    const assetsKey = queryKeys.projectAssets.all(projectId)\n    const projectKey = queryKeys.projectData(projectId)\n    queryClient.seedQuery(assetsKey, buildAssets(0))\n    queryClient.seedQuery(projectKey, buildProject(0))\n\n    const mutation = useDeleteProjectCharacter(projectId) as unknown as DeleteProjectCharacterMutation\n    const context = await mutation.onMutate('character-1')\n\n    const afterDeleteAssets = queryClient.getQueryData<ProjectAssetsData>(assetsKey)\n    expect(afterDeleteAssets?.characters).toHaveLength(0)\n\n    const afterDeleteProject = queryClient.getQueryData<Project>(projectKey)\n    expect(afterDeleteProject?.novelPromotionData?.characters ?? []).toHaveLength(0)\n\n    mutation.onError(new Error('delete failed'), 'character-1', context)\n\n    const rolledBackAssets = queryClient.getQueryData<ProjectAssetsData>(assetsKey)\n    expect(rolledBackAssets?.characters).toHaveLength(1)\n    expect(rolledBackAssets?.characters[0]?.id).toBe('character-1')\n  })\n})\n"
  },
  {
    "path": "tests/unit/optimistic/sse-invalidation.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { queryKeys } from '@/lib/query/keys'\nimport { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE } from '@/lib/task/types'\n\ntype InvalidateArg = { queryKey?: readonly unknown[]; exact?: boolean }\n\ntype EffectCleanup = (() => void) | void | null\n\nconst runtime = vi.hoisted(() => ({\n  queryClient: {\n    invalidateQueries: vi.fn(async (_arg?: InvalidateArg) => undefined),\n  },\n  effectCleanup: null as EffectCleanup,\n  scheduledTimers: [] as Array<() => void>,\n}))\n\nconst overlayMock = vi.hoisted(() => ({\n  applyTaskLifecycleToOverlay: vi.fn(),\n}))\n\nclass FakeEventSource {\n  static OPEN = 1\n  static instances: FakeEventSource[] = []\n\n  readonly url: string\n  readyState = FakeEventSource.OPEN\n  onmessage: ((event: MessageEvent) => void) | null = null\n  onerror: ((event: Event) => void) | null = null\n  private listeners = new Map<string, Set<EventListener>>()\n\n  constructor(url: string) {\n    this.url = url\n    FakeEventSource.instances.push(this)\n  }\n\n  addEventListener(type: string, handler: EventListener) {\n    const set = this.listeners.get(type) || new Set<EventListener>()\n    set.add(handler)\n    this.listeners.set(type, set)\n  }\n\n  removeEventListener(type: string, handler: EventListener) {\n    const set = this.listeners.get(type)\n    if (!set) return\n    set.delete(handler)\n  }\n\n  emit(type: string, payload: unknown) {\n    const event = { data: JSON.stringify(payload) } as MessageEvent\n    if (this.onmessage) this.onmessage(event)\n    const set = this.listeners.get(type)\n    if (!set) return\n    for (const handler of set) {\n      handler(event as unknown as Event)\n    }\n  }\n\n  close() {\n    this.readyState = 2\n  }\n}\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useMemo: <T,>(factory: () => T) => factory(),\n    useRef: <T,>(value: T) => ({ current: value }),\n    useEffect: (effect: () => EffectCleanup) => {\n      runtime.effectCleanup = effect()\n    },\n  }\n})\n\nvi.mock('@tanstack/react-query', () => ({\n  useQueryClient: () => runtime.queryClient,\n}))\n\nvi.mock('@/lib/query/task-target-overlay', () => overlayMock)\n\nfunction hasInvalidation(predicate: (arg: InvalidateArg) => boolean) {\n  return runtime.queryClient.invalidateQueries.mock.calls.some((call) => {\n    const arg = (call[0] || {}) as InvalidateArg\n    return predicate(arg)\n  })\n}\n\ndescribe('sse invalidation behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    runtime.effectCleanup = null\n    runtime.scheduledTimers = []\n    FakeEventSource.instances = []\n\n    ;(globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource\n    ;(globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }).window = {\n      setTimeout: ((cb: () => void) => {\n        runtime.scheduledTimers.push(cb)\n        return runtime.scheduledTimers.length as unknown as ReturnType<typeof setTimeout>\n      }) as unknown as typeof setTimeout,\n      clearTimeout: (() => undefined) as unknown as typeof clearTimeout,\n    }\n  })\n\n  it('PROCESSING(progress 数值) 不触发 target-state invalidation；COMPLETED 触发', async () => {\n    const { useSSE } = await import('@/lib/query/hooks/useSSE')\n\n    useSSE({\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      enabled: true,\n    })\n\n    const source = FakeEventSource.instances[0]\n    expect(source).toBeTruthy()\n\n    source.emit(TASK_SSE_EVENT_TYPE.LIFECYCLE, {\n      type: TASK_SSE_EVENT_TYPE.LIFECYCLE,\n      taskId: 'task-1',\n      taskType: 'IMAGE_CHARACTER',\n      targetType: 'CharacterAppearance',\n      targetId: 'appearance-1',\n      episodeId: 'episode-1',\n      payload: {\n        lifecycleType: TASK_EVENT_TYPE.PROCESSING,\n        progress: 32,\n      },\n    })\n\n    expect(hasInvalidation((arg) => {\n      const key = arg.queryKey || []\n      return Array.isArray(key) && key[0] === 'task-target-states'\n    })).toBe(false)\n\n    source.emit(TASK_SSE_EVENT_TYPE.LIFECYCLE, {\n      type: TASK_SSE_EVENT_TYPE.LIFECYCLE,\n      taskId: 'task-1',\n      taskType: 'IMAGE_CHARACTER',\n      targetType: 'CharacterAppearance',\n      targetId: 'appearance-1',\n      episodeId: 'episode-1',\n      payload: {\n        lifecycleType: TASK_EVENT_TYPE.COMPLETED,\n      },\n    })\n\n    for (const cb of runtime.scheduledTimers) cb()\n\n    expect(hasInvalidation((arg) => {\n      const key = arg.queryKey || []\n      return Array.isArray(key)\n        && key[0] === queryKeys.tasks.targetStatesAll('project-1')[0]\n        && key[1] === 'project-1'\n        && arg.exact === false\n    })).toBe(true)\n\n    expect(overlayMock.applyTaskLifecycleToOverlay).toHaveBeenCalledWith(\n      runtime.queryClient,\n      expect.objectContaining({\n        projectId: 'project-1',\n        lifecycleType: TASK_EVENT_TYPE.COMPLETED,\n        targetType: 'CharacterAppearance',\n        targetId: 'appearance-1',\n      }),\n    )\n  })\n})\n"
  },
  {
    "path": "tests/unit/optimistic/task-target-overlay.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { QueryClient } from '@tanstack/react-query'\nimport {\n  applyTaskLifecycleToOverlay,\n  upsertTaskTargetOverlay,\n  type TaskTargetOverlayMap,\n} from '@/lib/query/task-target-overlay'\nimport { queryKeys } from '@/lib/query/keys'\nimport { TASK_EVENT_TYPE } from '@/lib/task/types'\n\nfunction getOverlay(\n  queryClient: QueryClient,\n  projectId: string,\n  key: string,\n) {\n  const map = queryClient.getQueryData<TaskTargetOverlayMap>(\n    queryKeys.tasks.targetStateOverlay(projectId),\n  ) || {}\n  return map[key] || null\n}\n\ndescribe('task-target-overlay', () => {\n  it('creates optimistic runningTaskId when onMutate omits it', () => {\n    const queryClient = new QueryClient()\n    const projectId = 'project-1'\n    const key = 'NovelPromotionPanel:panel-1'\n\n    upsertTaskTargetOverlay(queryClient, {\n      projectId,\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      runningTaskType: 'video_panel',\n      intent: 'generate',\n    })\n\n    const overlay = getOverlay(queryClient, projectId, key)\n    expect(overlay?.runningTaskId).toMatch(/^optimistic:NovelPromotionPanel:panel-1:/)\n  })\n\n  it('does not clear overlay on completed event from a different taskId', () => {\n    const queryClient = new QueryClient()\n    const projectId = 'project-1'\n    const key = 'NovelPromotionPanel:panel-2'\n\n    upsertTaskTargetOverlay(queryClient, {\n      projectId,\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-2',\n      runningTaskId: 'task-new',\n      runningTaskType: 'video_panel',\n      intent: 'generate',\n    })\n\n    applyTaskLifecycleToOverlay(queryClient, {\n      projectId,\n      lifecycleType: TASK_EVENT_TYPE.COMPLETED,\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-2',\n      taskId: 'task-old',\n      taskType: 'video_panel',\n      intent: 'generate',\n      hasOutputAtStart: null,\n      progress: null,\n      stage: null,\n      stageLabel: null,\n      eventTs: new Date().toISOString(),\n    })\n\n    const overlay = getOverlay(queryClient, projectId, key)\n    expect(overlay?.runningTaskId).toBe('task-new')\n  })\n\n  it('clears overlay on completed event from the same taskId', () => {\n    const queryClient = new QueryClient()\n    const projectId = 'project-1'\n    const key = 'NovelPromotionPanel:panel-3'\n\n    upsertTaskTargetOverlay(queryClient, {\n      projectId,\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-3',\n      runningTaskId: 'task-3',\n      runningTaskType: 'video_panel',\n      intent: 'generate',\n    })\n\n    applyTaskLifecycleToOverlay(queryClient, {\n      projectId,\n      lifecycleType: TASK_EVENT_TYPE.COMPLETED,\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-3',\n      taskId: 'task-3',\n      taskType: 'video_panel',\n      intent: 'generate',\n      hasOutputAtStart: null,\n      progress: null,\n      stage: null,\n      stageLabel: null,\n      eventTs: new Date().toISOString(),\n    })\n\n    const overlay = getOverlay(queryClient, projectId, key)\n    expect(overlay).toBeNull()\n  })\n})\n"
  },
  {
    "path": "tests/unit/optimistic/task-target-state-map.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { TaskTargetState } from '@/lib/query/hooks/useTaskTargetStateMap'\n\nconst runtime = vi.hoisted(() => ({\n  useQueryCalls: [] as Array<Record<string, unknown>>,\n  apiStates: [] as TaskTargetState[],\n  overlayStates: {} as Record<string, {\n    targetType: string\n    targetId: string\n    phase: 'queued' | 'processing'\n    runningTaskId: string | null\n    runningTaskType: string | null\n    intent: 'generate' | 'process' | 'regenerate'\n    hasOutputAtStart: boolean | null\n    progress: number | null\n    stage: string | null\n    stageLabel: string | null\n    updatedAt: string | null\n    lastError: null\n    expiresAt: number\n  }>,\n}))\n\nconst overlayNow = new Date().toISOString()\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useMemo: <T,>(factory: () => T) => factory(),\n  }\n})\n\nvi.mock('@tanstack/react-query', () => ({\n  useQuery: (options: Record<string, unknown>) => {\n    runtime.useQueryCalls.push(options)\n\n    const queryKey = (options.queryKey || []) as unknown[]\n    const first = queryKey[0]\n    if (first === 'task-target-states-overlay') {\n      return {\n        data: runtime.overlayStates,\n      }\n    }\n\n    return {\n      data: runtime.apiStates,\n    }\n  },\n}))\n\ndescribe('task target state map behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    runtime.useQueryCalls = []\n    runtime.apiStates = [\n      {\n        targetType: 'CharacterAppearance',\n        targetId: 'appearance-1',\n        phase: 'idle',\n        runningTaskId: null,\n        runningTaskType: null,\n        intent: 'process',\n        hasOutputAtStart: null,\n        progress: null,\n        stage: null,\n        stageLabel: null,\n        lastError: null,\n        updatedAt: null,\n      },\n      {\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-1',\n        phase: 'processing',\n        runningTaskId: 'task-api-panel',\n        runningTaskType: 'IMAGE_PANEL',\n        intent: 'process',\n        hasOutputAtStart: null,\n        progress: 10,\n        stage: 'api',\n        stageLabel: 'API处理中',\n        lastError: null,\n        updatedAt: overlayNow,\n      },\n    ]\n    runtime.overlayStates = {\n      'CharacterAppearance:appearance-1': {\n        targetType: 'CharacterAppearance',\n        targetId: 'appearance-1',\n        phase: 'processing',\n        runningTaskId: 'task-ov-1',\n        runningTaskType: 'IMAGE_CHARACTER',\n        intent: 'process',\n        hasOutputAtStart: false,\n        progress: 50,\n        stage: 'generate',\n        stageLabel: '生成中',\n        updatedAt: overlayNow,\n        lastError: null,\n        expiresAt: Date.now() + 30_000,\n      },\n      'NovelPromotionPanel:panel-1': {\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-1',\n        phase: 'queued',\n        runningTaskId: 'task-ov-2',\n        runningTaskType: 'LIP_SYNC',\n        intent: 'process',\n        hasOutputAtStart: null,\n        progress: null,\n        stage: null,\n        stageLabel: null,\n        updatedAt: overlayNow,\n        lastError: null,\n        expiresAt: Date.now() + 30_000,\n      },\n    }\n  })\n\n  it('enables polling while queued/processing and merges overlay only when rules match', async () => {\n    const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')\n\n    const result = useTaskTargetStateMap('project-1', [\n      { targetType: 'CharacterAppearance', targetId: 'appearance-1', types: ['IMAGE_CHARACTER'] },\n      { targetType: 'NovelPromotionPanel', targetId: 'panel-1', types: ['IMAGE_PANEL'] },\n    ])\n\n    const firstCall = runtime.useQueryCalls[0]\n    expect(typeof firstCall?.refetchInterval).toBe('function')\n    const interval = (firstCall?.refetchInterval as ((state: { state: { data?: TaskTargetState[] } }) => number | false))({\n      state: { data: runtime.apiStates },\n    })\n    expect(interval).toBe(2000)\n\n    const appearance = result.getState('CharacterAppearance', 'appearance-1')\n    expect(appearance?.phase).toBe('processing')\n    expect(appearance?.runningTaskType).toBe('IMAGE_CHARACTER')\n    expect(appearance?.runningTaskId).toBe('task-ov-1')\n\n    const panel = result.getState('NovelPromotionPanel', 'panel-1')\n    expect(panel?.phase).toBe('processing')\n    expect(panel?.runningTaskType).toBe('IMAGE_PANEL')\n    expect(panel?.runningTaskId).toBe('task-api-panel')\n  })\n\n  it('allows newer overlay to override completed state for immediate rerun feedback', async () => {\n    runtime.apiStates = [\n      {\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-2',\n        phase: 'completed',\n        runningTaskId: null,\n        runningTaskType: null,\n        intent: 'generate',\n        hasOutputAtStart: true,\n        progress: 100,\n        stage: null,\n        stageLabel: null,\n        lastError: null,\n        updatedAt: '2026-02-27T00:00:00.000Z',\n      },\n    ]\n    runtime.overlayStates = {\n      'NovelPromotionPanel:panel-2': {\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-2',\n        phase: 'queued',\n        runningTaskId: 'task-overlay-new',\n        runningTaskType: 'VIDEO_PANEL',\n        intent: 'generate',\n        hasOutputAtStart: true,\n        progress: null,\n        stage: null,\n        stageLabel: null,\n        updatedAt: '2026-02-27T00:00:01.000Z',\n        lastError: null,\n        expiresAt: Date.now() + 30_000,\n      },\n    }\n\n    const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')\n\n    const result = useTaskTargetStateMap('project-1', [\n      { targetType: 'NovelPromotionPanel', targetId: 'panel-2', types: ['VIDEO_PANEL'] },\n    ])\n\n    const state = result.getState('NovelPromotionPanel', 'panel-2')\n    expect(state?.phase).toBe('queued')\n    expect(state?.runningTaskId).toBe('task-overlay-new')\n    expect(state?.runningTaskType).toBe('VIDEO_PANEL')\n  })\n\n  it('allows active overlay to override completed state even with timestamp skew', async () => {\n    runtime.apiStates = [\n      {\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-3',\n        phase: 'completed',\n        runningTaskId: null,\n        runningTaskType: null,\n        intent: 'generate',\n        hasOutputAtStart: true,\n        progress: 100,\n        stage: null,\n        stageLabel: null,\n        lastError: null,\n        updatedAt: '2026-02-27T00:00:05.000Z',\n      },\n    ]\n    runtime.overlayStates = {\n      'NovelPromotionPanel:panel-3': {\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-3',\n        phase: 'queued',\n        runningTaskId: 'task-overlay-old',\n        runningTaskType: 'VIDEO_PANEL',\n        intent: 'generate',\n        hasOutputAtStart: true,\n        progress: null,\n        stage: null,\n        stageLabel: null,\n        updatedAt: '2026-02-27T00:00:01.000Z',\n        lastError: null,\n        expiresAt: Date.now() + 30_000,\n      },\n    }\n\n    const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')\n\n    const result = useTaskTargetStateMap('project-1', [\n      { targetType: 'NovelPromotionPanel', targetId: 'panel-3', types: ['VIDEO_PANEL'] },\n    ])\n\n    const state = result.getState('NovelPromotionPanel', 'panel-3')\n    expect(state?.phase).toBe('queued')\n    expect(state?.runningTaskId).toBe('task-overlay-old')\n    expect(state?.runningTaskType).toBe('VIDEO_PANEL')\n  })\n\n  it('matches task type whitelist case-insensitively', async () => {\n    runtime.apiStates = [\n      {\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-4',\n        phase: 'idle',\n        runningTaskId: null,\n        runningTaskType: null,\n        intent: 'generate',\n        hasOutputAtStart: null,\n        progress: null,\n        stage: null,\n        stageLabel: null,\n        lastError: null,\n        updatedAt: null,\n      },\n    ]\n    runtime.overlayStates = {\n      'NovelPromotionPanel:panel-4': {\n        targetType: 'NovelPromotionPanel',\n        targetId: 'panel-4',\n        phase: 'processing',\n        runningTaskId: 'task-overlay-upper',\n        runningTaskType: 'VIDEO_PANEL',\n        intent: 'generate',\n        hasOutputAtStart: false,\n        progress: 15,\n        stage: 'generate_panel_video',\n        stageLabel: '生成中',\n        updatedAt: '2026-02-27T00:00:10.000Z',\n        lastError: null,\n        expiresAt: Date.now() + 30_000,\n      },\n    }\n\n    const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')\n\n    const result = useTaskTargetStateMap('project-1', [\n      { targetType: 'NovelPromotionPanel', targetId: 'panel-4', types: ['video_panel'] },\n    ])\n\n    const state = result.getState('NovelPromotionPanel', 'panel-4')\n    expect(state?.phase).toBe('processing')\n    expect(state?.runningTaskType).toBe('VIDEO_PANEL')\n    expect(state?.runningTaskId).toBe('task-overlay-upper')\n  })\n})\n"
  },
  {
    "path": "tests/unit/providers/bailian-llm.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst createChatCompletionMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'chatcmpl_bailian',\n    object: 'chat.completion',\n    created: 1,\n    model: 'qwen3.5-plus',\n    choices: [\n      {\n        index: 0,\n        message: { role: 'assistant', content: 'ok' },\n        finish_reason: 'stop',\n      },\n    ],\n    usage: {\n      prompt_tokens: 1,\n      completion_tokens: 1,\n      total_tokens: 2,\n    },\n  })),\n)\n\nconst openAiCtorMock = vi.hoisted(() =>\n  vi.fn(() => ({\n    chat: {\n      completions: {\n        create: createChatCompletionMock,\n      },\n    },\n  })),\n)\n\nvi.mock('openai', () => ({\n  default: openAiCtorMock,\n}))\n\nimport { completeBailianLlm } from '@/lib/providers/bailian/llm'\n\ndescribe('bailian llm provider', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('calls dashscope openai-compatible endpoint for registered qwen model', async () => {\n    const completion = await completeBailianLlm({\n      modelId: 'qwen3.5-plus',\n      messages: [{ role: 'user', content: 'hello' }],\n      apiKey: 'bl-key',\n      temperature: 0.2,\n    })\n\n    expect(openAiCtorMock).toHaveBeenCalledWith({\n      apiKey: 'bl-key',\n      baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n      timeout: 30_000,\n    })\n    expect(createChatCompletionMock).toHaveBeenCalledWith({\n      model: 'qwen3.5-plus',\n      messages: [{ role: 'user', content: 'hello' }],\n      temperature: 0.2,\n    })\n    expect(completion.choices[0]?.message?.content).toBe('ok')\n  })\n\n  it('fails fast when model is not in official bailian catalog', async () => {\n    await expect(\n      completeBailianLlm({\n        modelId: 'qwen-plus',\n        messages: [{ role: 'user', content: 'hello' }],\n        apiKey: 'bl-key',\n      }),\n    ).rejects.toThrow(/MODEL_NOT_REGISTERED/)\n\n    expect(openAiCtorMock).not.toHaveBeenCalled()\n    expect(createChatCompletionMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/unit/providers/bailian-tts.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { synthesizeWithBailianTTS } from '@/lib/providers/bailian/tts'\n\nfunction buildWavBuffer(durationMs: number): Buffer {\n  const sampleRate = 8000\n  const channels = 1\n  const bitsPerSample = 16\n  const byteRate = sampleRate * channels * (bitsPerSample / 8)\n  const dataLength = Math.round((durationMs / 1000) * byteRate)\n  const pcmData = Buffer.alloc(dataLength, 0)\n  const output = Buffer.alloc(44 + dataLength)\n\n  output.write('RIFF', 0, 'ascii')\n  output.writeUInt32LE(36 + dataLength, 4)\n  output.write('WAVE', 8, 'ascii')\n  output.write('fmt ', 12, 'ascii')\n  output.writeUInt32LE(16, 16)\n  output.writeUInt16LE(1, 20)\n  output.writeUInt16LE(channels, 22)\n  output.writeUInt32LE(sampleRate, 24)\n  output.writeUInt32LE(byteRate, 28)\n  output.writeUInt16LE(channels * (bitsPerSample / 8), 32)\n  output.writeUInt16LE(bitsPerSample, 34)\n  output.write('data', 36, 'ascii')\n  output.writeUInt32LE(dataLength, 40)\n  pcmData.copy(output, 44)\n\n  return output\n}\n\ndescribe('bailian tts synthesis', () => {\n  beforeEach(() => {\n    vi.restoreAllMocks()\n  })\n\n  it('synthesizes one segment and returns wav buffer', async () => {\n    const wav = buildWavBuffer(120)\n    const fetchMock = vi.fn(async (input: string) => {\n      if (input.includes('/multimodal-generation/generation')) {\n        return {\n          ok: true,\n          text: async () => JSON.stringify({\n            output: {\n              audio: {\n                url: 'https://audio.example/segment-1.wav',\n              },\n            },\n            usage: { characters: 10 },\n            request_id: 'req-1',\n          }),\n        }\n      }\n      if (input === 'https://audio.example/segment-1.wav') {\n        return {\n          ok: true,\n          status: 200,\n          arrayBuffer: async () => wav.buffer.slice(wav.byteOffset, wav.byteOffset + wav.byteLength),\n        }\n      }\n      throw new Error(`unexpected fetch url: ${input}`)\n    })\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await synthesizeWithBailianTTS({\n      text: '你好，世界',\n      voiceId: 'voice_1',\n    }, 'bl-key')\n\n    expect(result.success).toBe(true)\n    expect(result.audioData).toBeDefined()\n    expect(result.audioDuration).toBe(120)\n    expect(result.audioUrl).toBe('https://audio.example/segment-1.wav')\n    expect(result.characters).toBe(10)\n    expect(fetchMock).toHaveBeenCalledTimes(2)\n  })\n\n  it('splits text over 600 chars and merges audio segments', async () => {\n    const wavA = buildWavBuffer(100)\n    const wavB = buildWavBuffer(200)\n    let generationCallCount = 0\n    const fetchMock = vi.fn(async (input: string) => {\n      if (input.includes('/multimodal-generation/generation')) {\n        generationCallCount += 1\n        const audioUrl = generationCallCount === 1\n          ? 'https://audio.example/segment-a.wav'\n          : 'https://audio.example/segment-b.wav'\n        return {\n          ok: true,\n          text: async () => JSON.stringify({\n            output: {\n              audio: { url: audioUrl },\n            },\n            usage: { characters: 600 },\n            request_id: `req-${generationCallCount}`,\n          }),\n        }\n      }\n      if (input === 'https://audio.example/segment-a.wav') {\n        return {\n          ok: true,\n          status: 200,\n          arrayBuffer: async () => wavA.buffer.slice(wavA.byteOffset, wavA.byteOffset + wavA.byteLength),\n        }\n      }\n      if (input === 'https://audio.example/segment-b.wav') {\n        return {\n          ok: true,\n          status: 200,\n          arrayBuffer: async () => wavB.buffer.slice(wavB.byteOffset, wavB.byteOffset + wavB.byteLength),\n        }\n      }\n      throw new Error(`unexpected fetch url: ${input}`)\n    })\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await synthesizeWithBailianTTS({\n      text: 'a'.repeat(601),\n      voiceId: 'voice_2',\n    }, 'bl-key')\n\n    expect(result.success).toBe(true)\n    expect(result.audioData).toBeDefined()\n    expect(result.audioDuration).toBe(300)\n    expect(result.audioUrl).toBeUndefined()\n    expect(result.characters).toBe(1200)\n    expect(generationCallCount).toBe(2)\n  })\n\n  it('fails explicitly when voiceId is missing', async () => {\n    const fetchMock = vi.fn()\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await synthesizeWithBailianTTS({\n      text: 'hello',\n      voiceId: '',\n    }, 'bl-key')\n\n    expect(result).toEqual({\n      success: false,\n      error: 'BAILIAN_TTS_VOICE_ID_REQUIRED',\n    })\n    expect(fetchMock).not.toHaveBeenCalled()\n  })\n})\n\n"
  },
  {
    "path": "tests/unit/providers/bailian-video.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst getProviderConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'bailian',\n    apiKey: 'bl-key',\n  })),\n)\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n}))\n\nimport { generateBailianVideo } from '@/lib/providers/bailian/video'\n\ndescribe('bailian video provider', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('submits i2v task and returns async externalId', async () => {\n    const fetchMock = vi.fn(async () => ({\n      ok: true,\n      status: 200,\n      text: async () => JSON.stringify({\n        request_id: 'req-1',\n        output: {\n          task_id: 'task-123',\n          task_status: 'PENDING',\n        },\n      }),\n    }))\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await generateBailianVideo({\n      userId: 'user-1',\n      imageUrl: 'https://example.com/frame.png',\n      prompt: '让人物向前走',\n      options: {\n        provider: 'bailian',\n        modelId: 'wan2.6-i2v-flash',\n        modelKey: 'bailian::wan2.6-i2v-flash',\n        duration: 5,\n        resolution: '720P',\n        promptExtend: true,\n      },\n    })\n\n    expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')\n    expect(fetchMock).toHaveBeenCalledTimes(1)\n    const firstCall = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit] | undefined\n    expect(firstCall).toBeDefined()\n    if (!firstCall) {\n      throw new Error('missing fetch call')\n    }\n    const requestUrl = firstCall[0]\n    const requestInit = firstCall[1]\n    expect(requestUrl).toBe('https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis')\n    expect(requestInit.method).toBe('POST')\n    expect(requestInit.headers).toEqual({\n      Authorization: 'Bearer bl-key',\n      'Content-Type': 'application/json',\n      'X-DashScope-Async': 'enable',\n    })\n    expect(requestInit.body).toBe(JSON.stringify({\n      model: 'wan2.6-i2v-flash',\n      input: {\n        img_url: 'https://example.com/frame.png',\n        prompt: '让人物向前走',\n      },\n      parameters: {\n        resolution: '720P',\n        prompt_extend: true,\n        duration: 5,\n      },\n    }))\n    expect(result).toEqual({\n      success: true,\n      async: true,\n      requestId: 'task-123',\n      externalId: 'BAILIAN:VIDEO:task-123',\n    })\n  })\n\n  it('submits kf2v task with first and last frame', async () => {\n    const fetchMock = vi.fn(async () => ({\n      ok: true,\n      status: 200,\n      text: async () => JSON.stringify({\n        output: {\n          task_id: 'task-kf2v-1',\n          task_status: 'PENDING',\n        },\n      }),\n    }))\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await generateBailianVideo({\n      userId: 'user-1',\n      imageUrl: 'https://example.com/first.png',\n      prompt: '让人物从左走到右',\n      options: {\n        provider: 'bailian',\n        modelId: 'wan2.2-kf2v-flash',\n        modelKey: 'bailian::wan2.2-kf2v-flash',\n        lastFrameImageUrl: 'https://example.com/last.png',\n        duration: 5,\n      },\n    })\n\n    expect(fetchMock).toHaveBeenCalledTimes(1)\n    const firstCall = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit] | undefined\n    expect(firstCall).toBeDefined()\n    if (!firstCall) {\n      throw new Error('missing fetch call')\n    }\n    const requestUrl = firstCall[0]\n    const requestInit = firstCall[1]\n    expect(requestUrl).toBe('https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis')\n    expect(requestInit.body).toBe(JSON.stringify({\n      model: 'wan2.2-kf2v-flash',\n      input: {\n        first_frame_url: 'https://example.com/first.png',\n        last_frame_url: 'https://example.com/last.png',\n        prompt: '让人物从左走到右',\n      },\n      parameters: {\n        duration: 5,\n      },\n    }))\n    expect(result).toEqual({\n      success: true,\n      async: true,\n      requestId: 'task-kf2v-1',\n      externalId: 'BAILIAN:VIDEO:task-kf2v-1',\n    })\n  })\n\n  it('fails fast when kf2v model misses last frame', async () => {\n    const fetchMock = vi.fn()\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    await expect(\n      generateBailianVideo({\n        userId: 'user-1',\n        imageUrl: 'https://example.com/first.png',\n        prompt: 'test',\n        options: {\n          provider: 'bailian',\n          modelId: 'wanx2.1-kf2v-plus',\n          modelKey: 'bailian::wanx2.1-kf2v-plus',\n        },\n      }),\n    ).rejects.toThrow(/BAILIAN_VIDEO_LAST_FRAME_IMAGE_URL_REQUIRED/)\n\n    expect(fetchMock).not.toHaveBeenCalled()\n  })\n\n  it('fails fast when options contain unsupported field', async () => {\n    const fetchMock = vi.fn()\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    await expect(\n      generateBailianVideo({\n        userId: 'user-1',\n        imageUrl: 'https://example.com/frame.png',\n        prompt: 'test',\n        options: {\n          provider: 'bailian',\n          modelId: 'wan2.6-i2v',\n          modelKey: 'bailian::wan2.6-i2v',\n          fps: 24,\n        },\n      }),\n    ).rejects.toThrow(/BAILIAN_VIDEO_OPTION_UNSUPPORTED: fps/)\n\n    expect(fetchMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/unit/providers/bailian-voice-cleanup.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionProject: {\n    findUnique: vi.fn(),\n  },\n  novelPromotionCharacter: {\n    findMany: vi.fn(),\n  },\n  globalCharacter: {\n    findMany: vi.fn(),\n  },\n  globalVoice: {\n    findMany: vi.fn(),\n  },\n  novelPromotionEpisode: {\n    findMany: vi.fn(),\n  },\n}))\n\nconst getProviderConfigMock = vi.hoisted(() => vi.fn())\nconst deleteBailianVoiceMock = vi.hoisted(() => vi.fn())\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n}))\n\nvi.mock('@/lib/providers/bailian/voice-manage', () => ({\n  deleteBailianVoice: deleteBailianVoiceMock,\n}))\n\nimport {\n  collectBailianManagedVoiceIds,\n  collectProjectBailianManagedVoiceIds,\n  cleanupUnreferencedBailianVoices,\n  isBailianManagedVoiceBinding,\n} from '@/lib/providers/bailian/voice-cleanup'\n\ndescribe('bailian voice cleanup', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    prismaMock.novelPromotionCharacter.findMany.mockResolvedValue([])\n    prismaMock.globalCharacter.findMany.mockResolvedValue([])\n    prismaMock.globalVoice.findMany.mockResolvedValue([])\n    prismaMock.novelPromotionEpisode.findMany.mockResolvedValue([])\n    getProviderConfigMock.mockResolvedValue({\n      apiKey: 'bl-key',\n    })\n    deleteBailianVoiceMock.mockResolvedValue({ requestId: 'req-1' })\n  })\n\n  it('identifies managed voice bindings by voiceType or id prefix', () => {\n    expect(isBailianManagedVoiceBinding({ voiceType: 'qwen-designed', voiceId: 'voice-a' })).toBe(true)\n    expect(isBailianManagedVoiceBinding({ voiceType: 'uploaded', voiceId: 'qwen-tts-vd-voice-b' })).toBe(true)\n    expect(isBailianManagedVoiceBinding({ voiceType: 'uploaded', voiceId: 'custom-voice-b' })).toBe(false)\n  })\n\n  it('collects and deduplicates managed voice ids', () => {\n    const voiceIds = collectBailianManagedVoiceIds([\n      { voiceType: 'qwen-designed', voiceId: 'qwen-tts-vd-1' },\n      { voiceType: 'qwen-designed', voiceId: 'qwen-tts-vd-1' },\n      { voiceType: 'uploaded', voiceId: 'custom-1' },\n      { voiceType: null, voiceId: 'qwen-tts-vd-2' },\n    ])\n\n    expect(voiceIds).toEqual(['qwen-tts-vd-1', 'qwen-tts-vd-2'])\n  })\n\n  it('deletes only unreferenced managed voices', async () => {\n    prismaMock.globalVoice.findMany.mockResolvedValue([\n      { voiceId: 'qwen-tts-vd-1' },\n    ])\n\n    const result = await cleanupUnreferencedBailianVoices({\n      voiceIds: ['qwen-tts-vd-1', 'qwen-tts-vd-2'],\n      scope: {\n        userId: 'user-1',\n      },\n    })\n\n    expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')\n    expect(deleteBailianVoiceMock).toHaveBeenCalledTimes(1)\n    expect(deleteBailianVoiceMock).toHaveBeenCalledWith({\n      apiKey: 'bl-key',\n      voiceId: 'qwen-tts-vd-2',\n    })\n    expect(result).toEqual({\n      requestedVoiceIds: ['qwen-tts-vd-1', 'qwen-tts-vd-2'],\n      skippedReferencedVoiceIds: ['qwen-tts-vd-1'],\n      deletedVoiceIds: ['qwen-tts-vd-2'],\n    })\n  })\n\n  it('collects managed voice ids from project characters and speaker voices', async () => {\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValue({\n      characters: [\n        { voiceId: 'qwen-tts-vd-character', voiceType: 'qwen-designed' },\n        { voiceId: 'plain-custom', voiceType: 'uploaded' },\n      ],\n      episodes: [\n        {\n          speakerVoices: JSON.stringify({\n            Narrator: { voiceType: 'qwen-designed', voiceId: 'qwen-tts-vd-inline' },\n            Guest: { voiceType: 'uploaded', voiceId: 'uploaded-id' },\n          }),\n        },\n      ],\n    })\n\n    const voiceIds = await collectProjectBailianManagedVoiceIds('project-1')\n    expect(voiceIds).toEqual(['qwen-tts-vd-character', 'qwen-tts-vd-inline'])\n  })\n})\n\n"
  },
  {
    "path": "tests/unit/providers/bailian-voice-design.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { createVoiceDesign } from '@/lib/providers/bailian/voice-design'\n\ndescribe('bailian voice design', () => {\n  beforeEach(() => {\n    vi.restoreAllMocks()\n  })\n\n  it('uses qwen3-tts-vd-2026-01-26 as target model', async () => {\n    const fetchMock = vi.fn(async (_input: unknown, _init?: unknown) => ({\n      ok: true,\n      json: async () => ({\n        output: {\n          voice: 'voice_1',\n          target_model: 'qwen3-tts-vd-2026-01-26',\n          preview_audio: {\n            data: 'base64',\n            sample_rate: 24000,\n            response_format: 'wav',\n          },\n        },\n        usage: { count: 1 },\n        request_id: 'req-1',\n      }),\n      text: async () => '',\n      status: 200,\n      headers: new Headers(),\n      redirected: false,\n      type: 'basic',\n      url: '',\n      bodyUsed: false,\n      clone: () => undefined as unknown as Response,\n      body: null,\n      arrayBuffer: async () => new ArrayBuffer(0),\n      blob: async () => new Blob(),\n      formData: async () => new FormData(),\n    }))\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    await createVoiceDesign({\n      voicePrompt: '成熟稳重男声',\n      previewText: '你好测试',\n    }, 'bl-key')\n\n    const firstCall = fetchMock.mock.calls[0] as [unknown, RequestInit?] | undefined\n    const requestBodyRaw = firstCall?.[1]?.body\n    expect(typeof requestBodyRaw).toBe('string')\n    const requestBody = JSON.parse(requestBodyRaw as string) as {\n      input?: { target_model?: string }\n    }\n    expect(requestBody.input?.target_model).toBe('qwen3-tts-vd-2026-01-26')\n  })\n})\n"
  },
  {
    "path": "tests/unit/providers/model-registry.test.ts",
    "content": "import { beforeEach, describe, expect, it } from 'vitest'\nimport {\n  assertOfficialModelRegistered,\n  isOfficialModelRegistered,\n  registerOfficialModel,\n  resetOfficialModelRegistryForTest,\n} from '@/lib/providers/official/model-registry'\n\ndescribe('official model registry', () => {\n  beforeEach(() => {\n    resetOfficialModelRegistryForTest()\n  })\n\n  it('throws MODEL_NOT_REGISTERED when model is absent', () => {\n    expect(() =>\n      assertOfficialModelRegistered({\n        provider: 'bailian',\n        modality: 'llm',\n        modelId: 'qwen-plus',\n      }),\n    ).toThrow(/MODEL_NOT_REGISTERED/)\n  })\n\n  it('accepts registered official model', () => {\n    registerOfficialModel({\n      provider: 'siliconflow',\n      modality: 'image',\n      modelId: 'sf-image',\n    })\n\n    expect(\n      isOfficialModelRegistered({\n        provider: 'siliconflow',\n        modality: 'image',\n        modelId: 'sf-image',\n      }),\n    ).toBe(true)\n  })\n})\n"
  },
  {
    "path": "tests/unit/query/project-location-generate-body.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { buildProjectLocationGenerateImageBody } from '@/lib/query/mutations/location-image-mutations'\n\ndescribe('buildProjectLocationGenerateImageBody', () => {\n  it('includes artStyle when generating a project location image', () => {\n    expect(buildProjectLocationGenerateImageBody({\n      locationId: 'location-1',\n      count: 1,\n      artStyle: 'japanese-anime',\n    })).toEqual({\n      type: 'location',\n      id: 'location-1',\n      imageIndex: undefined,\n      count: 1,\n      artStyle: 'japanese-anime',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/run-runtime/task-bridge.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { mapTaskSSEEventToRunEvents } from '@/lib/run-runtime/task-bridge'\nimport { RUN_EVENT_TYPE } from '@/lib/run-runtime/types'\nimport { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types'\n\nfunction buildEvent(input: Partial<SSEEvent>): SSEEvent {\n  return {\n    id: input.id || '1',\n    type: input.type || TASK_SSE_EVENT_TYPE.LIFECYCLE,\n    taskId: input.taskId || 'task_1',\n    projectId: input.projectId || 'project_1',\n    userId: input.userId || 'user_1',\n    ts: input.ts || new Date().toISOString(),\n    payload: input.payload || {},\n    taskType: input.taskType || null,\n    targetType: input.targetType || null,\n    targetId: input.targetId || null,\n    episodeId: input.episodeId || null,\n  }\n}\n\ndescribe('task->run event bridge', () => {\n  it('maps task.stream to step.chunk and normalizes lane by kind', () => {\n    const event = buildEvent({\n      type: TASK_SSE_EVENT_TYPE.STREAM,\n      payload: {\n        runId: 'run_1',\n        stepId: 'step_a',\n        stream: {\n          kind: 'reasoning',\n          delta: 'abc',\n          seq: 1,\n        },\n      },\n    })\n\n    const mapped = mapTaskSSEEventToRunEvents(event)\n    expect(mapped).toHaveLength(1)\n    expect(mapped[0]).toMatchObject({\n      runId: 'run_1',\n      eventType: RUN_EVENT_TYPE.STEP_CHUNK,\n      stepKey: 'step_a',\n      lane: 'reasoning',\n    })\n  })\n\n  it('uses taskType-based fallback stepKey for stream when stepId missing', () => {\n    const event = buildEvent({\n      type: TASK_SSE_EVENT_TYPE.STREAM,\n      taskType: 'story_to_script_run',\n      payload: {\n        runId: 'run_1',\n        stream: {\n          kind: 'text',\n          delta: 'hello',\n          seq: 1,\n        },\n      },\n    })\n\n    const mapped = mapTaskSSEEventToRunEvents(event)\n    expect(mapped).toHaveLength(1)\n    expect(mapped[0]).toMatchObject({\n      eventType: RUN_EVENT_TYPE.STEP_CHUNK,\n      stepKey: 'step:story_to_script_run',\n      lane: 'text',\n    })\n  })\n\n  it('maps task.processing + done=true to step.start then step.complete', () => {\n    const event = buildEvent({\n      payload: {\n        runId: 'run_2',\n        stepId: 'step_b',\n        lifecycleType: TASK_EVENT_TYPE.PROCESSING,\n        done: true,\n      },\n    })\n\n    const mapped = mapTaskSSEEventToRunEvents(event)\n    expect(mapped).toHaveLength(2)\n    expect(mapped[0]?.eventType).toBe(RUN_EVENT_TYPE.STEP_START)\n    expect(mapped[1]?.eventType).toBe(RUN_EVENT_TYPE.STEP_COMPLETE)\n  })\n\n  it('maps processing error stage to step.error', () => {\n    const event = buildEvent({\n      payload: {\n        meta: { runId: 'run_3' },\n        stepId: 'step_c',\n        lifecycleType: TASK_EVENT_TYPE.PROCESSING,\n        stage: 'worker_llm_error',\n        error: {\n          message: 'boom',\n        },\n      },\n    })\n\n    const mapped = mapTaskSSEEventToRunEvents(event)\n    expect(mapped).toHaveLength(2)\n    expect(mapped[0]?.eventType).toBe(RUN_EVENT_TYPE.STEP_START)\n    expect(mapped[1]).toMatchObject({\n      eventType: RUN_EVENT_TYPE.STEP_ERROR,\n      runId: 'run_3',\n      stepKey: 'step_c',\n    })\n  })\n\n  it('maps task.completed to step.complete and run.complete', () => {\n    const event = buildEvent({\n      payload: {\n        runId: 'run_4',\n        stepId: 'step_d',\n        lifecycleType: TASK_EVENT_TYPE.COMPLETED,\n      },\n    })\n\n    const mapped = mapTaskSSEEventToRunEvents(event)\n    expect(mapped).toHaveLength(2)\n    expect(mapped[0]?.eventType).toBe(RUN_EVENT_TYPE.STEP_COMPLETE)\n    expect(mapped[1]?.eventType).toBe(RUN_EVENT_TYPE.RUN_COMPLETE)\n  })\n\n  it('returns empty when runId is missing', () => {\n    const event = buildEvent({\n      payload: {\n        stepId: 'step_x',\n        lifecycleType: TASK_EVENT_TYPE.PROCESSING,\n      },\n    })\n    expect(mapTaskSSEEventToRunEvents(event)).toEqual([])\n  })\n})\n"
  },
  {
    "path": "tests/unit/storage/bootstrap.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { ensureStorageReady } from '@/lib/storage/bootstrap'\n\ntype MockCommand = {\n  readonly type: 'HeadBucketCommand' | 'CreateBucketCommand'\n  readonly input: Record<string, unknown>\n}\n\nconst {\n  sendMock,\n  s3ClientMock,\n  headBucketCommandMock,\n  createBucketCommandMock,\n} = vi.hoisted(() => ({\n  sendMock: vi.fn<(command: MockCommand) => Promise<unknown>>(),\n  s3ClientMock: vi.fn(() => ({ send: undefined as unknown })),\n  headBucketCommandMock: vi.fn((input: Record<string, unknown>): MockCommand => ({\n    type: 'HeadBucketCommand',\n    input,\n  })),\n  createBucketCommandMock: vi.fn((input: Record<string, unknown>): MockCommand => ({\n    type: 'CreateBucketCommand',\n    input,\n  })),\n}))\n\ns3ClientMock.mockImplementation(() => ({ send: sendMock }))\n\nvi.mock('@aws-sdk/client-s3', () => ({\n  S3Client: s3ClientMock,\n  HeadBucketCommand: headBucketCommandMock,\n  CreateBucketCommand: createBucketCommandMock,\n}))\n\ndescribe('storage bootstrap', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    process.env.MINIO_ENDPOINT = 'http://127.0.0.1:9000'\n    process.env.MINIO_REGION = 'us-east-1'\n    process.env.MINIO_BUCKET = 'waoowaoo'\n    process.env.MINIO_ACCESS_KEY = 'minioadmin'\n    process.env.MINIO_SECRET_KEY = 'minioadmin'\n    process.env.MINIO_FORCE_PATH_STYLE = 'true'\n  })\n\n  it('skips startup storage initialization when STORAGE_TYPE=local', async () => {\n    await expect(ensureStorageReady({ storageType: 'local' })).resolves.toBe('skipped')\n    expect(s3ClientMock).not.toHaveBeenCalled()\n  })\n\n  it('verifies the MinIO bucket during startup when it already exists', async () => {\n    sendMock.mockResolvedValueOnce({})\n\n    await expect(ensureStorageReady({ storageType: 'minio' })).resolves.toBe('existing')\n\n    expect(s3ClientMock).toHaveBeenCalledWith({\n      endpoint: 'http://127.0.0.1:9000',\n      region: 'us-east-1',\n      forcePathStyle: true,\n      credentials: {\n        accessKeyId: 'minioadmin',\n        secretAccessKey: 'minioadmin',\n      },\n    })\n    expect(headBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })\n    expect(createBucketCommandMock).not.toHaveBeenCalled()\n  })\n\n  it('creates the MinIO bucket during startup when HeadBucket reports it missing', async () => {\n    sendMock\n      .mockRejectedValueOnce(Object.assign(new Error('missing bucket'), {\n        name: 'NotFound',\n        $metadata: { httpStatusCode: 404 },\n      }))\n      .mockResolvedValueOnce({})\n\n    await expect(ensureStorageReady({ storageType: 'minio' })).resolves.toBe('created')\n\n    expect(headBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })\n    expect(createBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })\n    expect(sendMock).toHaveBeenNthCalledWith(2, {\n      type: 'CreateBucketCommand',\n      input: { Bucket: 'waoowaoo' },\n    })\n  })\n\n  it('fails fast when MinIO returns a non-bucket-missing error at startup', async () => {\n    const startupError = Object.assign(new Error('MinIO unavailable'), {\n      name: 'TimeoutError',\n      $metadata: { httpStatusCode: 503 },\n    })\n    sendMock.mockRejectedValueOnce(startupError)\n\n    await expect(ensureStorageReady({ storageType: 'minio' })).rejects.toBe(startupError)\n    expect(createBucketCommandMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/unit/storage/factory.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { createStorageProvider } from '@/lib/storage/factory'\nimport { StorageConfigError, StorageProviderNotImplementedError } from '@/lib/storage/errors'\n\ndescribe('storage factory', () => {\n  it('creates local provider when STORAGE_TYPE=local', () => {\n    const provider = createStorageProvider({ storageType: 'local' })\n    expect(provider.kind).toBe('local')\n  })\n\n  it('creates minio provider when STORAGE_TYPE=minio', () => {\n    process.env.MINIO_ENDPOINT = 'http://127.0.0.1:9000'\n    process.env.MINIO_REGION = 'us-east-1'\n    process.env.MINIO_BUCKET = 'waoowaoo'\n    process.env.MINIO_ACCESS_KEY = 'minioadmin'\n    process.env.MINIO_SECRET_KEY = 'minioadmin'\n    process.env.MINIO_FORCE_PATH_STYLE = 'true'\n\n    const provider = createStorageProvider({ storageType: 'minio' })\n    expect(provider.kind).toBe('minio')\n  })\n\n  it('throws explicit not-implemented error when STORAGE_TYPE=cos', () => {\n    expect(() => createStorageProvider({ storageType: 'cos' })).toThrow(StorageProviderNotImplementedError)\n  })\n\n  it('throws config error on unknown storage type', () => {\n    expect(() => createStorageProvider({ storageType: 'unknown' })).toThrow(StorageConfigError)\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/async-poll-bailian.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst getProviderConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    id: 'bailian',\n    apiKey: 'bl-key',\n  })),\n)\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n}))\n\nvi.mock('@/lib/async-submit', () => ({\n  queryFalStatus: vi.fn(),\n}))\n\nvi.mock('@/lib/async-task-utils', () => ({\n  queryGeminiBatchStatus: vi.fn(),\n  queryGoogleVideoStatus: vi.fn(),\n  querySeedanceVideoStatus: vi.fn(),\n}))\n\nimport { pollAsyncTask } from '@/lib/async-poll'\n\ndescribe('async poll bailian task', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('returns pending when task is running', async () => {\n    const fetchMock = vi.fn(async () => ({\n      ok: true,\n      status: 200,\n      text: async () => JSON.stringify({\n        output: {\n          task_status: 'RUNNING',\n        },\n      }),\n    }))\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await pollAsyncTask('BAILIAN:VIDEO:task-running', 'user-1')\n\n    expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')\n    expect(fetchMock).toHaveBeenCalledWith(\n      'https://dashscope.aliyuncs.com/api/v1/tasks/task-running',\n      {\n        headers: {\n          Authorization: 'Bearer bl-key',\n        },\n      },\n    )\n    expect(result).toEqual({ status: 'pending' })\n  })\n\n  it('returns completed with video url when task succeeded', async () => {\n    const fetchMock = vi.fn(async () => ({\n      ok: true,\n      status: 200,\n      text: async () => JSON.stringify({\n        output: {\n          task_status: 'SUCCEEDED',\n          video_url: 'https://video.example/result.mp4',\n        },\n      }),\n    }))\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await pollAsyncTask('BAILIAN:VIDEO:task-success', 'user-1')\n\n    expect(result).toEqual({\n      status: 'completed',\n      resultUrl: 'https://video.example/result.mp4',\n      videoUrl: 'https://video.example/result.mp4',\n      imageUrl: undefined,\n    })\n  })\n\n  it('returns failed when task succeeded but no media url', async () => {\n    const fetchMock = vi.fn(async () => ({\n      ok: true,\n      status: 200,\n      text: async () => JSON.stringify({\n        output: {\n          task_status: 'SUCCEEDED',\n        },\n      }),\n    }))\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await pollAsyncTask('BAILIAN:VIDEO:task-no-url', 'user-1')\n\n    expect(result).toEqual({\n      status: 'failed',\n      error: 'Bailian: 任务完成但未返回结果URL',\n    })\n  })\n\n  it('returns output code/message when task failed', async () => {\n    const fetchMock = vi.fn(async () => ({\n      ok: true,\n      status: 200,\n      text: async () => JSON.stringify({\n        output: {\n          task_status: 'FAILED',\n          code: 'InternalError.DownloadException',\n          message: 'Unknown error occurred while downloading the file.',\n        },\n      }),\n    }))\n    vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)\n\n    const result = await pollAsyncTask('BAILIAN:VIDEO:task-failed', 'user-1')\n\n    expect(result).toEqual({\n      status: 'failed',\n      error: 'Bailian: InternalError.DownloadException',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/async-poll-external-id.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { formatExternalId, parseExternalId } from '@/lib/async-poll'\n\ndescribe('async poll externalId contract', () => {\n  it('parses standard FAL externalId with endpoint', () => {\n    const parsed = parseExternalId('FAL:VIDEO:fal-ai/wan/v2.6/image-to-video:req_123')\n    expect(parsed.provider).toBe('FAL')\n    expect(parsed.type).toBe('VIDEO')\n    expect(parsed.endpoint).toBe('fal-ai/wan/v2.6/image-to-video')\n    expect(parsed.requestId).toBe('req_123')\n  })\n\n  it('rejects legacy non-standard externalId formats', () => {\n    expect(() => parseExternalId('FAL:fal-ai/wan/v2.6/image-to-video:req_123')).toThrow(/无效 FAL externalId/)\n    expect(() => parseExternalId('batches/legacy')).toThrow(/无法识别的 externalId 格式/)\n  })\n\n  it('requires endpoint when formatting FAL externalId', () => {\n    expect(() => formatExternalId('FAL', 'VIDEO', 'req_123')).toThrow(/requires endpoint/)\n  })\n\n  it('parses OPENAI video externalId with provider token', () => {\n    const parsed = parseExternalId('OPENAI:VIDEO:b3BlbmFpLWNvbXBhdGlibGU6b2EtMQ:vid_123')\n    expect(parsed.provider).toBe('OPENAI')\n    expect(parsed.type).toBe('VIDEO')\n    expect(parsed.providerToken).toBe('b3BlbmFpLWNvbXBhdGlibGU6b2EtMQ')\n    expect(parsed.requestId).toBe('vid_123')\n  })\n\n  it('requires provider token when formatting OPENAI externalId', () => {\n    expect(() => formatExternalId('OPENAI', 'VIDEO', 'vid_123')).toThrow(/providerToken/)\n  })\n\n  it('parses and formats BAILIAN externalId', () => {\n    const externalId = formatExternalId('BAILIAN', 'VIDEO', 'task_123')\n    expect(externalId).toBe('BAILIAN:VIDEO:task_123')\n\n    const parsed = parseExternalId(externalId)\n    expect(parsed.provider).toBe('BAILIAN')\n    expect(parsed.type).toBe('VIDEO')\n    expect(parsed.requestId).toBe('task_123')\n  })\n\n  it('parses and formats SILICONFLOW externalId', () => {\n    const externalId = formatExternalId('SILICONFLOW', 'IMAGE', 'task_456')\n    expect(externalId).toBe('SILICONFLOW:IMAGE:task_456')\n\n    const parsed = parseExternalId(externalId)\n    expect(parsed.provider).toBe('SILICONFLOW')\n    expect(parsed.type).toBe('IMAGE')\n    expect(parsed.requestId).toBe('task_456')\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/async-poll-openai.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({\n  id: 'openai-compatible:oa-1',\n  apiKey: 'oa-key',\n  baseUrl: 'https://oa.test/v1',\n})))\n\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: getProviderConfigMock,\n}))\n\nimport { pollAsyncTask } from '@/lib/async-poll'\n\nconst PROVIDER_TOKEN = Buffer.from('openai-compatible:oa-1', 'utf8').toString('base64url')\n\n/**\n * pollOpenAIVideoTask now uses raw fetch (not OpenAI SDK),\n * so we mock fetch instead of the SDK.\n */\ndescribe('async poll OPENAI video status mapping', () => {\n  let fetchSpy: ReturnType<typeof vi.fn>\n\n  beforeEach(() => {\n    vi.clearAllMocks()\n    getProviderConfigMock.mockResolvedValue({\n      id: 'openai-compatible:oa-1',\n      apiKey: 'oa-key',\n      baseUrl: 'https://oa.test/v1',\n    })\n    fetchSpy = vi.fn()\n    globalThis.fetch = fetchSpy as unknown as typeof fetch\n  })\n\n  it('maps queued/in_progress to pending', async () => {\n    fetchSpy\n      .mockResolvedValueOnce({\n        ok: true,\n        json: async () => ({ status: 'queued' }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        json: async () => ({ status: 'in_progress' }),\n      })\n\n    const queued = await pollAsyncTask(`OPENAI:VIDEO:${PROVIDER_TOKEN}:vid_queued`, 'user-1')\n    const progress = await pollAsyncTask(`OPENAI:VIDEO:${PROVIDER_TOKEN}:vid_running`, 'user-1')\n\n    expect(queued).toEqual({ status: 'pending' })\n    expect(progress).toEqual({ status: 'pending' })\n  })\n\n  it('maps completed to downloadable url and auth headers', async () => {\n    fetchSpy.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({\n        id: 'vid_done',\n        status: 'completed',\n      }),\n    })\n\n    const result = await pollAsyncTask(`OPENAI:VIDEO:${PROVIDER_TOKEN}:vid_done`, 'user-1')\n\n    expect(result.status).toBe('completed')\n    expect(result.resultUrl).toBe('https://oa.test/v1/videos/vid_done/content')\n    expect(result.videoUrl).toBe('https://oa.test/v1/videos/vid_done/content')\n    expect(result.downloadHeaders).toEqual({\n      Authorization: 'Bearer oa-key',\n    })\n  })\n\n  it('maps failed to failed with provider error message', async () => {\n    fetchSpy.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({\n        id: 'vid_failed',\n        status: 'failed',\n        error: { message: 'generation failed' },\n      }),\n    })\n\n    const result = await pollAsyncTask(`OPENAI:VIDEO:${PROVIDER_TOKEN}:vid_failed`, 'user-1')\n    expect(result).toEqual({ status: 'failed', error: 'generation failed' })\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/error-catalog.contract.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { ERROR_CATALOG, ERROR_CATEGORY, getErrorSpec } from '@/lib/errors/codes'\n\ndescribe('error catalog contract', () => {\n  it('keeps every catalog entry self-consistent and reachable through getErrorSpec', () => {\n    const seenMessageKeys = new Set<string>()\n\n    for (const [code, spec] of Object.entries(ERROR_CATALOG)) {\n      expect(getErrorSpec(code as keyof typeof ERROR_CATALOG)).toEqual(spec)\n      expect(spec.defaultMessage.trim().length).toBeGreaterThan(0)\n      expect(spec.userMessageKey.trim().length).toBeGreaterThan(0)\n      expect(spec.httpStatus).toBeGreaterThanOrEqual(200)\n      expect(spec.httpStatus).toBeLessThan(600)\n      expect(Object.values(ERROR_CATEGORY)).toContain(spec.category)\n      expect(seenMessageKeys.has(spec.userMessageKey)).toBe(false)\n      seenMessageKeys.add(spec.userMessageKey)\n    }\n  })\n\n  it('keeps retryable provider/system errors out of 4xx except 429 and 202', () => {\n    for (const spec of Object.values(ERROR_CATALOG)) {\n      if (!spec.retryable) continue\n      if (spec.httpStatus >= 500) continue\n      expect([202, 429]).toContain(spec.httpStatus)\n    }\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/error-message.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { resolveTaskErrorMessage, resolveTaskErrorSummary } from '@/lib/task/error-message'\n\ndescribe('task error message normalization', () => {\n  it('maps TASK_CANCELLED to unified cancelled message', () => {\n    const summary = resolveTaskErrorSummary({\n      errorCode: 'TASK_CANCELLED',\n      errorMessage: 'whatever',\n    })\n    expect(summary.cancelled).toBe(true)\n    expect(summary.code).toBe('CONFLICT')\n    expect(summary.message).toBe('Task cancelled by user')\n  })\n\n  it('keeps cancelled semantics from normalized task error details', () => {\n    const summary = resolveTaskErrorSummary({\n      error: {\n        code: 'CONFLICT',\n        message: 'Task cancelled by user',\n        details: { cancelled: true, originalCode: 'TASK_CANCELLED' },\n      },\n    })\n    expect(summary.cancelled).toBe(true)\n    expect(summary.code).toBe('CONFLICT')\n    expect(summary.message).toBe('Task cancelled by user')\n  })\n\n  it('extracts nested error message from payload', () => {\n    const message = resolveTaskErrorMessage({\n      error: {\n        details: {\n          message: 'provider failed',\n        },\n      },\n    }, 'fallback')\n    expect(message).toBe('provider failed')\n  })\n\n  it('supports flat error/details string payload', () => {\n    expect(resolveTaskErrorMessage({\n      error: 'provider failed',\n    }, 'fallback')).toBe('provider failed')\n\n    expect(resolveTaskErrorMessage({\n      details: 'provider failed',\n    }, 'fallback')).toBe('provider failed')\n  })\n\n  it('uses fallback when payload has no structured error', () => {\n    expect(resolveTaskErrorMessage({}, 'fallback')).toBe('fallback')\n  })\n\n  it('recognizes cancelled semantics from message-only payload', () => {\n    const summary = resolveTaskErrorSummary({\n      message: 'Task cancelled by user',\n    })\n    expect(summary.cancelled).toBe(true)\n    expect(summary.message).toBe('Task cancelled by user')\n  })\n\n  it('prefers user-friendly message for MODEL_NOT_OPEN', () => {\n    const summary = resolveTaskErrorSummary({\n      error: {\n        code: 'MODEL_NOT_OPEN',\n        message: 'raw provider message should not be shown',\n      },\n    })\n    expect(summary.code).toBe('MODEL_NOT_OPEN')\n    expect(summary.message).toContain('模型权限未开通')\n    expect(summary.message).toContain('https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model')\n  })\n\n  it('prefers user-friendly message for EMPTY_RESPONSE', () => {\n    const summary = resolveTaskErrorSummary({\n      error: {\n        code: 'EMPTY_RESPONSE',\n        message: 'raw provider empty response',\n      },\n    })\n    expect(summary.code).toBe('EMPTY_RESPONSE')\n    expect(summary.message).toContain('模型返回空响应')\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/intent.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { TASK_TYPE } from '@/lib/task/types'\nimport { resolveTaskIntent } from '@/lib/task/intent'\n\ndescribe('resolveTaskIntent', () => {\n  it('maps generate task types', () => {\n    expect(resolveTaskIntent(TASK_TYPE.IMAGE_CHARACTER)).toBe('generate')\n    expect(resolveTaskIntent(TASK_TYPE.IMAGE_LOCATION)).toBe('generate')\n    expect(resolveTaskIntent(TASK_TYPE.VIDEO_PANEL)).toBe('generate')\n  })\n\n  it('maps regenerate and modify task types', () => {\n    expect(resolveTaskIntent(TASK_TYPE.REGENERATE_GROUP)).toBe('regenerate')\n    expect(resolveTaskIntent(TASK_TYPE.PANEL_VARIANT)).toBe('regenerate')\n    expect(resolveTaskIntent(TASK_TYPE.MODIFY_ASSET_IMAGE)).toBe('modify')\n  })\n\n  it('falls back to process for unknown types', () => {\n    expect(resolveTaskIntent('unknown_type')).toBe('process')\n    expect(resolveTaskIntent(null)).toBe('process')\n    expect(resolveTaskIntent(undefined)).toBe('process')\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/llm-observe-contract.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { getTaskFlowMeta, getTaskPipeline } from '@/lib/llm-observe/stage-pipeline'\nimport { getLLMTaskPolicy } from '@/lib/llm-observe/task-policy'\nimport { TASK_TYPE } from '@/lib/task/types'\n\ndescribe('llm observe task contract', () => {\n  it('maps AI_CREATE tasks to standard llm policy', () => {\n    const characterPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_CHARACTER)\n    const locationPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_LOCATION)\n\n    expect(characterPolicy.consoleEnabled).toBe(true)\n    expect(characterPolicy.displayMode).toBe('loading')\n    expect(characterPolicy.captureReasoning).toBe(true)\n\n    expect(locationPolicy.consoleEnabled).toBe(true)\n    expect(locationPolicy.displayMode).toBe('loading')\n    expect(locationPolicy.captureReasoning).toBe(true)\n  })\n\n  it('maps story/script run tasks to long-flow stage metadata', () => {\n    const storyMeta = getTaskFlowMeta(TASK_TYPE.STORY_TO_SCRIPT_RUN)\n    const scriptMeta = getTaskFlowMeta(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)\n\n    expect(storyMeta.flowId).toBe('novel_promotion_generation')\n    expect(storyMeta.flowStageIndex).toBe(1)\n    expect(storyMeta.flowStageTotal).toBe(2)\n\n    expect(scriptMeta.flowId).toBe('novel_promotion_generation')\n    expect(scriptMeta.flowStageIndex).toBe(2)\n    expect(scriptMeta.flowStageTotal).toBe(2)\n  })\n\n  it('maps AI_CREATE tasks to dedicated single-stage flows', () => {\n    const characterMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_CHARACTER)\n    const locationMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_LOCATION)\n\n    expect(characterMeta.flowId).toBe('novel_promotion_ai_create_character')\n    expect(characterMeta.flowStageIndex).toBe(1)\n    expect(characterMeta.flowStageTotal).toBe(1)\n\n    expect(locationMeta.flowId).toBe('novel_promotion_ai_create_location')\n    expect(locationMeta.flowStageIndex).toBe(1)\n    expect(locationMeta.flowStageTotal).toBe(1)\n  })\n\n  it('returns a stable two-stage pipeline for story/script flow', () => {\n    const pipeline = getTaskPipeline(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)\n    const stageTaskTypes = pipeline.stages.map((stage) => stage.taskType)\n    expect(stageTaskTypes).toEqual([\n      TASK_TYPE.STORY_TO_SCRIPT_RUN,\n      TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n    ])\n  })\n\n  it('falls back to single-stage metadata for unknown task type', () => {\n    const meta = getTaskFlowMeta('unknown_task_type')\n    const pipeline = getTaskPipeline('unknown_task_type')\n\n    expect(meta.flowId).toBe('single:unknown_task_type')\n    expect(meta.flowStageIndex).toBe(1)\n    expect(meta.flowStageTotal).toBe(1)\n    expect(pipeline.stages).toHaveLength(1)\n    expect(pipeline.stages[0]?.taskType).toBe('unknown_task_type')\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/normalize-error.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { normalizeAnyError } from '@/lib/errors/normalize'\n\ndescribe('normalizeAnyError network termination mapping', () => {\n  it('maps undici terminated TypeError to NETWORK_ERROR', () => {\n    const normalized = normalizeAnyError(new TypeError('terminated'))\n    expect(normalized.code).toBe('NETWORK_ERROR')\n    expect(normalized.retryable).toBe(true)\n  })\n\n  it('maps socket hang up TypeError to NETWORK_ERROR', () => {\n    const normalized = normalizeAnyError(new TypeError('socket hang up'))\n    expect(normalized.code).toBe('NETWORK_ERROR')\n    expect(normalized.retryable).toBe(true)\n  })\n\n  it('maps wrapped terminated message to NETWORK_ERROR', () => {\n    const normalized = normalizeAnyError(new Error('exception TypeError: terminated'))\n    expect(normalized.code).toBe('NETWORK_ERROR')\n    expect(normalized.retryable).toBe(true)\n  })\n})\n\ndescribe('normalizeAnyError provider-specific mapping', () => {\n  it('maps Ark ModelNotOpen payload to MODEL_NOT_OPEN', () => {\n    const normalized = normalizeAnyError({\n      status: 404,\n      code: 'ModelNotOpen',\n      message: 'Your account has not activated the model doubao-seedream. Please activate the model service in the Ark Console.',\n    })\n    expect(normalized.code).toBe('MODEL_NOT_OPEN')\n    expect(normalized.retryable).toBe(false)\n  })\n\n  it('maps Gemini empty response payload to EMPTY_RESPONSE even when status is 429', () => {\n    const normalized = normalizeAnyError({\n      status: 429,\n      message: 'received empty response from Gemini: no meaningful content in candidates (code: channel:empty_response)',\n    })\n    expect(normalized.code).toBe('EMPTY_RESPONSE')\n    expect(normalized.retryable).toBe(true)\n  })\n\n  it('maps template status 500 message to EXTERNAL_ERROR instead of INTERNAL_ERROR', () => {\n    const normalized = normalizeAnyError(new Error('Template request failed with status 500: upstream overloaded'))\n    expect(normalized.code).toBe('EXTERNAL_ERROR')\n    expect(normalized.retryable).toBe(true)\n  })\n\n  it('maps openai-compatible video template mismatch to VIDEO_API_FORMAT_UNSUPPORTED', () => {\n    const normalized = normalizeAnyError(\n      new Error('VIDEO_API_FORMAT_UNSUPPORTED: OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND'),\n    )\n    expect(normalized.code).toBe('VIDEO_API_FORMAT_UNSUPPORTED')\n    expect(normalized.retryable).toBe(false)\n  })\n\n  it('maps template status 415 message to VIDEO_API_FORMAT_UNSUPPORTED', () => {\n    const normalized = normalizeAnyError(\n      new Error('Template request failed with status 415: unsupported media type'),\n    )\n    expect(normalized.code).toBe('VIDEO_API_FORMAT_UNSUPPORTED')\n    expect(normalized.retryable).toBe(false)\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/presentation.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { resolveTaskPresentationState } from '@/lib/task/presentation'\n\ndescribe('resolveTaskPresentationState', () => {\n  it('uses overlay mode when running and has output', () => {\n    const state = resolveTaskPresentationState({\n      phase: 'processing',\n      intent: 'regenerate',\n      resource: 'image',\n      hasOutput: true,\n    })\n    expect(state.isRunning).toBe(true)\n    expect(state.mode).toBe('overlay')\n    expect(state.labelKey).toBe('taskStatus.intent.regenerate.running.image')\n  })\n\n  it('uses placeholder mode when running and no output', () => {\n    const state = resolveTaskPresentationState({\n      phase: 'queued',\n      intent: 'generate',\n      resource: 'image',\n      hasOutput: false,\n    })\n    expect(state.mode).toBe('placeholder')\n    expect(state.labelKey).toBe('taskStatus.intent.generate.running.image')\n  })\n\n  it('maps failed state to failed label', () => {\n    const state = resolveTaskPresentationState({\n      phase: 'failed',\n      intent: 'modify',\n      resource: 'video',\n      hasOutput: true,\n    })\n    expect(state.isError).toBe(true)\n    expect(state.labelKey).toBe('taskStatus.failed.video')\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/publisher.direct-run-events.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\ntype TaskEventRow = {\n  id: number\n  taskId: string\n  projectId: string\n  userId: string\n  eventType: string\n  payload: Record<string, unknown> | null\n  createdAt: Date\n}\n\nconst taskEventCreateMock = vi.hoisted(() =>\n  vi.fn<(...args: unknown[]) => Promise<TaskEventRow | null>>(async () => null),\n)\nconst taskEventFindManyMock = vi.hoisted(() =>\n  vi.fn<(...args: unknown[]) => Promise<TaskEventRow[]>>(async () => []),\n)\nconst taskFindManyMock = vi.hoisted(() =>\n  vi.fn<(...args: unknown[]) => Promise<Array<Record<string, unknown>>>>(async () => []),\n)\nconst redisPublishMock = vi.hoisted(() => vi.fn(async () => 1))\nconst mapTaskSSEEventToRunEventsMock = vi.hoisted(() =>\n  vi.fn(() => [{\n    runId: 'run-1',\n    projectId: 'project-1',\n    userId: 'user-1',\n    eventType: 'step.chunk',\n    stepKey: 'split_clips',\n    attempt: 1,\n    lane: 'text',\n    payload: { ok: true },\n  }]),\n)\nconst publishRunEventMock = vi.hoisted(() => vi.fn(async () => undefined))\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: {\n    taskEvent: {\n      create: taskEventCreateMock,\n      findMany: taskEventFindManyMock,\n    },\n    task: {\n      findMany: taskFindManyMock,\n    },\n  },\n}))\n\nvi.mock('@/lib/redis', () => ({\n  redis: {\n    publish: redisPublishMock,\n  },\n}))\n\nvi.mock('@/lib/run-runtime/task-bridge', () => ({\n  mapTaskSSEEventToRunEvents: mapTaskSSEEventToRunEventsMock,\n}))\n\nvi.mock('@/lib/run-runtime/publisher', () => ({\n  publishRunEvent: publishRunEventMock,\n}))\n\nimport { publishTaskStreamEvent } from '@/lib/task/publisher'\n\ndescribe('task publisher direct run event boundary', () => {\n  beforeEach(() => {\n    taskEventCreateMock.mockReset()\n    taskEventFindManyMock.mockReset()\n    taskFindManyMock.mockReset()\n    redisPublishMock.mockReset()\n    mapTaskSSEEventToRunEventsMock.mockClear()\n    publishRunEventMock.mockClear()\n  })\n\n  it('does not mirror run events for story_to_script task stream events', async () => {\n    await publishTaskStreamEvent({\n      taskId: 'task-1',\n      projectId: 'project-1',\n      userId: 'user-1',\n      taskType: 'story_to_script_run',\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      episodeId: 'episode-1',\n      payload: {\n        stepId: 'split_clips',\n        stream: {\n          kind: 'text',\n          seq: 1,\n          lane: 'main',\n          delta: 'hello',\n        },\n      },\n      persist: false,\n    })\n\n    expect(redisPublishMock).toHaveBeenCalledTimes(1)\n    expect(mapTaskSSEEventToRunEventsMock).not.toHaveBeenCalled()\n    expect(publishRunEventMock).not.toHaveBeenCalled()\n  })\n\n  it('continues mirroring run events for non-core task types', async () => {\n    await publishTaskStreamEvent({\n      taskId: 'task-2',\n      projectId: 'project-1',\n      userId: 'user-1',\n      taskType: 'voice_line',\n      targetType: 'VoiceLine',\n      targetId: 'line-1',\n      payload: {\n        stepId: 'voice',\n        stream: {\n          kind: 'text',\n          seq: 1,\n          lane: 'main',\n          delta: 'world',\n        },\n      },\n      persist: false,\n    })\n\n    expect(mapTaskSSEEventToRunEventsMock).toHaveBeenCalledTimes(1)\n    expect(publishRunEventMock).toHaveBeenCalledWith(expect.objectContaining({\n      runId: 'run-1',\n      eventType: 'step.chunk',\n      stepKey: 'split_clips',\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/unit/task/publisher.replay.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\ntype TaskEventRow = {\n  id: number\n  taskId: string\n  projectId: string\n  userId: string\n  eventType: string\n  payload: Record<string, unknown> | null\n  createdAt: Date\n}\n\ntype TaskMeta = {\n  id: string\n  type: string\n  targetType: string\n  targetId: string\n  episodeId: string | null\n}\n\nconst taskEventFindManyMock = vi.hoisted(() =>\n  vi.fn<(...args: unknown[]) => Promise<TaskEventRow[]>>(async () => []),\n)\nconst taskEventCreateMock = vi.hoisted(() =>\n  vi.fn<(...args: unknown[]) => Promise<TaskEventRow | null>>(async () => null),\n)\nconst taskFindManyMock = vi.hoisted(() =>\n  vi.fn<(...args: unknown[]) => Promise<TaskMeta[]>>(async () => []),\n)\nconst redisPublishMock = vi.hoisted(() => vi.fn(async () => 1))\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: {\n    taskEvent: {\n      findMany: taskEventFindManyMock,\n      create: taskEventCreateMock,\n    },\n    task: {\n      findMany: taskFindManyMock,\n    },\n  },\n}))\n\nvi.mock('@/lib/redis', () => ({\n  redis: {\n    publish: redisPublishMock,\n  },\n}))\n\nimport { listEventsAfter, listTaskLifecycleEvents, publishTaskStreamEvent } from '@/lib/task/publisher'\n\ndescribe('task publisher replay', () => {\n  beforeEach(() => {\n    taskEventFindManyMock.mockReset()\n    taskEventCreateMock.mockReset()\n    taskFindManyMock.mockReset()\n    redisPublishMock.mockReset()\n  })\n\n  it('replays persisted lifecycle + stream rows in chronological order', async () => {\n    taskEventFindManyMock.mockResolvedValueOnce([\n      {\n        id: 12,\n        taskId: 'task-1',\n        projectId: 'project-1',\n        userId: 'user-1',\n        eventType: 'task.stream',\n        payload: {\n          stepId: 'step-1',\n          stream: {\n            kind: 'text',\n            seq: 2,\n            lane: 'main',\n            delta: 'world',\n          },\n        },\n        createdAt: new Date('2026-02-27T00:00:02.000Z'),\n      },\n      {\n        id: 11,\n        taskId: 'task-1',\n        projectId: 'project-1',\n        userId: 'user-1',\n        eventType: 'task.processing',\n        payload: {\n          lifecycleType: 'task.processing',\n          stepId: 'step-1',\n          stepTitle: '阶段1',\n        },\n        createdAt: new Date('2026-02-27T00:00:01.000Z'),\n      },\n      {\n        id: 10,\n        taskId: 'task-1',\n        projectId: 'project-1',\n        userId: 'user-1',\n        eventType: 'task.ignored',\n        payload: {},\n        createdAt: new Date('2026-02-27T00:00:00.000Z'),\n      },\n    ])\n    taskFindManyMock.mockResolvedValueOnce([\n      {\n        id: 'task-1',\n        type: 'script_to_storyboard_run',\n        targetType: 'episode',\n        targetId: 'episode-1',\n        episodeId: 'episode-1',\n      },\n    ])\n\n    const events = await listTaskLifecycleEvents('task-1', 50)\n\n    expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({\n      where: { taskId: 'task-1' },\n      orderBy: { id: 'desc' },\n      take: 50,\n    }))\n    expect(events).toHaveLength(2)\n    expect(events.map((event) => event.id)).toEqual(['11', '12'])\n    expect(events.map((event) => event.type)).toEqual(['task.lifecycle', 'task.stream'])\n    expect((events[1]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('world')\n  })\n\n  it('persists stream rows when persist=true', async () => {\n    taskEventCreateMock.mockResolvedValueOnce({\n      id: 99,\n      taskId: 'task-1',\n      projectId: 'project-1',\n      userId: 'user-1',\n      eventType: 'task.stream',\n      payload: {\n        stream: {\n          kind: 'text',\n          seq: 1,\n          lane: 'main',\n          delta: 'hello',\n        },\n      },\n      createdAt: new Date('2026-02-27T00:00:03.000Z'),\n    })\n    redisPublishMock.mockResolvedValueOnce(1)\n\n    const message = await publishTaskStreamEvent({\n      taskId: 'task-1',\n      projectId: 'project-1',\n      userId: 'user-1',\n      taskType: 'story_to_script_run',\n      targetType: 'episode',\n      targetId: 'episode-1',\n      episodeId: 'episode-1',\n      payload: {\n        stepId: 'step-1',\n        stream: {\n          kind: 'text',\n          seq: 1,\n          lane: 'main',\n          delta: 'hello',\n        },\n      },\n      persist: true,\n    })\n\n    expect(taskEventCreateMock).toHaveBeenCalledWith(expect.objectContaining({\n      data: expect.objectContaining({\n        taskId: 'task-1',\n        eventType: 'task.stream',\n      }),\n    }))\n    expect(redisPublishMock).toHaveBeenCalledTimes(1)\n    expect(message?.id).toBe('99')\n    expect(message?.type).toBe('task.stream')\n  })\n\n  it('replays lifecycle + stream rows in listEventsAfter', async () => {\n    taskEventFindManyMock.mockResolvedValueOnce([\n      {\n        id: 101,\n        taskId: 'task-1',\n        projectId: 'project-1',\n        userId: 'user-1',\n        eventType: 'task.stream',\n        payload: {\n          stepId: 'step-1',\n          stream: {\n            kind: 'text',\n            seq: 3,\n            lane: 'main',\n            delta: 'chunk',\n          },\n        },\n        createdAt: new Date('2026-02-27T00:00:03.000Z'),\n      },\n      {\n        id: 102,\n        taskId: 'task-1',\n        projectId: 'project-1',\n        userId: 'user-1',\n        eventType: 'task.processing',\n        payload: {\n          lifecycleType: 'task.processing',\n          stepId: 'step-1',\n        },\n        createdAt: new Date('2026-02-27T00:00:04.000Z'),\n      },\n    ])\n    taskFindManyMock.mockResolvedValueOnce([\n      {\n        id: 'task-1',\n        type: 'story_to_script_run',\n        targetType: 'episode',\n        targetId: 'episode-1',\n        episodeId: 'episode-1',\n      },\n    ])\n\n    const events = await listEventsAfter('project-1', 100, 20)\n\n    expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({\n      where: {\n        projectId: 'project-1',\n        id: { gt: 100 },\n      },\n      orderBy: { id: 'asc' },\n    }))\n    expect(events).toHaveLength(2)\n    expect(events.map((event) => event.id)).toEqual(['101', '102'])\n    expect(events.map((event) => event.type)).toEqual(['task.stream', 'task.lifecycle'])\n    expect((events[0]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('chunk')\n  })\n})\n"
  },
  {
    "path": "tests/unit/user-api/llm-test-connection.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst openAIState = vi.hoisted(() => ({\n  modelList: vi.fn(async () => ({ data: [] })),\n  create: vi.fn(async () => ({\n    model: 'gpt-4.1-mini',\n    choices: [{ message: { content: '2' } }],\n  })),\n}))\n\nconst fetchMock = vi.hoisted(() =>\n  vi.fn(async (input: unknown) => {\n    const url = String(input)\n    if (url.includes('/compatible-mode/v1/models')) {\n      return new Response(JSON.stringify({ data: [{ id: 'qwen-plus' }] }), { status: 200 })\n    }\n    if (url.endsWith('/v1/models')) {\n      return new Response(JSON.stringify({ data: [{ id: 'Qwen/Qwen3-32B' }] }), { status: 200 })\n    }\n    if (url.endsWith('/v1/user/info')) {\n      return new Response(JSON.stringify({ data: { balance: '9.8000' } }), { status: 200 })\n    }\n    return new Response('not-found', { status: 404 })\n  }),\n)\n\nvi.mock('openai', () => ({\n  default: class OpenAI {\n    models = {\n      list: openAIState.modelList,\n    }\n    chat = {\n      completions: {\n        create: openAIState.create,\n      },\n    }\n  },\n}))\n\nimport { testLlmConnection } from '@/lib/user-api/llm-test-connection'\n\ndescribe('llm test connection', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    vi.stubGlobal('fetch', fetchMock)\n  })\n\n  it('tests openai-compatible provider via openai-style endpoint', async () => {\n    const result = await testLlmConnection({\n      provider: 'openai-compatible',\n      apiKey: 'oa-key',\n      baseUrl: 'https://compat.example.com/v1',\n      model: 'gpt-4.1-mini',\n    })\n\n    expect(result.provider).toBe('openai-compatible')\n    expect(result.message).toBe('openai-compatible 连接成功')\n    expect(result.model).toBe('gpt-4.1-mini')\n    expect(result.answer).toBe('2')\n    expect(openAIState.create).toHaveBeenCalledWith({\n      model: 'gpt-4.1-mini',\n      messages: [{ role: 'user', content: '1+1等于几？只回答数字' }],\n      max_tokens: 10,\n      temperature: 0,\n    })\n  })\n\n  it('requires baseUrl for gemini-compatible provider', async () => {\n    await expect(testLlmConnection({\n      provider: 'gemini-compatible',\n      apiKey: 'gm-key',\n    })).rejects.toThrow('自定义渠道需要提供 baseUrl')\n  })\n\n  it('tests bailian provider via zero-inference probe', async () => {\n    const result = await testLlmConnection({\n      provider: 'bailian',\n      apiKey: 'bl-key',\n    })\n\n    expect(result.provider).toBe('bailian')\n    expect(result.message).toBe('bailian 连接成功')\n    expect(result.model).toBe('qwen-plus')\n  })\n\n  it('tests siliconflow provider via zero-inference probes', async () => {\n    const result = await testLlmConnection({\n      provider: 'siliconflow',\n      apiKey: 'sf-key',\n    })\n\n    expect(result.provider).toBe('siliconflow')\n    expect(result.message).toBe('siliconflow 连接成功')\n    expect(result.model).toBe('Qwen/Qwen3-32B')\n    expect(result.answer).toBe('balance=9.8000')\n  })\n})\n"
  },
  {
    "path": "tests/unit/user-api/model-llm-protocol-probe.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst resolveOpenAICompatClientConfigMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    providerId: 'openai-compatible:node-1',\n    baseUrl: 'https://compat.example.com/v1',\n    apiKey: 'sk-test',\n  })),\n)\n\nvi.mock('@/lib/model-gateway/openai-compat/common', () => ({\n  resolveOpenAICompatClientConfig: resolveOpenAICompatClientConfigMock,\n}))\n\nimport { probeModelLlmProtocol } from '@/lib/user-api/model-llm-protocol-probe'\n\ndescribe('user-api model llm protocol probe', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('returns responses protocol when responses endpoint succeeds', async () => {\n    const fetchMock = vi.fn(async () => new Response(JSON.stringify({ id: 'resp_1' }), { status: 200 }))\n    vi.stubGlobal('fetch', fetchMock)\n\n    const result = await probeModelLlmProtocol({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n    })\n\n    expect(result.success).toBe(true)\n    if (!result.success) return\n    expect(result.protocol).toBe('responses')\n    expect(fetchMock).toHaveBeenCalledTimes(1)\n    const firstCall = fetchMock.mock.calls[0] as unknown[] | undefined\n    expect(String(firstCall?.[0])).toBe('https://compat.example.com/v1/responses')\n  })\n\n  it('returns chat-completions when responses is unsupported and chat succeeds', async () => {\n    const fetchMock = vi.fn(async (input: unknown) => {\n      const url = String(input)\n      if (url.endsWith('/responses')) return new Response('not found', { status: 404 })\n      if (url.endsWith('/chat/completions')) return new Response(JSON.stringify({ id: 'chatcmpl_1' }), { status: 200 })\n      return new Response('unexpected', { status: 500 })\n    })\n    vi.stubGlobal('fetch', fetchMock)\n\n    const result = await probeModelLlmProtocol({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n    })\n\n    expect(result.success).toBe(true)\n    if (!result.success) return\n    expect(result.protocol).toBe('chat-completions')\n    expect(result.traces.map((trace) => trace.endpoint)).toEqual(['responses', 'chat-completions'])\n  })\n\n  it('returns chat-completions when responses is rate limited but chat succeeds', async () => {\n    const fetchMock = vi.fn(async (input: unknown) => {\n      const url = String(input)\n      if (url.endsWith('/responses')) return new Response('rate limit', { status: 429 })\n      if (url.endsWith('/chat/completions')) return new Response(JSON.stringify({ id: 'chatcmpl_1' }), { status: 200 })\n      return new Response('unexpected', { status: 500 })\n    })\n    vi.stubGlobal('fetch', fetchMock)\n\n    const result = await probeModelLlmProtocol({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n    })\n\n    expect(result.success).toBe(true)\n    if (!result.success) return\n    expect(result.protocol).toBe('chat-completions')\n    expect(result.traces[0]?.status).toBe(429)\n    expect(result.traces[1]?.status).toBe(200)\n  })\n\n  it('treats responses 5xx with not-implemented style message as unsupported', async () => {\n    const fetchMock = vi.fn(async (input: unknown) => {\n      const url = String(input)\n      if (url.endsWith('/responses')) {\n        return new Response(JSON.stringify({\n          error: {\n            message: 'not implemented (request id: x)',\n            code: 'local:convert_request_failed',\n          },\n        }), { status: 500 })\n      }\n      if (url.endsWith('/chat/completions')) {\n        return new Response(JSON.stringify({ id: 'chatcmpl_1' }), { status: 200 })\n      }\n      return new Response('unexpected', { status: 500 })\n    })\n    vi.stubGlobal('fetch', fetchMock)\n\n    const result = await probeModelLlmProtocol({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n    })\n\n    expect(result.success).toBe(true)\n    if (!result.success) return\n    expect(result.protocol).toBe('chat-completions')\n  })\n\n  it('treats responses 400 with unsupported keywords as unsupported', async () => {\n    const fetchMock = vi.fn(async (input: unknown) => {\n      const url = String(input)\n      if (url.endsWith('/responses')) {\n        return new Response(JSON.stringify({ error: { message: 'unknown endpoint /responses' } }), { status: 400 })\n      }\n      if (url.endsWith('/chat/completions')) {\n        return new Response(JSON.stringify({ id: 'chatcmpl_1' }), { status: 200 })\n      }\n      return new Response('unexpected', { status: 500 })\n    })\n    vi.stubGlobal('fetch', fetchMock)\n\n    const result = await probeModelLlmProtocol({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n    })\n\n    expect(result.success).toBe(true)\n    if (!result.success) return\n    expect(result.protocol).toBe('chat-completions')\n  })\n\n  it('returns chat-completions when responses 422 has no unsupported keywords but chat succeeds', async () => {\n    const fetchMock = vi.fn(async (input: unknown) => {\n      const url = String(input)\n      if (url.endsWith('/responses')) {\n        return new Response(JSON.stringify({ error: { message: 'invalid payload' } }), { status: 422 })\n      }\n      if (url.endsWith('/chat/completions')) {\n        return new Response(JSON.stringify({ id: 'chatcmpl_1' }), { status: 200 })\n      }\n      return new Response('unexpected', { status: 500 })\n    })\n    vi.stubGlobal('fetch', fetchMock)\n\n    const result = await probeModelLlmProtocol({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n    })\n\n    expect(result.success).toBe(true)\n    if (!result.success) return\n    expect(result.protocol).toBe('chat-completions')\n    expect(result.traces[0]?.status).toBe(422)\n    expect(result.traces[1]?.status).toBe(200)\n  })\n\n  it('returns auth failure when responses and chat both return 401', async () => {\n    const fetchMock = vi.fn(async () => new Response('unauthorized', { status: 401 }))\n    vi.stubGlobal('fetch', fetchMock)\n\n    const result = await probeModelLlmProtocol({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n    })\n\n    expect(result.success).toBe(false)\n    if (result.success) return\n    expect(result.code).toBe('PROBE_AUTH_FAILED')\n    expect(fetchMock).toHaveBeenCalledTimes(2)\n  })\n\n  it('returns chat-completions when responses auth fails but chat succeeds', async () => {\n    const fetchMock = vi.fn(async (input: unknown) => {\n      const url = String(input)\n      if (url.endsWith('/responses')) return new Response('unauthorized', { status: 401 })\n      if (url.endsWith('/chat/completions')) return new Response(JSON.stringify({ id: 'chatcmpl_1' }), { status: 200 })\n      return new Response('unexpected', { status: 500 })\n    })\n    vi.stubGlobal('fetch', fetchMock)\n\n    const result = await probeModelLlmProtocol({\n      userId: 'user-1',\n      providerId: 'openai-compatible:node-1',\n      modelId: 'gpt-4.1-mini',\n    })\n\n    expect(result.success).toBe(true)\n    if (!result.success) return\n    expect(result.protocol).toBe('chat-completions')\n    expect(fetchMock).toHaveBeenCalledTimes(2)\n  })\n})\n"
  },
  {
    "path": "tests/unit/user-api/model-template-save.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst prismaMock = vi.hoisted(() => ({\n  userPreference: {\n    findUnique: vi.fn<(...args: unknown[]) => Promise<{ customProviders: string; customModels: string } | null>>(async () => null),\n    upsert: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({})),\n  },\n}))\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nimport { saveModelTemplateConfiguration } from '@/lib/user-api/model-template/save'\n\nfunction readSavedModelsFromUpsert(): Array<Record<string, unknown>> {\n  const firstCall = prismaMock.userPreference.upsert.mock.calls[0]\n  if (!firstCall) throw new Error('expected upsert to be called')\n  const payload = (firstCall as [{ update?: { customModels?: unknown } }])[0]\n  const raw = payload.update?.customModels\n  if (typeof raw !== 'string') throw new Error('expected customModels string')\n  const parsed = JSON.parse(raw) as unknown\n  if (!Array.isArray(parsed)) throw new Error('expected customModels array')\n  return parsed as Array<Record<string, unknown>>\n}\n\ndescribe('user-api model template save', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('preserves existing model fields while updating target model template', async () => {\n    prismaMock.userPreference.findUnique.mockResolvedValueOnce({\n      customProviders: JSON.stringify([\n        { id: 'openai-compatible:oa-1', name: 'OpenAI Compat' },\n      ]),\n      customModels: JSON.stringify([\n        {\n          modelId: 'veo3.1',\n          modelKey: 'openai-compatible:oa-1::veo3.1',\n          name: 'Veo 3.1',\n          type: 'video',\n          provider: 'openai-compatible:oa-1',\n          customPricing: { video: { basePrice: 1.2 } },\n          capabilities: { video: { durationOptions: [5, 8] } },\n        },\n      ]),\n    })\n\n    await saveModelTemplateConfiguration({\n      userId: 'user-1',\n      providerId: 'openai-compatible:oa-1',\n      modelId: 'veo3.1',\n      name: 'Veo 3.1',\n      type: 'video',\n      template: {\n        version: 1,\n        mediaType: 'video',\n        mode: 'async',\n        create: { method: 'POST', path: '/v2/videos/generations' },\n        status: { method: 'GET', path: '/v2/videos/generations/{{task_id}}' },\n        response: {\n          taskIdPath: '$.task_id',\n          statusPath: '$.status',\n        },\n        polling: {\n          intervalMs: 3000,\n          timeoutMs: 180000,\n          doneStates: ['done'],\n          failStates: ['failed'],\n        },\n      },\n      source: 'ai',\n    })\n\n    const savedModels = readSavedModelsFromUpsert()\n    const target = savedModels.find((item) => item.modelKey === 'openai-compatible:oa-1::veo3.1')\n    expect(target).toBeTruthy()\n    expect(target?.customPricing).toEqual({ video: { basePrice: 1.2 } })\n    expect(target?.capabilities).toEqual({ video: { durationOptions: [5, 8] } })\n    expect(target?.compatMediaTemplate).toMatchObject({\n      mediaType: 'video',\n      mode: 'async',\n    })\n    expect(target?.compatMediaTemplateSource).toBe('ai')\n    expect(typeof target?.compatMediaTemplateCheckedAt).toBe('string')\n  })\n})\n"
  },
  {
    "path": "tests/unit/user-api/model-template-schema.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { validateOpenAICompatMediaTemplate } from '@/lib/user-api/model-template'\n\ndescribe('user-api model template schema', () => {\n  it('accepts valid async video template', () => {\n    const result = validateOpenAICompatMediaTemplate({\n      version: 1,\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        method: 'POST',\n        path: '/v2/videos/generations',\n        contentType: 'application/json',\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n        },\n      },\n      status: {\n        method: 'GET',\n        path: '/v2/videos/generations/{{task_id}}',\n      },\n      response: {\n        taskIdPath: '$.task_id',\n        statusPath: '$.status',\n        outputUrlPath: '$.video_url',\n        errorPath: '$.error.message',\n      },\n      polling: {\n        intervalMs: 3000,\n        timeoutMs: 300000,\n        doneStates: ['succeeded'],\n        failStates: ['failed'],\n      },\n    })\n\n    expect(result.ok).toBe(true)\n    expect(result.template?.mode).toBe('async')\n  })\n\n  it('rejects unsupported placeholders', () => {\n    const result = validateOpenAICompatMediaTemplate({\n      version: 1,\n      mediaType: 'image',\n      mode: 'sync',\n      create: {\n        method: 'POST',\n        path: '/images/generations',\n        contentType: 'application/json',\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt_text}}',\n        },\n      },\n      response: {\n        outputUrlPath: '$.data[0].url',\n      },\n    })\n\n    expect(result.ok).toBe(false)\n    expect(result.issues.some((issue) => issue.field.includes('bodyTemplate.prompt'))).toBe(true)\n  })\n\n  it('rejects async template missing polling/status fields', () => {\n    const result = validateOpenAICompatMediaTemplate({\n      version: 1,\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        method: 'POST',\n        path: '/videos',\n        contentType: 'application/json',\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n        },\n      },\n      response: {\n        taskIdPath: '$.id',\n      },\n    })\n\n    expect(result.ok).toBe(false)\n    expect(result.issues.map((issue) => issue.field)).toEqual(expect.arrayContaining(['status']))\n  })\n\n  it('rejects async create endpoint without bodyTemplate for POST', () => {\n    const result = validateOpenAICompatMediaTemplate({\n      version: 1,\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        method: 'POST',\n        path: '/v1/video/create',\n      },\n      status: {\n        method: 'GET',\n        path: '/v1/video/query?id={{task_id}}',\n      },\n    })\n\n    expect(result.ok).toBe(false)\n    expect(result.issues.map((issue) => issue.field)).toEqual(expect.arrayContaining(['create.bodyTemplate']))\n  })\n\n  it('rejects async status path without task_id placeholder', () => {\n    const result = validateOpenAICompatMediaTemplate({\n      version: 1,\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        method: 'POST',\n        path: '/v1/video/create',\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n        },\n      },\n      status: {\n        method: 'GET',\n        path: '/v1/video/query',\n      },\n    })\n\n    expect(result.ok).toBe(false)\n    expect(result.issues.map((issue) => issue.field)).toEqual(expect.arrayContaining(['status.path']))\n  })\n\n  it('rejects async template when response paths or polling are omitted', () => {\n    const result = validateOpenAICompatMediaTemplate({\n      version: 1,\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        method: 'POST',\n        path: '/v1/video/create',\n        contentType: 'application/json',\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n        },\n      },\n      status: {\n        method: 'GET',\n        path: '/v1/video/query?id={{task_id}}',\n      },\n    })\n\n    expect(result.ok).toBe(false)\n    expect(result.issues.map((issue) => issue.field)).toEqual(expect.arrayContaining([\n      'response.taskIdPath',\n      'response.statusPath',\n      'polling',\n    ]))\n  })\n\n  it('accepts multipart file field declarations for media templates', () => {\n    const result = validateOpenAICompatMediaTemplate({\n      version: 1,\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        method: 'POST',\n        path: '/videos',\n        contentType: 'multipart/form-data',\n        multipartFileFields: ['input_reference'],\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n          input_reference: '{{image}}',\n        },\n      },\n      status: {\n        method: 'GET',\n        path: '/videos/{{task_id}}',\n      },\n      content: {\n        method: 'GET',\n        path: '/videos/{{task_id}}/content',\n      },\n      response: {\n        taskIdPath: '$.id',\n        statusPath: '$.status',\n      },\n      polling: {\n        intervalMs: 5000,\n        timeoutMs: 600000,\n        doneStates: ['completed'],\n        failStates: ['failed'],\n      },\n    })\n\n    expect(result.ok).toBe(true)\n    expect(result.template?.create.multipartFileFields).toEqual(['input_reference'])\n  })\n\n  it('rejects multipart file fields that are not present in bodyTemplate', () => {\n    const result = validateOpenAICompatMediaTemplate({\n      version: 1,\n      mediaType: 'video',\n      mode: 'async',\n      create: {\n        method: 'POST',\n        path: '/videos',\n        contentType: 'multipart/form-data',\n        multipartFileFields: ['input_reference'],\n        bodyTemplate: {\n          model: '{{model}}',\n          prompt: '{{prompt}}',\n        },\n      },\n      status: {\n        method: 'GET',\n        path: '/videos/{{task_id}}',\n      },\n      response: {\n        taskIdPath: '$.id',\n        statusPath: '$.status',\n      },\n      polling: {\n        intervalMs: 5000,\n        timeoutMs: 600000,\n        doneStates: ['completed'],\n        failStates: ['failed'],\n      },\n    })\n\n    expect(result.ok).toBe(false)\n    expect(result.issues.some((issue) => issue.field === 'create.multipartFileFields')).toBe(true)\n  })\n})\n"
  },
  {
    "path": "tests/unit/user-api/provider-test-compatible.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst openAIState = vi.hoisted(() => ({\n  create: vi.fn(async () => ({\n    choices: [{ message: { content: 'pong' } }],\n  })),\n}))\n\nconst fetchMock = vi.hoisted(() =>\n  vi.fn<typeof fetch>(async () => new Response('not-found', { status: 404 })),\n)\n\nvi.mock('openai', () => ({\n  default: class OpenAI {\n    chat = {\n      completions: {\n        create: openAIState.create,\n      },\n    }\n  },\n}))\n\nimport { testProviderConnection } from '@/lib/user-api/provider-test'\n\ndescribe('provider test connection compatible probes', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    vi.stubGlobal('fetch', fetchMock)\n  })\n\n  it('asks user to configure llm when free probes are unsupported', async () => {\n    const result = await testProviderConnection({\n      apiType: 'openai-compatible',\n      baseUrl: 'https://compat.example.com/v1',\n      apiKey: 'compat-key',\n    })\n\n    expect(result.success).toBe(false)\n    expect(result.steps[0]?.name).toBe('models')\n    expect(result.steps[0]?.status).toBe('skip')\n    expect(result.steps[1]?.name).toBe('credits')\n    expect(result.steps[1]?.status).toBe('skip')\n    expect(result.steps[2]).toEqual({\n      name: 'textGen',\n      status: 'fail',\n      message: 'No free probe endpoint detected. Please configure an LLM model first, then retry / 未发现可用的免费探测接口，请先配置 LLM 模型后再测试',\n    })\n  })\n\n  it('falls back to configured llm test when free probes are unsupported', async () => {\n    const result = await testProviderConnection({\n      apiType: 'openai-compatible',\n      baseUrl: 'https://compat.example.com/v1',\n      apiKey: 'compat-key',\n      llmModel: 'gpt-4.1-mini',\n    })\n\n    expect(result.success).toBe(true)\n    expect(result.steps[0]?.status).toBe('skip')\n    expect(result.steps[1]?.status).toBe('skip')\n    expect(result.steps[2]).toEqual({\n      name: 'textGen',\n      status: 'pass',\n      model: 'gpt-4.1-mini',\n      message: 'Response: pong',\n    })\n    expect(openAIState.create).toHaveBeenCalledWith({\n      model: 'gpt-4.1-mini',\n      messages: [{ role: 'user', content: 'hi' }],\n      max_tokens: 20,\n      temperature: 0,\n    })\n  })\n\n  it('marks success when any free probe endpoint passes', async () => {\n    fetchMock.mockImplementation(async (input: RequestInfo | URL) => {\n      const url = String(input)\n      if (url.endsWith('/v1/models')) {\n        return new Response(JSON.stringify({ data: [{ id: 'm1' }, { id: 'm2' }] }), { status: 200 })\n      }\n      return new Response('not-found', { status: 404 })\n    })\n\n    const result = await testProviderConnection({\n      apiType: 'gemini-compatible',\n      baseUrl: 'https://compat.example.com',\n      apiKey: 'compat-key',\n    })\n\n    expect(result.success).toBe(true)\n    expect(result.steps[0]).toMatchObject({\n      name: 'models',\n      status: 'pass',\n      message: 'Found 2 models',\n    })\n    expect(result.steps[1]?.name).toBe('credits')\n    expect(result.steps[1]?.status).toBe('skip')\n    expect(result.steps.length).toBe(2)\n  })\n})\n"
  },
  {
    "path": "tests/unit/user-api/provider-test.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { testProviderConnection } from '@/lib/user-api/provider-test'\n\nconst fetchMock = vi.hoisted(() =>\n  vi.fn(async (input: unknown) => {\n    const url = String(input)\n    if (url.includes('dashscope.aliyuncs.com/compatible-mode/v1/models')) {\n      return new Response(JSON.stringify({ data: [{ id: 'qwen-plus' }] }), { status: 200 })\n    }\n    if (url.includes('api.siliconflow.cn/v1/models')) {\n      return new Response(JSON.stringify({ data: [{ id: 'Qwen/Qwen3-32B' }] }), { status: 200 })\n    }\n    if (url.includes('api.siliconflow.cn/v1/user/info')) {\n      return new Response(JSON.stringify({ data: { balance: '12.3000' } }), { status: 200 })\n    }\n    return new Response('not-found', { status: 404 })\n  }),\n)\n\ndescribe('provider test connection', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    vi.stubGlobal('fetch', fetchMock)\n  })\n\n  it('passes bailian probe with models step and credits skip', async () => {\n    const result = await testProviderConnection({\n      apiType: 'bailian',\n      apiKey: 'bl-key',\n    })\n\n    expect(result.success).toBe(true)\n    expect(result.steps).toEqual([\n      {\n        name: 'models',\n        status: 'pass',\n        message: 'Found 1 models',\n      },\n      {\n        name: 'credits',\n        status: 'skip',\n        message: 'Not supported by Bailian probe API',\n      },\n    ])\n  })\n\n  it('passes siliconflow probe with models and credits steps', async () => {\n    const result = await testProviderConnection({\n      apiType: 'siliconflow',\n      apiKey: 'sf-key',\n    })\n\n    expect(result.success).toBe(true)\n    expect(result.steps[0]).toEqual({\n      name: 'models',\n      status: 'pass',\n      message: 'Found 1 models',\n    })\n    expect(result.steps[1]).toEqual({\n      name: 'credits',\n      status: 'pass',\n      message: 'Balance: 12.3000',\n    })\n  })\n\n  it('classifies auth failures for bailian models probe', async () => {\n    fetchMock.mockImplementationOnce(async () => new Response('unauthorized', { status: 401 }))\n\n    const result = await testProviderConnection({\n      apiType: 'bailian',\n      apiKey: 'bad-key',\n    })\n\n    expect(result.success).toBe(false)\n    expect(result.steps[0]).toEqual({\n      name: 'models',\n      status: 'fail',\n      message: 'Authentication failed (401)',\n      detail: 'unauthorized',\n    })\n    expect(result.steps[1]).toEqual({\n      name: 'credits',\n      status: 'skip',\n      message: 'Not supported by Bailian probe API',\n    })\n  })\n\n  it('classifies rate limit failures for siliconflow models probe', async () => {\n    fetchMock.mockImplementationOnce(async () => new Response('rate limit', { status: 429 }))\n\n    const result = await testProviderConnection({\n      apiType: 'siliconflow',\n      apiKey: 'sf-key',\n    })\n\n    expect(result.success).toBe(false)\n    expect(result.steps[0]).toEqual({\n      name: 'models',\n      status: 'fail',\n      message: 'Rate limited (429)',\n      detail: 'rate limit',\n    })\n    expect(result.steps[1]).toEqual({\n      name: 'credits',\n      status: 'skip',\n      message: 'Skipped because model probe failed',\n    })\n  })\n\n  it('classifies network failures for siliconflow user info probe', async () => {\n    fetchMock.mockImplementationOnce(async () =>\n      new Response(JSON.stringify({ data: [{ id: 'Qwen/Qwen3-32B' }] }), { status: 200 }),\n    )\n    fetchMock.mockImplementationOnce(async () => {\n      throw new Error('socket hang up')\n    })\n\n    const result = await testProviderConnection({\n      apiType: 'siliconflow',\n      apiKey: 'sf-key',\n    })\n\n    expect(result.success).toBe(false)\n    expect(result.steps[0]).toEqual({\n      name: 'models',\n      status: 'pass',\n      message: 'Found 1 models',\n    })\n    expect(result.steps[1]).toEqual({\n      name: 'credits',\n      status: 'fail',\n      message: 'Network error: socket hang up',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/voice/generate-voice-line.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionVoiceLine: {\n    findUnique: vi.fn(),\n    update: vi.fn(async () => undefined),\n  },\n  novelPromotionProject: {\n    findUnique: vi.fn(),\n  },\n  novelPromotionEpisode: {\n    findUnique: vi.fn(),\n  },\n}))\n\nconst resolveModelSelectionOrSingleMock = vi.hoisted(() => vi.fn())\nconst getProviderKeyMock = vi.hoisted(() => vi.fn((providerId: string) => providerId))\nconst getAudioApiKeyMock = vi.hoisted(() => vi.fn())\n\nconst normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn())\nconst extractStorageKeyMock = vi.hoisted(() => vi.fn())\nconst getSignedUrlMock = vi.hoisted(() => vi.fn((storageKey: string) => `signed://${storageKey}`))\nconst toFetchableUrlMock = vi.hoisted(() => vi.fn((url: string) => url))\nconst uploadObjectMock = vi.hoisted(() => vi.fn(async () => 'voice/storage/line-1.wav'))\nconst resolveStorageKeyFromMediaValueMock = vi.hoisted(() => vi.fn())\nconst synthesizeWithBailianTTSMock = vi.hoisted(() => vi.fn())\nconst falSubscribeMock = vi.hoisted(() => vi.fn())\nconst getProviderConfigMock = vi.hoisted(() => vi.fn())\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nvi.mock('@/lib/api-config', () => ({\n  getAudioApiKey: getAudioApiKeyMock,\n  getProviderConfig: getProviderConfigMock,\n  getProviderKey: getProviderKeyMock,\n  resolveModelSelectionOrSingle: resolveModelSelectionOrSingleMock,\n}))\n\nvi.mock('@/lib/media/outbound-image', () => ({\n  normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,\n}))\n\nvi.mock('@/lib/storage', () => ({\n  extractStorageKey: extractStorageKeyMock,\n  getSignedUrl: getSignedUrlMock,\n  toFetchableUrl: toFetchableUrlMock,\n  uploadObject: uploadObjectMock,\n}))\n\nvi.mock('@/lib/media/service', () => ({\n  resolveStorageKeyFromMediaValue: resolveStorageKeyFromMediaValueMock,\n}))\n\nvi.mock('@/lib/providers/bailian', () => ({\n  synthesizeWithBailianTTS: synthesizeWithBailianTTSMock,\n}))\n\nvi.mock('@fal-ai/client', () => ({\n  fal: {\n    config: vi.fn(),\n    subscribe: falSubscribeMock,\n  },\n}))\n\nimport { generateVoiceLine } from '@/lib/voice/generate-voice-line'\n\ndescribe('generate voice line with bailian provider', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    const audioBytes = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])\n\n    prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({\n      id: 'line-1',\n      episodeId: 'episode-1',\n      speaker: 'Narrator',\n      content: '你好，世界',\n      emotionPrompt: null,\n      emotionStrength: null,\n    })\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValue({\n      characters: [],\n    })\n    prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({\n      speakerVoices: JSON.stringify({\n        Narrator: {\n          audioUrl: 'voice/reference.wav',\n          voiceId: 'voice_abc123',\n        },\n      }),\n    })\n\n    resolveModelSelectionOrSingleMock.mockResolvedValue({\n      provider: 'bailian',\n      modelId: 'qwen3-tts-vd-2026-01-26',\n      modelKey: 'bailian::qwen3-tts-vd-2026-01-26',\n      mediaType: 'audio',\n    })\n\n    getProviderConfigMock.mockResolvedValue({\n      id: 'bailian',\n      name: 'Alibaba Bailian',\n      apiKey: 'bl-key',\n    })\n    synthesizeWithBailianTTSMock.mockResolvedValue({\n      success: true,\n      audioData: Buffer.from(audioBytes),\n      audioDuration: 1,\n    })\n  })\n\n  it('uses speaker voiceId to generate and persists uploaded audio', async () => {\n    const result = await generateVoiceLine({\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      lineId: 'line-1',\n      userId: 'user-1',\n      audioModel: 'bailian::qwen3-tts-vd-2026-01-26',\n    })\n\n    expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')\n    expect(synthesizeWithBailianTTSMock).toHaveBeenCalledWith({\n      text: '你好，世界',\n      voiceId: 'voice_abc123',\n      modelId: 'qwen3-tts-vd-2026-01-26',\n      languageType: 'Chinese',\n    }, 'bl-key')\n    expect(uploadObjectMock).toHaveBeenCalledTimes(1)\n    expect(prismaMock.novelPromotionVoiceLine.update).toHaveBeenCalledWith({\n      where: { id: 'line-1' },\n      data: {\n        audioUrl: 'voice/storage/line-1.wav',\n        audioDuration: 1,\n      },\n    })\n    expect(result).toEqual({\n      lineId: 'line-1',\n      audioUrl: 'signed://voice/storage/line-1.wav',\n      storageKey: 'voice/storage/line-1.wav',\n      audioDuration: 1,\n    })\n  })\n\n  it('fails explicitly when bailian speaker binding only has uploaded audio', async () => {\n    prismaMock.novelPromotionEpisode.findUnique.mockResolvedValueOnce({\n      speakerVoices: JSON.stringify({\n        Narrator: {\n          audioUrl: 'voice/reference.wav',\n        },\n      }),\n    })\n\n    await expect(\n      generateVoiceLine({\n        projectId: 'project-1',\n        episodeId: 'episode-1',\n        lineId: 'line-1',\n        userId: 'user-1',\n        audioModel: 'bailian::qwen3-tts-vd-2026-01-26',\n      }),\n    ).rejects.toThrow('无音色ID，QwenTTS 必须使用 AI 设计音色')\n\n    expect(synthesizeWithBailianTTSMock).not.toHaveBeenCalled()\n    expect(uploadObjectMock).not.toHaveBeenCalled()\n  })\n\n  it('maps bailian invalid parameter to a qwen voice guidance error', async () => {\n    synthesizeWithBailianTTSMock.mockResolvedValueOnce({\n      success: false,\n      error: 'BAILIAN_TTS_FAILED(400): InvalidParameter',\n    })\n\n    await expect(\n      generateVoiceLine({\n        projectId: 'project-1',\n        episodeId: 'episode-1',\n        lineId: 'line-1',\n        userId: 'user-1',\n        audioModel: 'bailian::qwen3-tts-vd-2026-01-26',\n      }),\n    ).rejects.toThrow('无效音色ID，QwenTTS 必须使用 AI 设计音色')\n\n    expect(uploadObjectMock).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "tests/unit/voice/provider-voice-binding.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport {\n  getSpeakerVoicePreviewUrl,\n  hasAnyVoiceBinding,\n  parseSpeakerVoiceMap,\n  resolveVoiceBindingForProvider,\n} from '@/lib/voice/provider-voice-binding'\n\ndescribe('provider voice binding', () => {\n  it('parses legacy fal speaker voice entry to explicit fal provider', () => {\n    const map = parseSpeakerVoiceMap(JSON.stringify({\n      Narrator: {\n        voiceType: 'uploaded',\n        audioUrl: 'voice/reference.wav',\n      },\n    }))\n\n    expect(map.Narrator).toEqual({\n      provider: 'fal',\n      voiceType: 'uploaded',\n      audioUrl: 'voice/reference.wav',\n    })\n  })\n\n  it('parses legacy bailian entry with voiceId and preview audio', () => {\n    const map = parseSpeakerVoiceMap(JSON.stringify({\n      Narrator: {\n        voiceType: 'qwen-designed',\n        voiceId: 'qwen-tts-vd-001',\n        audioUrl: 'voice/qwen-preview.wav',\n      },\n    }))\n\n    expect(map.Narrator).toEqual({\n      provider: 'bailian',\n      voiceType: 'qwen-designed',\n      voiceId: 'qwen-tts-vd-001',\n      previewAudioUrl: 'voice/qwen-preview.wav',\n    })\n  })\n\n  it('resolves bailian binding from speaker voiceId when character has no voice', () => {\n    const map = parseSpeakerVoiceMap(JSON.stringify({\n      Narrator: {\n        provider: 'bailian',\n        voiceType: 'qwen-designed',\n        voiceId: 'qwen-tts-vd-001',\n      },\n    }))\n\n    const binding = resolveVoiceBindingForProvider({\n      providerKey: 'bailian',\n      character: { customVoiceUrl: null, voiceId: null },\n      speakerVoice: map.Narrator,\n    })\n\n    expect(binding).toEqual({\n      provider: 'bailian',\n      source: 'speaker',\n      voiceId: 'qwen-tts-vd-001',\n    })\n  })\n\n  it('does not treat bailian voice entry as fal reference audio', () => {\n    const map = parseSpeakerVoiceMap(JSON.stringify({\n      Narrator: {\n        provider: 'bailian',\n        voiceType: 'qwen-designed',\n        voiceId: 'qwen-tts-vd-001',\n        previewAudioUrl: 'voice/qwen-preview.wav',\n      },\n    }))\n\n    const binding = resolveVoiceBindingForProvider({\n      providerKey: 'fal',\n      character: { customVoiceUrl: null, voiceId: null },\n      speakerVoice: map.Narrator,\n    })\n\n    expect(binding).toBeNull()\n  })\n\n  it('returns preview url from fal and bailian entry correctly', () => {\n    const map = parseSpeakerVoiceMap(JSON.stringify({\n      FalSpeaker: {\n        provider: 'fal',\n        voiceType: 'uploaded',\n        audioUrl: 'voice/fal.wav',\n      },\n      BailianSpeaker: {\n        provider: 'bailian',\n        voiceType: 'qwen-designed',\n        voiceId: 'qwen-tts-vd-001',\n        previewAudioUrl: 'voice/qwen-preview.wav',\n      },\n    }))\n\n    expect(getSpeakerVoicePreviewUrl(map.FalSpeaker)).toBe('voice/fal.wav')\n    expect(getSpeakerVoicePreviewUrl(map.BailianSpeaker)).toBe('voice/qwen-preview.wav')\n    expect(hasAnyVoiceBinding({ speakerVoice: map.BailianSpeaker })).toBe(true)\n  })\n\n  it('throws explicitly when a speaker entry has no usable binding', () => {\n    expect(() => parseSpeakerVoiceMap(JSON.stringify({\n      Narrator: {\n        voiceType: 'uploaded',\n      },\n    }))).toThrow('SPEAKER_VOICE_ENTRY_MISSING_BINDING')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/analyze-global.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  project: { findUnique: vi.fn() },\n  novelPromotionProject: { findUnique: vi.fn() },\n}))\n\nconst llmMock = vi.hoisted(() => ({\n  chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),\n  getCompletionContent: vi.fn(() => '{\"ok\":true}'),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nconst parseMock = vi.hoisted(() => ({\n  chunkContent: vi.fn(() => ['chunk-1', 'chunk-2']),\n  safeParseCharactersResponse: vi.fn(() => ({ new_characters: [] })),\n  safeParseLocationsResponse: vi.fn(() => ({ locations: [] })),\n}))\n\nconst persistMock = vi.hoisted(() => ({\n  createAnalyzeGlobalStats: vi.fn((totalChunks: number) => ({\n    totalChunks,\n    processedChunks: 0,\n    newCharacters: 0,\n    updatedCharacters: 0,\n    newLocations: 0,\n    skippedCharacters: 0,\n    skippedLocations: 0,\n  })),\n  persistAnalyzeGlobalChunk: vi.fn(async (args: { stats: { newCharacters: number; newLocations: number } }) => {\n    args.stats.newCharacters += 1\n    args.stats.newLocations += 1\n  }),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => llmMock)\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))\nvi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    onStage: vi.fn(),\n    onChunk: vi.fn(),\n    onComplete: vi.fn(),\n    onError: vi.fn(),\n    flush: vi.fn(async () => undefined),\n  })),\n}))\nvi.mock('@/lib/workers/handlers/analyze-global-parse', () => ({\n  CHUNK_SIZE: 3000,\n  chunkContent: parseMock.chunkContent,\n  parseAliases: vi.fn(() => []),\n  readText: (value: unknown) => (typeof value === 'string' ? value : ''),\n  safeParseCharactersResponse: parseMock.safeParseCharactersResponse,\n  safeParseLocationsResponse: parseMock.safeParseLocationsResponse,\n}))\nvi.mock('@/lib/workers/handlers/analyze-global-prompt', () => ({\n  loadAnalyzeGlobalPromptTemplates: vi.fn(() => ({ characterTemplate: 'c', locationTemplate: 'l' })),\n  buildAnalyzeGlobalPrompts: vi.fn(() => ({\n    characterPrompt: 'character prompt',\n    locationPrompt: 'location prompt',\n  })),\n}))\nvi.mock('@/lib/workers/handlers/analyze-global-persist', () => ({\n  createAnalyzeGlobalStats: persistMock.createAnalyzeGlobalStats,\n  persistAnalyzeGlobalChunk: persistMock.persistAnalyzeGlobalChunk,\n}))\n\nimport { handleAnalyzeGlobalTask } from '@/lib/workers/handlers/analyze-global'\n\nfunction buildJob(): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-analyze-global-1',\n      type: TASK_TYPE.ANALYZE_GLOBAL,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: null,\n      targetType: 'NovelPromotionProject',\n      targetId: 'np-project-1',\n      payload: {},\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker analyze-global behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })\n\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValue({\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-1',\n      globalAssetText: '全局设定',\n      characters: [{ id: 'char-1', name: 'Hero', aliases: null, introduction: 'hero intro' }],\n      locations: [{ id: 'loc-1', name: 'Old Town', summary: 'old town summary' }],\n      episodes: [{ id: 'ep-1', name: '第一集', novelText: 'episode text' }],\n    })\n  })\n\n  it('no analyzable content -> explicit error', async () => {\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-1',\n      globalAssetText: '',\n      characters: [],\n      locations: [],\n      episodes: [{ id: 'ep-1', name: '第一集', novelText: '' }],\n    })\n\n    await expect(handleAnalyzeGlobalTask(buildJob())).rejects.toThrow('没有可分析的内容')\n  })\n\n  it('success path -> persists every chunk and returns stats summary', async () => {\n    const result = await handleAnalyzeGlobalTask(buildJob())\n\n    expect(parseMock.chunkContent).toHaveBeenCalled()\n    expect(persistMock.persistAnalyzeGlobalChunk).toHaveBeenCalledTimes(2)\n\n    expect(result).toEqual({\n      success: true,\n      stats: {\n        totalChunks: 2,\n        newCharacters: 2,\n        updatedCharacters: 0,\n        newLocations: 2,\n        skippedCharacters: 0,\n        skippedLocations: 0,\n        totalCharacters: 1,\n        totalLocations: 1,\n      },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/analyze-novel.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  project: { findUnique: vi.fn() },\n  novelPromotionProject: {\n    findUnique: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  novelPromotionEpisode: { findFirst: vi.fn() },\n  novelPromotionCharacter: { create: vi.fn(async () => ({ id: 'char-new-1' })) },\n  novelPromotionLocation: { create: vi.fn(async () => ({ id: 'loc-new-1' })) },\n  locationImage: { create: vi.fn(async () => ({})) },\n}))\n\nconst llmMock = vi.hoisted(() => ({\n  chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),\n  getCompletionContent: vi.fn(),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => llmMock)\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\nvi.mock('@/lib/constants', () => ({\n  getArtStylePrompt: vi.fn(() => 'cinematic style'),\n  removeLocationPromptSuffix: vi.fn((text: string) => text.replace(' [SUFFIX]', '')),\n}))\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))\nvi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    onStage: vi.fn(),\n    onChunk: vi.fn(),\n    onComplete: vi.fn(),\n    onError: vi.fn(),\n    flush: vi.fn(async () => undefined),\n  })),\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: {\n    NP_AGENT_CHARACTER_PROFILE: 'char',\n    NP_SELECT_LOCATION: 'loc',\n  },\n  buildPrompt: vi.fn(() => 'analysis-prompt'),\n}))\n\nimport { handleAnalyzeNovelTask } from '@/lib/workers/handlers/analyze-novel'\n\nfunction buildJob(): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-analyze-novel-1',\n      type: TASK_TYPE.ANALYZE_NOVEL,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionProject',\n      targetId: 'np-project-1',\n      payload: {},\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker analyze-novel behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.project.findUnique.mockResolvedValue({\n      id: 'project-1',\n      mode: 'novel-promotion',\n    })\n\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValue({\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-1',\n      artStyle: 'cinematic',\n      globalAssetText: '全局设定文本',\n      characters: [{ id: 'char-existing', name: '已有角色' }],\n      locations: [{ id: 'loc-existing', name: '已有场景', summary: 'old' }],\n    })\n\n    prismaMock.novelPromotionEpisode.findFirst.mockResolvedValue({\n      novelText: '首集内容',\n    })\n\n    llmMock.getCompletionContent\n      .mockReturnValueOnce(JSON.stringify({\n        characters: [\n          {\n            name: '新角色',\n            aliases: ['别名A'],\n            role_level: 'main',\n            personality_tags: ['冷静'],\n            visual_keywords: ['黑发'],\n          },\n        ],\n      }))\n      .mockReturnValueOnce(JSON.stringify({\n        locations: [\n          {\n            name: '新地点',\n            summary: '雨夜街道',\n            descriptions: ['雨夜街道 [SUFFIX]'],\n          },\n        ],\n      }))\n  })\n\n  it('no global text and no episode text -> explicit error', async () => {\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-1',\n      artStyle: 'cinematic',\n      globalAssetText: '',\n      characters: [],\n      locations: [],\n    })\n    prismaMock.novelPromotionEpisode.findFirst.mockResolvedValueOnce({ novelText: '' })\n\n    await expect(handleAnalyzeNovelTask(buildJob())).rejects.toThrow('请先填写全局资产设定或剧本内容')\n  })\n\n  it('success path -> creates character/location and persists cleaned location descriptions', async () => {\n    const result = await handleAnalyzeNovelTask(buildJob())\n\n    expect(result).toEqual({\n      success: true,\n      characters: [{ id: 'char-new-1' }],\n      locations: [{ id: 'loc-new-1' }],\n      characterCount: 1,\n      locationCount: 1,\n    })\n\n    expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith(\n      expect.objectContaining({\n        data: expect.objectContaining({\n          novelPromotionProjectId: 'np-project-1',\n          name: '新角色',\n          aliases: JSON.stringify(['别名A']),\n        }),\n      }),\n    )\n\n    expect(prismaMock.novelPromotionLocation.create).toHaveBeenCalledWith(\n      expect.objectContaining({\n        data: expect.objectContaining({\n          novelPromotionProjectId: 'np-project-1',\n          name: '新地点',\n          summary: '雨夜街道',\n        }),\n      }),\n    )\n\n    expect(prismaMock.locationImage.create).toHaveBeenCalledWith({\n      data: {\n        locationId: 'loc-new-1',\n        imageIndex: 0,\n        description: '雨夜街道',\n      },\n    })\n\n    expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({\n      where: { id: 'np-project-1' },\n      data: { artStylePrompt: 'cinematic style' },\n    })\n\n    expect(workerMock.reportTaskProgress).toHaveBeenCalledWith(\n      expect.anything(),\n      60,\n      expect.objectContaining({\n        stepId: 'analyze_characters',\n        done: true,\n        output: expect.stringContaining('\"characters\"'),\n      }),\n    )\n\n    expect(workerMock.reportTaskProgress).toHaveBeenCalledWith(\n      expect.anything(),\n      70,\n      expect.objectContaining({\n        stepId: 'analyze_locations',\n        done: true,\n        output: expect.stringContaining('\"locations\"'),\n      }),\n    )\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/asset-hub-ai-design.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst configMock = vi.hoisted(() => ({\n  getUserModelConfig: vi.fn(),\n}))\n\nconst assetUtilsMock = vi.hoisted(() => ({\n  aiDesign: vi.fn(),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/config-service', () => configMock)\nvi.mock('@/lib/asset-utils', () => assetUtilsMock)\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: workerMock.reportTaskProgress,\n}))\nvi.mock('@/lib/workers/utils', () => ({\n  assertTaskActive: workerMock.assertTaskActive,\n}))\n\nimport { handleAssetHubAIDesignTask } from '@/lib/workers/handlers/asset-hub-ai-design'\n\nfunction buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-asset-ai-design-1',\n      type,\n      locale: 'zh',\n      projectId: 'global-asset-hub',\n      episodeId: null,\n      targetType: 'GlobalCharacter',\n      targetId: 'target-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker asset-hub-ai-design behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    configMock.getUserModelConfig.mockResolvedValue({ analysisModel: 'llm::analysis-default' })\n    assetUtilsMock.aiDesign.mockResolvedValue({\n      success: true,\n      prompt: 'generated prompt',\n    })\n  })\n\n  it('missing userInstruction -> explicit error', async () => {\n    const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER, {})\n    await expect(handleAssetHubAIDesignTask(job)).rejects.toThrow('userInstruction is required')\n  })\n\n  it('unsupported task type -> explicit error', async () => {\n    const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, { userInstruction: 'design a hero' })\n    await expect(handleAssetHubAIDesignTask(job)).rejects.toThrow('Unsupported asset hub ai design task type')\n  })\n\n  it('success uses payload analysisModel override and character assetType', async () => {\n    const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER, {\n      userInstruction: '  design a heroic character  ',\n      analysisModel: '  llm::analysis-override  ',\n    })\n\n    const result = await handleAssetHubAIDesignTask(job)\n\n    expect(assetUtilsMock.aiDesign).toHaveBeenCalledWith(expect.objectContaining({\n      userId: 'user-1',\n      analysisModel: 'llm::analysis-override',\n      userInstruction: 'design a heroic character',\n      assetType: 'character',\n      projectId: 'global-asset-hub',\n      skipBilling: true,\n    }))\n    expect(result).toEqual({ prompt: 'generated prompt' })\n  })\n\n  it('location type success -> passes location assetType', async () => {\n    const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION, {\n      userInstruction: 'design a rainy alley',\n    })\n\n    await handleAssetHubAIDesignTask(job)\n\n    expect(assetUtilsMock.aiDesign).toHaveBeenCalledWith(expect.objectContaining({\n      assetType: 'location',\n      analysisModel: 'llm::analysis-default',\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/asset-hub-ai-modify.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst llmMock = vi.hoisted(() => ({\n  chatCompletion: vi.fn(),\n  getCompletionContent: vi.fn(),\n}))\n\nconst configMock = vi.hoisted(() => ({\n  getUserModelConfig: vi.fn(),\n}))\n\nconst streamContextMock = vi.hoisted(() => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nconst llmStreamMock = vi.hoisted(() => {\n  const flush = vi.fn(async () => undefined)\n  return {\n    flush,\n    createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n    createWorkerLLMStreamCallbacks: vi.fn(() => ({\n      onStage: vi.fn(),\n      onChunk: vi.fn(),\n      onComplete: vi.fn(),\n      onError: vi.fn(),\n      flush,\n    })),\n  }\n})\n\nvi.mock('@/lib/llm-client', () => llmMock)\nvi.mock('@/lib/config-service', () => configMock)\nvi.mock('@/lib/llm-observe/internal-stream-context', () => streamContextMock)\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: workerMock.reportTaskProgress,\n}))\nvi.mock('@/lib/workers/utils', () => ({\n  assertTaskActive: workerMock.assertTaskActive,\n}))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: llmStreamMock.createWorkerLLMStreamContext,\n  createWorkerLLMStreamCallbacks: llmStreamMock.createWorkerLLMStreamCallbacks,\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: {\n    NP_CHARACTER_MODIFY: 'np_character_modify',\n    NP_LOCATION_MODIFY: 'np_location_modify',\n  },\n  buildPrompt: vi.fn((_args: unknown) => 'final-prompt'),\n}))\n\nimport { handleAssetHubAIModifyTask } from '@/lib/workers/handlers/asset-hub-ai-modify'\n\nfunction buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-asset-ai-modify-1',\n      type,\n      locale: 'zh',\n      projectId: 'global-asset-hub',\n      episodeId: null,\n      targetType: 'GlobalCharacter',\n      targetId: 'target-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker asset-hub-ai-modify behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    configMock.getUserModelConfig.mockResolvedValue({ analysisModel: 'llm::analysis-1' })\n    llmMock.chatCompletion.mockResolvedValue({ id: 'completion-1' })\n    llmMock.getCompletionContent.mockReturnValue('{\"prompt\":\"modified description\"}')\n  })\n\n  it('missing analysisModel in user config -> explicit error', async () => {\n    configMock.getUserModelConfig.mockResolvedValueOnce({ analysisModel: '' })\n    const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER, {\n      characterId: 'char-1',\n      currentDescription: 'old',\n      modifyInstruction: 'new',\n    })\n\n    await expect(handleAssetHubAIModifyTask(job)).rejects.toThrow('请先在用户配置中设置分析模型')\n  })\n\n  it('unsupported type -> explicit error', async () => {\n    const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, {\n      characterId: 'char-1',\n      currentDescription: 'old',\n      modifyInstruction: 'new',\n    })\n\n    await expect(handleAssetHubAIModifyTask(job)).rejects.toThrow('Unsupported task type')\n  })\n\n  it('character success -> parses JSON prompt and returns modifiedDescription', async () => {\n    const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER, {\n      characterId: 'char-1',\n      currentDescription: 'old character description',\n      modifyInstruction: 'add armor details',\n    })\n\n    const result = await handleAssetHubAIModifyTask(job)\n\n    expect(llmMock.chatCompletion).toHaveBeenCalledWith(\n      'user-1',\n      'llm::analysis-1',\n      [{ role: 'user', content: 'final-prompt' }],\n      expect.objectContaining({\n        projectId: 'asset-hub',\n        action: 'ai_modify_character',\n      }),\n    )\n    expect(result).toEqual({\n      success: true,\n      modifiedDescription: 'modified description',\n    })\n    expect(llmStreamMock.flush).toHaveBeenCalled()\n  })\n\n  it('location success -> requires locationName and returns modifiedDescription', async () => {\n    const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION, {\n      locationId: 'loc-1',\n      locationName: 'Old Town',\n      currentDescription: 'old location description',\n      modifyInstruction: 'add more fog',\n    })\n\n    const result = await handleAssetHubAIModifyTask(job)\n    expect(result).toEqual({\n      success: true,\n      modifiedDescription: 'modified description',\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/asset-hub-image-suffix.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { CHARACTER_PROMPT_SUFFIX } from '@/lib/constants'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst workersUtilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => {}),\n  getUserModels: vi.fn(async () => ({\n    characterModel: 'character-model-1',\n    locationModel: 'location-model-1',\n  })),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  globalCharacter: {\n    findFirst: vi.fn(),\n  },\n  globalCharacterAppearance: {\n    update: vi.fn(async () => ({})),\n  },\n  globalLocation: {\n    findFirst: vi.fn(),\n  },\n  globalLocationImage: {\n    update: vi.fn(async () => ({})),\n  },\n}))\n\nconst sharedMock = vi.hoisted(() => ({\n  generateLabeledImageToCos: vi.fn(async () => 'cos/generated-character.png'),\n  parseJsonStringArray: vi.fn(() => []),\n}))\n\nvi.mock('@/lib/workers/utils', () => workersUtilsMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(\n    '@/lib/workers/handlers/image-task-handler-shared',\n  )\n  return {\n    ...actual,\n    generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,\n    parseJsonStringArray: sharedMock.parseJsonStringArray,\n  }\n})\n\nimport { handleAssetHubImageTask } from '@/lib/workers/handlers/asset-hub-image-task-handler'\n\nfunction buildJob(payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-asset-hub-image-1',\n      type: TASK_TYPE.ASSET_HUB_IMAGE,\n      locale: 'zh',\n      projectId: 'project-1',\n      targetType: 'GlobalCharacter',\n      targetId: 'global-character-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\nfunction countOccurrences(input: string, target: string) {\n  if (!target) return 0\n  return input.split(target).length - 1\n}\n\ndescribe('asset hub character image prompt suffix regression', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    prismaMock.globalCharacter.findFirst.mockResolvedValue({\n      id: 'global-character-1',\n      name: 'Hero',\n      appearances: [\n        {\n          id: 'appearance-1',\n          appearanceIndex: 0,\n          changeReason: 'base',\n          description: '主角，黑发，冷静',\n          descriptions: null,\n        },\n      ],\n    })\n  })\n\n  it('keeps character prompt suffix in actual generation prompt', async () => {\n    const job = buildJob({\n      type: 'character',\n      id: 'global-character-1',\n      appearanceIndex: 0,\n    })\n\n    await handleAssetHubImageTask(job)\n\n    const generationCall = sharedMock.generateLabeledImageToCos.mock.calls[0] as unknown as [{ prompt?: string }] | undefined\n    const callArg = generationCall?.[0]\n    const prompt = callArg?.prompt || ''\n\n    expect(prompt).toContain('主角，黑发，冷静')\n    expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)\n    expect(countOccurrences(prompt, CHARACTER_PROMPT_SUFFIX)).toBe(1)\n  })\n\n  it('honors requested count for global location generation', async () => {\n    prismaMock.globalLocation.findFirst.mockResolvedValueOnce({\n      id: 'global-location-1',\n      name: 'Old Town',\n      images: [\n        { id: 'global-location-image-1', description: '雨夜街道 A' },\n        { id: 'global-location-image-2', description: '雨夜街道 B' },\n        { id: 'global-location-image-3', description: '雨夜街道 C' },\n      ],\n    })\n\n    const result = await handleAssetHubImageTask(buildJob({\n      type: 'location',\n      id: 'global-location-1',\n      count: 1,\n    }))\n\n    expect(result).toEqual({\n      type: 'location',\n      locationId: 'global-location-1',\n      imageCount: 1,\n    })\n    expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledTimes(1)\n    expect(prismaMock.globalLocationImage.update).toHaveBeenCalledTimes(1)\n    expect(prismaMock.globalLocationImage.update).toHaveBeenCalledWith({\n      where: { id: 'global-location-image-1' },\n      data: { imageUrl: 'cos/generated-character.png' },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/character-image-task-handler.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { CHARACTER_PROMPT_SUFFIX, getArtStylePrompt } from '@/lib/constants'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => undefined),\n  getProjectModels: vi.fn(async () => ({ characterModel: 'image-model-1', artStyle: 'realistic' })),\n  toSignedUrlIfCos: vi.fn((url: string | null | undefined) => (url ? `https://signed.example/${url}` : null)),\n}))\n\nconst outboundMock = vi.hoisted(() => ({\n  normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-primary-ref']),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  characterAppearance: {\n    findUnique: vi.fn(),\n    findFirst: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  novelPromotionCharacter: {\n    findUnique: vi.fn(),\n  },\n}))\n\nconst sharedMock = vi.hoisted(() => ({\n  generateLabeledImageToCos: vi.fn<(input: {\n    prompt: string\n    options?: { referenceImages?: string[]; aspectRatio?: string }\n  }) => Promise<string>>(async () => 'cos/character-generated-0.png'),\n}))\n\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/media/outbound-image', () => outboundMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))\nvi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(\n    '@/lib/workers/handlers/image-task-handler-shared',\n  )\n  return {\n    ...actual,\n    generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,\n  }\n})\n\nimport { handleCharacterImageTask } from '@/lib/workers/handlers/character-image-task-handler'\n\nfunction buildJob(payload: Record<string, unknown>, targetId = 'appearance-2'): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-character-image-1',\n      type: TASK_TYPE.IMAGE_CHARACTER,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: null,\n      targetType: 'CharacterAppearance',\n      targetId,\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker character-image-task-handler behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.characterAppearance.findUnique.mockResolvedValue({\n      id: 'appearance-2',\n      characterId: 'character-1',\n      appearanceIndex: 1,\n      descriptions: JSON.stringify(['角色描述A']),\n      description: '角色描述A',\n      imageUrls: JSON.stringify([]),\n      selectedIndex: 0,\n      imageUrl: null,\n      changeReason: '战斗形态',\n      character: { name: 'Hero' },\n    })\n\n    prismaMock.characterAppearance.findFirst.mockResolvedValue({\n      imageUrl: 'cos/primary.png',\n      imageUrls: JSON.stringify(['cos/primary.png']),\n    })\n  })\n\n  it('characterModel not configured -> explicit error', async () => {\n    utilsMock.getProjectModels.mockResolvedValueOnce({ characterModel: '', artStyle: 'realistic' })\n    await expect(handleCharacterImageTask(buildJob({}))).rejects.toThrow('Character model not configured')\n  })\n\n  it('success path -> uses primary appearance as reference and persists imageUrls', async () => {\n    const job = buildJob({ imageIndex: 0 })\n    const result = await handleCharacterImageTask(job)\n\n    expect(result).toEqual({\n      appearanceId: 'appearance-2',\n      imageCount: 1,\n      imageUrl: 'cos/character-generated-0.png',\n    })\n\n    const generationInput = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as {\n      prompt: string\n      options?: { referenceImages?: string[]; aspectRatio?: string }\n    }\n    const realisticStylePrompt = getArtStylePrompt('realistic', 'zh')\n\n    expect(generationInput.prompt).toContain(CHARACTER_PROMPT_SUFFIX)\n    expect(generationInput.prompt).toContain(realisticStylePrompt)\n    expect(generationInput.prompt.split(CHARACTER_PROMPT_SUFFIX).length - 1).toBe(1)\n    expect(generationInput.prompt.split(realisticStylePrompt).length - 1).toBe(1)\n    expect(generationInput.options).toEqual(expect.objectContaining({\n      referenceImages: ['normalized-primary-ref'],\n      aspectRatio: '3:2',\n    }))\n\n    expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({\n      where: { id: 'appearance-2' },\n      data: {\n        imageUrls: JSON.stringify(['cos/character-generated-0.png']),\n        imageUrl: 'cos/character-generated-0.png',\n      },\n    })\n  })\n\n  it('payload artStyle overrides project artStyle in prompt', async () => {\n    const job = buildJob({ imageIndex: 0, artStyle: 'japanese-anime' })\n    await handleCharacterImageTask(job)\n\n    const generationInput = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as {\n      prompt: string\n    }\n    expect(generationInput.prompt).toContain(getArtStylePrompt('japanese-anime', 'zh'))\n    expect(generationInput.prompt).not.toContain(getArtStylePrompt('realistic', 'zh'))\n  })\n\n  it('invalid payload artStyle -> explicit error', async () => {\n    await expect(handleCharacterImageTask(buildJob({ imageIndex: 0, artStyle: 'noir' }))).rejects.toThrow(\n      'Invalid artStyle in IMAGE_CHARACTER payload',\n    )\n  })\n\n  it('uses requested count for grouped generation and expands imageUrls to requested size', async () => {\n    sharedMock.generateLabeledImageToCos\n      .mockResolvedValueOnce('cos/character-generated-0.png')\n      .mockResolvedValueOnce('cos/character-generated-1.png')\n      .mockResolvedValueOnce('cos/character-generated-2.png')\n      .mockResolvedValueOnce('cos/character-generated-3.png')\n      .mockResolvedValueOnce('cos/character-generated-4.png')\n\n    const result = await handleCharacterImageTask(buildJob({ count: 5 }))\n\n    expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledTimes(5)\n    expect(result).toEqual({\n      appearanceId: 'appearance-2',\n      imageCount: 5,\n      imageUrl: 'cos/character-generated-0.png',\n    })\n    expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({\n      where: { id: 'appearance-2' },\n      data: {\n        imageUrls: JSON.stringify([\n          'cos/character-generated-0.png',\n          'cos/character-generated-1.png',\n          'cos/character-generated-2.png',\n          'cos/character-generated-3.png',\n          'cos/character-generated-4.png',\n        ]),\n        imageUrl: 'cos/character-generated-0.png',\n      },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/character-profile.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionCharacter: {\n    findFirst: vi.fn(),\n    findMany: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  characterAppearance: {\n    create: vi.fn(async () => ({})),\n  },\n}))\n\nconst llmMock = vi.hoisted(() => ({\n  chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),\n  getCompletionContent: vi.fn(),\n}))\n\nconst helperMock = vi.hoisted(() => ({\n  resolveProjectModel: vi.fn(async () => ({\n    id: 'project-1',\n    novelPromotionData: {\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-1',\n    },\n  })),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => llmMock)\nvi.mock('@/types/character-profile', () => ({\n  validateProfileData: vi.fn(() => true),\n  stringifyProfileData: vi.fn((value: unknown) => JSON.stringify(value)),\n}))\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))\nvi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    onStage: vi.fn(),\n    onChunk: vi.fn(),\n    onComplete: vi.fn(),\n    onError: vi.fn(),\n    flush: vi.fn(async () => undefined),\n  })),\n}))\nvi.mock('@/lib/workers/handlers/character-profile-helpers', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/character-profile-helpers')>(\n    '@/lib/workers/handlers/character-profile-helpers',\n  )\n  return {\n    ...actual,\n    resolveProjectModel: helperMock.resolveProjectModel,\n  }\n})\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_AGENT_CHARACTER_VISUAL: 'np_agent_character_visual' },\n  buildPrompt: vi.fn(() => 'character-visual-prompt'),\n}))\n\nimport { handleCharacterProfileTask } from '@/lib/workers/handlers/character-profile'\n\nfunction buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-character-profile-1',\n      type,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: null,\n      targetType: 'NovelPromotionCharacter',\n      targetId: 'character-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker character-profile behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    llmMock.getCompletionContent.mockReturnValue(\n      JSON.stringify({\n        characters: [\n          {\n            appearances: [\n              {\n                change_reason: '默认形象',\n                descriptions: ['黑发，冷静，风衣'],\n              },\n            ],\n          },\n        ],\n      }),\n    )\n\n    prismaMock.novelPromotionCharacter.findFirst.mockImplementation(async (args: { where: { id: string } }) => ({\n      id: args.where.id,\n      name: args.where.id === 'character-2' ? 'Villain' : 'Hero',\n      profileData: JSON.stringify({ archetype: 'lead' }),\n      profileConfirmed: false,\n      novelPromotionProjectId: 'np-project-1',\n    }))\n\n    prismaMock.novelPromotionCharacter.findMany.mockResolvedValue([\n      {\n        id: 'character-1',\n        name: 'Hero',\n        profileData: JSON.stringify({ archetype: 'lead' }),\n        profileConfirmed: false,\n      },\n      {\n        id: 'character-2',\n        name: 'Villain',\n        profileData: JSON.stringify({ archetype: 'antagonist' }),\n        profileConfirmed: false,\n      },\n    ])\n  })\n\n  it('unsupported task type -> explicit error', async () => {\n    const job = buildJob(TASK_TYPE.AI_CREATE_CHARACTER, {})\n    await expect(handleCharacterProfileTask(job)).rejects.toThrow('Unsupported character profile task type')\n  })\n\n  it('confirm profile success -> creates appearance and marks profileConfirmed', async () => {\n    const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })\n    const result = await handleCharacterProfileTask(job)\n\n    expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({\n      data: expect.objectContaining({\n        characterId: 'character-1',\n        appearanceIndex: 0,\n        changeReason: '默认形象',\n        description: '黑发，冷静，风衣',\n      }),\n    })\n\n    expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({\n      where: { id: 'character-1' },\n      data: { profileConfirmed: true },\n    })\n\n    expect(result).toEqual(expect.objectContaining({\n      success: true,\n      character: expect.objectContaining({\n        id: 'character-1',\n        profileConfirmed: true,\n      }),\n    }))\n  })\n\n  it('batch confirm -> loops through all unconfirmed characters and returns count', async () => {\n    const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM, {})\n    const result = await handleCharacterProfileTask(job)\n\n    expect(result).toEqual({\n      success: true,\n      count: 2,\n    })\n    expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(2)\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/clips-build.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  project: { findUnique: vi.fn() },\n  novelPromotionProject: { findUnique: vi.fn() },\n  novelPromotionEpisode: { findUnique: vi.fn() },\n  novelPromotionClip: {\n    findMany: vi.fn(async () => []),\n    update: vi.fn(async () => ({ id: 'clip-row-1' })),\n    deleteMany: vi.fn(async () => ({})),\n    create: vi.fn(async () => ({ id: 'clip-row-1' })),\n  },\n}))\n\nconst llmMock = vi.hoisted(() => ({\n  chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),\n  getCompletionContent: vi.fn(),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => llmMock)\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\nvi.mock('@/lib/constants', () => ({\n  buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),\n}))\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))\nvi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    onStage: vi.fn(),\n    onChunk: vi.fn(),\n    onComplete: vi.fn(),\n    onError: vi.fn(),\n    flush: vi.fn(async () => undefined),\n  })),\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_AGENT_CLIP: 'np_agent_clip' },\n  buildPrompt: vi.fn(() => 'clip-split-prompt'),\n}))\nvi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({\n  createClipContentMatcher: (content: string) => ({\n    matchBoundary: (start: string, end: string, fromIndex = 0) => {\n      const startIndex = content.indexOf(start, fromIndex)\n      if (startIndex === -1) return null\n      const endStart = content.indexOf(end, startIndex)\n      if (endStart === -1) return null\n      return {\n        startIndex,\n        endIndex: endStart + end.length,\n      }\n    },\n  }),\n}))\n\nimport { handleClipsBuildTask } from '@/lib/workers/handlers/clips-build'\n\nfunction buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-clips-build-1',\n      type: TASK_TYPE.CLIPS_BUILD,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker clips-build behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })\n\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValue({\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-1',\n      characters: [{ id: 'char-1', name: 'Hero' }],\n      locations: [{ id: 'loc-1', name: 'Old Town' }],\n    })\n\n    prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({\n      id: 'episode-1',\n      name: '第一集',\n      novelPromotionProjectId: 'np-project-1',\n      novelText: 'A START one END B START two END C',\n    })\n    prismaMock.novelPromotionClip.findMany.mockResolvedValue([])\n\n    llmMock.getCompletionContent.mockReturnValue(\n      JSON.stringify([\n        {\n          start: 'START one',\n          end: 'END',\n          summary: 'first clip',\n          location: 'Old Town',\n          characters: ['Hero'],\n        },\n      ]),\n    )\n  })\n\n  it('missing episodeId -> explicit error', async () => {\n    const job = buildJob({}, null)\n    await expect(handleClipsBuildTask(job)).rejects.toThrow('episodeId is required')\n  })\n\n  it('success path -> creates clip row with concrete boundaries and characters payload', async () => {\n    const job = buildJob({ episodeId: 'episode-1' })\n    const result = await handleClipsBuildTask(job)\n\n    expect(result).toEqual({\n      episodeId: 'episode-1',\n      count: 1,\n    })\n\n    expect(prismaMock.novelPromotionClip.create).toHaveBeenCalledWith({\n      data: {\n        episodeId: 'episode-1',\n        startText: 'START one',\n        endText: 'END',\n        summary: 'first clip',\n        location: 'Old Town',\n        characters: JSON.stringify(['Hero']),\n        content: 'START one END',\n      },\n      select: { id: true },\n    })\n  })\n\n  it('AI boundaries cannot be matched -> explicit boundary error', async () => {\n    llmMock.getCompletionContent.mockReturnValue(\n      JSON.stringify([\n        {\n          start: 'NOT_FOUND_START',\n          end: 'NOT_FOUND_END',\n          summary: 'bad clip',\n        },\n      ]),\n    )\n\n    const job = buildJob({ episodeId: 'episode-1' })\n    await expect(handleClipsBuildTask(job)).rejects.toThrow('split_clips boundary matching failed')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/episode-split.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  project: {\n    findUnique: vi.fn(async () => ({ id: 'project-1', mode: 'novel-promotion' })),\n  },\n  novelPromotionProject: {\n    findFirst: vi.fn(async () => ({ id: 'np-project-1' })),\n  },\n}))\n\nconst llmClientMock = vi.hoisted(() => ({\n  chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),\n  getCompletionContent: vi.fn(() => JSON.stringify({\n    episodes: [\n      {\n        number: 1,\n        title: '第一集',\n        summary: '开端',\n        startMarker: 'START_MARKER',\n        endMarker: 'END_MARKER',\n      },\n    ],\n  })),\n}))\n\nconst configServiceMock = vi.hoisted(() => ({\n  getUserModelConfig: vi.fn(async () => ({\n    analysisModel: 'llm::analysis-model',\n  })),\n}))\n\nconst internalStreamMock = vi.hoisted(() => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\n\nconst sharedMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => {}),\n}))\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => {}),\n}))\n\nconst llmStreamMock = vi.hoisted(() => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'stream-1' })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    flush: vi.fn(async () => {}),\n  })),\n}))\n\nconst promptMock = vi.hoisted(() => ({\n  PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },\n  buildPrompt: vi.fn(() => 'EPISODE_SPLIT_PROMPT'),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => llmClientMock)\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/llm-observe/internal-stream-context', () => internalStreamMock)\nvi.mock('@/lib/workers/shared', () => sharedMock)\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/workers/handlers/llm-stream', () => llmStreamMock)\nvi.mock('@/lib/prompt-i18n', () => promptMock)\nvi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({\n  createTextMarkerMatcher: (content: string) => ({\n    matchMarker: (marker: string, fromIndex = 0) => {\n      const startIndex = content.indexOf(marker, fromIndex)\n      if (startIndex === -1) return null\n      return {\n        startIndex,\n        endIndex: startIndex + marker.length,\n      }\n    },\n  }),\n}))\n\nimport { handleEpisodeSplitTask } from '@/lib/workers/handlers/episode-split'\n\nfunction buildJob(content: string): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-episode-split-1',\n      type: TASK_TYPE.EPISODE_SPLIT_LLM,\n      locale: 'zh',\n      projectId: 'project-1',\n      targetType: 'NovelPromotionProject',\n      targetId: 'project-1',\n      payload: { content },\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker episode-split', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('fails fast when content is too short', async () => {\n    const job = buildJob('short text')\n    await expect(handleEpisodeSplitTask(job)).rejects.toThrow('文本太短，至少需要 100 字')\n  })\n\n  it('returns matched episodes when ai boundaries are valid', async () => {\n    const content = [\n      '前置内容用于凑长度，确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',\n      '前置内容用于凑长度，确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',\n      'START_MARKER',\n      '这里是第一集的正文内容，包含角色冲突与场景推进，长度足够用于单元测试验证。',\n      'END_MARKER',\n      '后置内容用于确保边界外还有文本，并继续补足长度。',\n    ].join('')\n\n    const job = buildJob(content)\n    const result = await handleEpisodeSplitTask(job)\n\n    expect(result.success).toBe(true)\n    expect(result.episodes).toHaveLength(1)\n    expect(result.episodes[0]?.number).toBe(1)\n    expect(result.episodes[0]?.title).toBe('第一集')\n    expect(result.episodes[0]?.content).toContain('START_MARKER')\n    expect(result.episodes[0]?.content).toContain('END_MARKER')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/image-task-handlers-core.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => {}),\n  getProjectModels: vi.fn(async () => ({ editModel: 'edit-model' })),\n  getUserModels: vi.fn(async () => ({ editModel: 'edit-model', analysisModel: 'analysis-model' })),\n  resolveImageSourceFromGeneration: vi.fn(async () => 'generated-image-source'),\n  stripLabelBar: vi.fn(async () => 'required-reference-image'),\n  toSignedUrlIfCos: vi.fn(() => 'https://signed/current-image.png'),\n  uploadImageSourceToCos: vi.fn(async () => 'cos/new-image.png'),\n  withLabelBar: vi.fn(async (source: unknown) => source),\n}))\n\nconst outboundImageMock = vi.hoisted(() => ({\n  normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-reference-image']),\n  normalizeToBase64ForGeneration: vi.fn(async () => 'base64-required-reference'),\n}))\n\nconst sharedMock = vi.hoisted(() => ({\n  resolveNovelData: vi.fn(async () => ({ videoRatio: '16:9' })),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  characterAppearance: {\n    findUnique: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  locationImage: {\n    findUnique: vi.fn(),\n    findFirst: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  novelPromotionPanel: {\n    findUnique: vi.fn(),\n    findFirst: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  novelPromotionProject: {\n    findUnique: vi.fn(),\n  },\n}))\n\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/media/outbound-image', () => outboundImageMock)\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\nvi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(\n    '@/lib/workers/handlers/image-task-handler-shared',\n  )\n  return {\n    ...actual,\n    resolveNovelData: sharedMock.resolveNovelData,\n  }\n})\n\nimport { handleModifyAssetImageTask } from '@/lib/workers/handlers/image-task-handlers-core'\n\nfunction buildJob(payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-1',\n      type: TASK_TYPE.MODIFY_ASSET_IMAGE,\n      locale: 'zh',\n      projectId: 'project-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'target-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\nfunction readUpdateData(arg: unknown): Record<string, unknown> {\n  if (!arg || typeof arg !== 'object') return {}\n  const data = (arg as { data?: unknown }).data\n  if (!data || typeof data !== 'object') return {}\n  return data as Record<string, unknown>\n}\n\ndescribe('worker image-task-handlers-core', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('fails fast when modify task payload is incomplete', async () => {\n    const job = buildJob({})\n    await expect(handleModifyAssetImageTask(job)).rejects.toThrow('modify task missing type/modifyPrompt')\n  })\n\n  it('updates location image with expected generation options and persistence payload', async () => {\n    prismaMock.locationImage.findUnique.mockResolvedValue({\n      id: 'location-image-1',\n      locationId: 'location-1',\n      imageUrl: 'cos/location-old.png',\n      location: { name: 'Old Town' },\n    })\n\n    const job = buildJob({\n      type: 'location',\n      locationImageId: 'location-image-1',\n      modifyPrompt: 'add heavy rain',\n      extraImageUrls: [' https://example.com/location-ref.png '],\n      generationOptions: { resolution: '1536x1024' },\n    })\n\n    const result = await handleModifyAssetImageTask(job)\n    expect(result).toEqual({\n      type: 'location',\n      locationImageId: 'location-image-1',\n      imageUrl: 'cos/new-image.png',\n    })\n\n    expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(\n      expect.anything(),\n      expect.objectContaining({\n        options: expect.objectContaining({\n          aspectRatio: '1:1',\n          resolution: '1536x1024',\n          referenceImages: ['required-reference-image', 'normalized-reference-image'],\n        }),\n      }),\n    )\n\n    const locationUpdateCall = prismaMock.locationImage.update.mock.calls.at(-1) as [unknown] | undefined\n    const updateArg = locationUpdateCall?.[0]\n    const updateData = readUpdateData(updateArg)\n    expect(updateData.previousImageUrl).toBe('cos/location-old.png')\n    expect(updateData.imageUrl).toBe('cos/new-image.png')\n  })\n\n  it('updates storyboard panel image and keeps candidateImages reset', async () => {\n    prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({\n      id: 'panel-1',\n      storyboardId: 'storyboard-1',\n      panelIndex: 0,\n      imageUrl: 'cos/panel-old.png',\n      previousImageUrl: null,\n    })\n\n    const job = buildJob({\n      type: 'storyboard',\n      panelId: 'panel-1',\n      modifyPrompt: 'cinematic backlight',\n      selectedAssets: [{ imageUrl: 'https://example.com/asset-ref.png' }],\n      extraImageUrls: ['https://example.com/extra-ref.png'],\n      generationOptions: { resolution: '2048x1152' },\n    })\n\n    const result = await handleModifyAssetImageTask(job)\n    expect(result).toEqual({\n      type: 'storyboard',\n      panelId: 'panel-1',\n      imageUrl: 'cos/new-image.png',\n    })\n\n    expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(\n      expect.anything(),\n      expect.objectContaining({\n        options: expect.objectContaining({\n          aspectRatio: '16:9',\n          resolution: '2048x1152',\n          referenceImages: [\n            'base64-required-reference',\n            'normalized-reference-image',\n          ],\n        }),\n      }),\n    )\n\n    const panelUpdateCall = prismaMock.novelPromotionPanel.update.mock.calls.at(-1) as [unknown] | undefined\n    const updateArg = panelUpdateCall?.[0]\n    const updateData = readUpdateData(updateArg)\n    expect(updateData.previousImageUrl).toBe('cos/panel-old.png')\n    expect(updateData.imageUrl).toBe('cos/new-image.png')\n    expect(updateData.candidateImages).toBeNull()\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/image-worker.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\ntype WorkerProcessor = (job: Job<TaskJobData>) => Promise<unknown>\n\nconst workerState = vi.hoisted(() => ({\n  processor: null as WorkerProcessor | null,\n}))\n\nconst sharedMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: WorkerProcessor) => await handler(job)),\n}))\n\nconst handlerMock = vi.hoisted(() => ({\n  handleAssetHubImageTask: vi.fn(async () => ({ ok: true })),\n  handleAssetHubModifyTask: vi.fn(async () => ({ ok: true })),\n  handleCharacterImageTask: vi.fn(async () => ({ ok: true })),\n  handleLocationImageTask: vi.fn(async () => ({ ok: true })),\n  handleModifyAssetImageTask: vi.fn(async () => ({ ok: true })),\n  handlePanelImageTask: vi.fn(async () => ({ ok: true })),\n  handlePanelVariantTask: vi.fn(async () => ({ ok: true })),\n}))\n\nconst configServiceMock = vi.hoisted(() => ({\n  getUserWorkflowConcurrencyConfig: vi.fn(async () => ({\n    analysis: 5,\n    image: 5,\n    video: 5,\n  })),\n}))\n\nconst gateMock = vi.hoisted(() => ({\n  withUserConcurrencyGate: vi.fn(async <T>(input: {\n    run: () => Promise<T>\n  }) => await input.run()),\n}))\n\nvi.mock('bullmq', () => ({\n  Queue: class {\n    constructor(_name: string) {}\n\n    async add() {\n      return { id: 'job-1' }\n    }\n\n    async getJob() {\n      return null\n    }\n  },\n  Worker: class {\n    constructor(_name: string, processor: WorkerProcessor) {\n      workerState.processor = processor\n    }\n  },\n}))\n\nvi.mock('@/lib/redis', () => ({ queueRedis: {} }))\nvi.mock('@/lib/workers/shared', () => sharedMock)\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/workers/user-concurrency-gate', () => gateMock)\nvi.mock('@/lib/workers/handlers/image-task-handlers', () => handlerMock)\n\nfunction buildJob(type: TaskJobData['type']): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-image-1',\n      type,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      payload: {},\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker image concurrency behavior', () => {\n  beforeEach(async () => {\n    vi.clearAllMocks()\n    workerState.processor = null\n\n    const mod = await import('@/lib/workers/image.worker')\n    mod.createImageWorker()\n  })\n\n  it('reads user image concurrency and applies gate before processing', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    const job = buildJob(TASK_TYPE.IMAGE_PANEL)\n    await processor!(job)\n\n    expect(configServiceMock.getUserWorkflowConcurrencyConfig).toHaveBeenCalledWith('user-1')\n    expect(gateMock.withUserConcurrencyGate).toHaveBeenCalledWith(expect.objectContaining({\n      scope: 'image',\n      userId: 'user-1',\n      limit: 5,\n    }))\n    expect(handlerMock.handlePanelImageTask).toHaveBeenCalledWith(job)\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/llm-proxy.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { describe, expect, it } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\nimport { handleLLMProxyTask, isLLMProxyTaskType } from '@/lib/workers/handlers/llm-proxy'\n\nfunction buildJob(type: TaskJobData['type']): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-llm-proxy-1',\n      type,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      payload: { episodeId: 'episode-1' },\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker llm-proxy behavior', () => {\n  it('current route map has no enabled proxy task type', () => {\n    expect(isLLMProxyTaskType(TASK_TYPE.STORY_TO_SCRIPT_RUN)).toBe(false)\n    expect(isLLMProxyTaskType(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)).toBe(false)\n  })\n\n  it('unsupported proxy task type -> explicit error', async () => {\n    const job = buildJob(TASK_TYPE.STORY_TO_SCRIPT_RUN)\n    await expect(handleLLMProxyTask(job)).rejects.toThrow('Unsupported llm proxy task type')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/llm-stream.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { Job } from 'bullmq'\nimport type { TaskJobData } from '@/lib/task/types'\n\nconst reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))\nconst reportTaskStreamChunkMock = vi.hoisted(() => vi.fn(async () => undefined))\nconst assertTaskActiveMock = vi.hoisted(() => vi.fn(async () => undefined))\nconst isTaskActiveMock = vi.hoisted(() => vi.fn(async () => true))\n\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: reportTaskProgressMock,\n  reportTaskStreamChunk: reportTaskStreamChunkMock,\n}))\n\nvi.mock('@/lib/workers/utils', () => ({\n  assertTaskActive: assertTaskActiveMock,\n}))\n\nvi.mock('@/lib/task/service', () => ({\n  isTaskActive: isTaskActiveMock,\n}))\n\nimport { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from '@/lib/workers/handlers/llm-stream'\n\nfunction buildJob(): Job<TaskJobData> {\n  const data: TaskJobData = {\n    taskId: 'task-1',\n    type: 'story_to_script_run',\n    locale: 'zh',\n    projectId: 'project-1',\n    userId: 'user-1',\n    targetType: 'NovelPromotionEpisode',\n    targetId: 'episode-1',\n    payload: {},\n    trace: null,\n  }\n  return {\n    data,\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('createWorkerLLMStreamCallbacks', () => {\n  beforeEach(() => {\n    reportTaskProgressMock.mockReset()\n    reportTaskStreamChunkMock.mockReset()\n    assertTaskActiveMock.mockReset()\n    isTaskActiveMock.mockReset()\n    isTaskActiveMock.mockResolvedValue(true)\n  })\n\n  it('publishes final step output on onComplete for replay recovery', async () => {\n    const job = buildJob()\n    const context = createWorkerLLMStreamContext(job, 'story_to_script')\n    const callbacks = createWorkerLLMStreamCallbacks(job, context)\n\n    expect(callbacks.onStage).toBeTruthy()\n    callbacks.onStage?.({\n      stage: 'streaming',\n      provider: 'ark',\n      step: {\n        id: 'screenplay_clip_1',\n        attempt: 2,\n        title: 'progress.streamStep.screenplayConversion',\n        index: 1,\n        total: 1,\n      },\n    })\n    expect(callbacks.onComplete).toBeTruthy()\n    callbacks.onComplete?.('final screenplay text', {\n      id: 'screenplay_clip_1',\n      attempt: 2,\n      title: 'progress.streamStep.screenplayConversion',\n      index: 1,\n      total: 1,\n    })\n    await callbacks.flush()\n\n    const finalProgressCall = reportTaskProgressMock.mock.calls.find((call) => {\n      const payload = (call as unknown as [unknown, unknown, Record<string, unknown> | undefined])[2]\n      return payload?.stage === 'worker_llm_complete'\n    })\n\n    expect(finalProgressCall).toBeDefined()\n    const payload = (finalProgressCall as unknown as [unknown, unknown, Record<string, unknown>])[2]\n    expect(payload.done).toBe(true)\n    expect(payload.output).toBe('final screenplay text')\n    expect(payload.stepId).toBe('screenplay_clip_1')\n    expect(payload.stepAttempt).toBe(2)\n    expect(payload.stepTitle).toBe('progress.streamStep.screenplayConversion')\n    expect(payload.stepIndex).toBe(1)\n    expect(payload.stepTotal).toBe(1)\n  })\n\n  it('keeps completion payload bound to provided step under interleaved steps', async () => {\n    const job = buildJob()\n    const context = createWorkerLLMStreamContext(job, 'story_to_script')\n    const callbacks = createWorkerLLMStreamCallbacks(job, context)\n\n    expect(callbacks.onChunk).toBeTruthy()\n    callbacks.onChunk?.({\n      kind: 'text',\n      delta: 'A-',\n      seq: 1,\n      lane: 'main',\n      step: { id: 'analyze_characters', attempt: 1, title: 'A', index: 1, total: 2 },\n    })\n    callbacks.onChunk?.({\n      kind: 'text',\n      delta: 'B-',\n      seq: 1,\n      lane: 'main',\n      step: { id: 'analyze_locations', attempt: 1, title: 'B', index: 2, total: 2 },\n    })\n    expect(callbacks.onComplete).toBeTruthy()\n    callbacks.onComplete?.('characters-final', {\n      id: 'analyze_characters',\n      attempt: 1,\n      title: 'A',\n      index: 1,\n      total: 2,\n    })\n    await callbacks.flush()\n\n    const finalProgressCall = reportTaskProgressMock.mock.calls.find((call) => {\n      const payload = (call as unknown as [unknown, unknown, Record<string, unknown> | undefined])[2]\n      return payload?.stage === 'worker_llm_complete'\n    })\n\n    expect(finalProgressCall).toBeDefined()\n    const payload = (finalProgressCall as unknown as [unknown, unknown, Record<string, unknown>])[2]\n    expect(payload.stepId).toBe('analyze_characters')\n    expect(payload.stepTitle).toBe('A')\n    expect(payload.output).toBe('characters-final')\n  })\n\n  it('uses injected active controller for run-owned workflows', async () => {\n    const job = buildJob()\n    const context = createWorkerLLMStreamContext(job, 'story_to_script')\n    const assertActive = vi.fn(async (_stage: string) => undefined)\n    const isActive = vi.fn(async () => true)\n    const callbacks = createWorkerLLMStreamCallbacks(job, context, {\n      assertActive,\n      isActive,\n    })\n\n    callbacks.onChunk?.({\n      kind: 'text',\n      delta: 'hello',\n      seq: 1,\n      lane: 'main',\n      step: { id: 'split_clips', attempt: 1, title: 'split', index: 1, total: 1 },\n    })\n    await callbacks.flush()\n\n    expect(assertActive).toHaveBeenCalledWith('worker_llm_stream')\n    expect(assertTaskActiveMock).not.toHaveBeenCalled()\n    expect(reportTaskStreamChunkMock).toHaveBeenCalledWith(\n      expect.anything(),\n      expect.objectContaining({\n        delta: 'hello',\n      }),\n      expect.objectContaining({\n        stepId: 'split_clips',\n      }),\n    )\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/location-image-task-handler.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { getArtStylePrompt } from '@/lib/constants'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => undefined),\n  getProjectModels: vi.fn(async () => ({ locationModel: 'location-model-1', artStyle: 'japanese-anime' })),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  locationImage: {\n    findUnique: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  novelPromotionLocation: {\n    findUnique: vi.fn(),\n    findMany: vi.fn(async () => []),\n  },\n}))\n\nconst sharedMock = vi.hoisted(() => ({\n  generateLabeledImageToCos: vi.fn(async () => 'cos/location-generated-1.png'),\n}))\n\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))\nvi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(\n    '@/lib/workers/handlers/image-task-handler-shared',\n  )\n  return {\n    ...actual,\n    generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,\n  }\n})\n\nimport { handleLocationImageTask } from '@/lib/workers/handlers/location-image-task-handler'\n\nfunction buildJob(payload: Record<string, unknown>, targetId = 'location-image-1'): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-location-image-1',\n      type: TASK_TYPE.IMAGE_LOCATION,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: null,\n      targetType: 'LocationImage',\n      targetId,\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker location-image-task-handler behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.locationImage.findUnique.mockResolvedValue({\n      id: 'location-image-1',\n      locationId: 'location-1',\n      imageIndex: 0,\n      description: '雨夜街道',\n      location: { name: 'Old Town' },\n    })\n\n    prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({\n      id: 'location-1',\n      name: 'Old Town',\n      images: [\n        {\n          id: 'location-image-1',\n          locationId: 'location-1',\n          imageIndex: 0,\n          description: '雨夜街道',\n        },\n      ],\n    })\n  })\n\n  it('locationModel missing -> explicit error', async () => {\n    utilsMock.getProjectModels.mockResolvedValueOnce({ locationModel: '', artStyle: 'japanese-anime' })\n    await expect(handleLocationImageTask(buildJob({}))).rejects.toThrow('Location model not configured')\n  })\n\n  it('success path -> generates and persists concrete location image url', async () => {\n    const result = await handleLocationImageTask(buildJob({ imageIndex: 0 }))\n    const animeStylePrompt = getArtStylePrompt('japanese-anime', 'zh')\n\n    expect(result).toEqual({\n      updated: 1,\n      locationIds: ['location-1'],\n    })\n\n    expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(\n      expect.objectContaining({\n        prompt: `雨夜街道，${animeStylePrompt}`,\n        label: 'Old Town',\n        targetId: 'location-image-1',\n        options: expect.objectContaining({ aspectRatio: '1:1' }),\n      }),\n    )\n    const generationCall = sharedMock.generateLabeledImageToCos.mock.calls[0] as unknown as [{ prompt: string }] | undefined\n    expect(generationCall).toBeTruthy()\n    if (!generationCall) throw new Error('expected generateLabeledImageToCos call')\n    const generationInput = generationCall[0]\n    expect(generationInput.prompt.split(animeStylePrompt).length - 1).toBe(1)\n\n    expect(prismaMock.locationImage.update).toHaveBeenCalledWith({\n      where: { id: 'location-image-1' },\n      data: { imageUrl: 'cos/location-generated-1.png' },\n    })\n  })\n\n  it('payload artStyle overrides project artStyle in prompt', async () => {\n    await handleLocationImageTask(buildJob({ imageIndex: 0, artStyle: 'realistic' }))\n\n    expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(\n      expect.objectContaining({\n        prompt: `雨夜街道，${getArtStylePrompt('realistic', 'zh')}`,\n      }),\n    )\n  })\n\n  it('invalid payload artStyle -> explicit error', async () => {\n    await expect(handleLocationImageTask(buildJob({ imageIndex: 0, artStyle: 'anime' }))).rejects.toThrow(\n      'Invalid artStyle in IMAGE_LOCATION payload',\n    )\n  })\n\n  it('honors requested count when location already has more slots', async () => {\n    prismaMock.locationImage.findUnique.mockResolvedValueOnce(null)\n    prismaMock.novelPromotionLocation.findUnique.mockResolvedValueOnce({\n      id: 'location-1',\n      name: 'Old Town',\n      images: [\n        { id: 'location-image-1', locationId: 'location-1', imageIndex: 0, description: '雨夜街道 A' },\n        { id: 'location-image-2', locationId: 'location-1', imageIndex: 1, description: '雨夜街道 B' },\n        { id: 'location-image-3', locationId: 'location-1', imageIndex: 2, description: '雨夜街道 C' },\n      ],\n    })\n\n    const result = await handleLocationImageTask(buildJob({ locationId: 'location-1', count: 1 }, 'location-1'))\n\n    expect(result).toEqual({\n      updated: 1,\n      locationIds: ['location-1'],\n    })\n    expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledTimes(1)\n    expect(prismaMock.locationImage.update).toHaveBeenCalledTimes(1)\n    expect(prismaMock.locationImage.update).toHaveBeenCalledWith({\n      where: { id: 'location-image-1' },\n      data: { imageUrl: 'cos/location-generated-1.png' },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/modify-image-reference-description.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => {}),\n  getProjectModels: vi.fn(async () => ({ editModel: 'edit-model' })),\n  getUserModels: vi.fn(async () => ({ editModel: 'edit-model', analysisModel: 'analysis-model' })),\n  resolveImageSourceFromGeneration: vi.fn(async () => 'generated-image-source'),\n  stripLabelBar: vi.fn(async () => 'required-reference-image'),\n  toSignedUrlIfCos: vi.fn(() => 'https://signed/current-image.png'),\n  uploadImageSourceToCos: vi.fn(async () => 'cos/new-image.png'),\n  withLabelBar: vi.fn(async (source: unknown) => source),\n}))\n\nconst outboundImageMock = vi.hoisted(() => ({\n  normalizeReferenceImagesForGeneration: vi.fn(async (input?: string[]) => input?.map((item) => item.trim()) || []),\n  normalizeToBase64ForGeneration: vi.fn(async () => 'base64-reference'),\n}))\n\nconst aiRuntimeMock = vi.hoisted(() => ({\n  executeAiTextStep: vi.fn(async () => ({ text: '{\"prompt\":\"TEXT_UPDATED_DESCRIPTION\"}' })),\n  executeAiVisionStep: vi.fn(async () => ({ text: '{\"prompt\":\"VISION_UPDATED_DESCRIPTION\"}' })),\n}))\n\nconst promptMock = vi.hoisted(() => ({\n  PROMPT_IDS: {\n    NP_CHARACTER_DESCRIPTION_UPDATE: 'np_character_description_update',\n    NP_LOCATION_DESCRIPTION_UPDATE: 'np_location_description_update',\n  },\n  buildPrompt: vi.fn(({ promptId }: { promptId: string }) => `${promptId}-prompt`),\n}))\n\nconst loggerWarnMock = vi.hoisted(() => vi.fn())\nconst loggingMock = vi.hoisted(() => ({\n  createScopedLogger: vi.fn(() => ({\n    warn: loggerWarnMock,\n  })),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  characterAppearance: {\n    findUnique: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  locationImage: {\n    findUnique: vi.fn(),\n    findFirst: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  novelPromotionPanel: {\n    findUnique: vi.fn(),\n    findFirst: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n  novelPromotionProject: {\n    findUnique: vi.fn(),\n  },\n  globalCharacter: {\n    findFirst: vi.fn(),\n  },\n  globalCharacterAppearance: {\n    update: vi.fn(async () => ({})),\n  },\n  globalLocation: {\n    findFirst: vi.fn(),\n  },\n  globalLocationImage: {\n    update: vi.fn(async () => ({})),\n  },\n}))\n\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/media/outbound-image', () => outboundImageMock)\nvi.mock('@/lib/ai-runtime', () => aiRuntimeMock)\nvi.mock('@/lib/prompt-i18n', () => promptMock)\nvi.mock('@/lib/logging/core', () => loggingMock)\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nimport { handleModifyAssetImageTask } from '@/lib/workers/handlers/image-task-handlers-core'\nimport { handleAssetHubModifyTask } from '@/lib/workers/handlers/asset-hub-modify-task-handler'\n\nfunction buildJob(type: TaskType, payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-1',\n      type,\n      locale: 'zh',\n      projectId: 'project-1',\n      targetType: 'GlobalCharacter',\n      targetId: 'target-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\nfunction getUpdateData(callArg: unknown): Record<string, unknown> {\n  if (!callArg || typeof callArg !== 'object') return {}\n  const maybeData = (callArg as { data?: unknown }).data\n  if (!maybeData || typeof maybeData !== 'object') return {}\n  return maybeData as Record<string, unknown>\n}\n\ndescribe('modify image syncs descriptions after edit', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.characterAppearance.findUnique.mockResolvedValue({\n      id: 'appearance-1',\n      imageUrls: JSON.stringify(['cos/original-image.png', 'cos/original-image-2.png']),\n      imageUrl: 'cos/original-image.png',\n      selectedIndex: 1,\n      changeReason: 'base',\n      description: 'old primary description',\n      descriptions: JSON.stringify(['old primary description', 'old variant description']),\n      character: { name: 'Hero' },\n    })\n\n    prismaMock.locationImage.findFirst.mockResolvedValue({\n      id: 'location-image-1',\n      locationId: 'location-1',\n      description: 'old location description',\n      imageUrl: 'cos/original-location.png',\n      previousDescription: null,\n      location: { name: 'Old Town' },\n    })\n\n    prismaMock.globalCharacter.findFirst.mockResolvedValue({\n      id: 'global-character-1',\n      name: 'Hero',\n      appearances: [\n        {\n          id: 'global-appearance-1',\n          appearanceIndex: 0,\n          changeReason: 'base',\n          description: 'global primary description',\n          descriptions: JSON.stringify(['global primary description', 'global variant description']),\n          imageUrl: 'cos/original-global.png',\n          imageUrls: JSON.stringify(['cos/original-global.png', 'cos/original-global-2.png']),\n          selectedIndex: 1,\n          previousDescription: null,\n          previousDescriptions: null,\n        },\n      ],\n    })\n\n    prismaMock.globalLocation.findFirst.mockResolvedValue({\n      id: 'global-location-1',\n      name: 'Old Town',\n      images: [\n        {\n          id: 'global-location-image-1',\n          imageIndex: 0,\n          description: 'global location description',\n          imageUrl: 'cos/original-global-location.png',\n          previousDescription: null,\n        },\n      ],\n    })\n  })\n\n  it('syncs project character descriptions for pure text edits', async () => {\n    const job = buildJob(TASK_TYPE.MODIFY_ASSET_IMAGE, {\n      type: 'character',\n      appearanceId: 'appearance-1',\n      imageIndex: 1,\n      modifyPrompt: '给角色增加更复杂的甲胄细节',\n    })\n\n    await handleModifyAssetImageTask(job)\n\n    expect(aiRuntimeMock.executeAiTextStep).toHaveBeenCalledTimes(1)\n    expect(aiRuntimeMock.executeAiVisionStep).not.toHaveBeenCalled()\n\n    const characterUpdateCall = prismaMock.characterAppearance.update.mock.calls.at(-1) as [unknown] | undefined\n    const updateArg = characterUpdateCall?.[0]\n    const updateData = getUpdateData(updateArg)\n    expect(updateData.previousDescription).toBe('old primary description')\n    expect(updateData.previousDescriptions).toBe(JSON.stringify(['old primary description', 'old variant description']))\n    expect(updateData.description).toBe('old primary description')\n    expect(updateData.descriptions).toBe(JSON.stringify(['old primary description', 'TEXT_UPDATED_DESCRIPTION']))\n    expect(updateData.imageUrl).toBe('cos/new-image.png')\n  })\n\n  it('syncs asset-hub character descriptions for reference-image edits and preserves sibling variants', async () => {\n    utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/new-global-image.png')\n\n    const job = buildJob(TASK_TYPE.ASSET_HUB_MODIFY, {\n      type: 'character',\n      id: 'global-character-1',\n      appearanceIndex: 0,\n      imageIndex: 1,\n      modifyPrompt: '把服装改成更锐利的深色铠甲',\n      extraImageUrls: ['https://ref.example/b.png'],\n    })\n\n    await handleAssetHubModifyTask(job)\n\n    expect(aiRuntimeMock.executeAiVisionStep).toHaveBeenCalledTimes(1)\n\n    const globalCharacterUpdateCall = prismaMock.globalCharacterAppearance.update.mock.calls.at(-1) as [unknown] | undefined\n    const updateArg = globalCharacterUpdateCall?.[0]\n    const updateData = getUpdateData(updateArg)\n    expect(updateData.previousDescription).toBe('global primary description')\n    expect(updateData.previousDescriptions).toBe(JSON.stringify(['global primary description', 'global variant description']))\n    expect(updateData.description).toBe('global primary description')\n    expect(updateData.descriptions).toBe(JSON.stringify(['global primary description', 'VISION_UPDATED_DESCRIPTION']))\n    expect(updateData.imageUrl).toBe('cos/new-global-image.png')\n    expect(updateData.imageUrls).toBe(JSON.stringify(['cos/original-global.png', 'cos/new-global-image.png']))\n  })\n\n  it('syncs project location descriptions for pure text edits', async () => {\n    aiRuntimeMock.executeAiTextStep.mockResolvedValueOnce({ text: '{\"prompt\":\"TEXT_UPDATED_LOCATION\"}' })\n\n    const job = buildJob(TASK_TYPE.MODIFY_ASSET_IMAGE, {\n      type: 'location',\n      locationId: 'location-1',\n      imageIndex: 0,\n      modifyPrompt: '增加更浓的晨雾和老城石墙细节',\n    })\n\n    await handleModifyAssetImageTask(job)\n\n    const locationUpdateCall = prismaMock.locationImage.update.mock.calls.at(-1) as [unknown] | undefined\n    const updateArg = locationUpdateCall?.[0]\n    const updateData = getUpdateData(updateArg)\n    expect(updateData.previousDescription).toBe('old location description')\n    expect(updateData.description).toBe('TEXT_UPDATED_LOCATION')\n    expect(updateData.imageUrl).toBe('cos/new-image.png')\n  })\n\n  it('syncs asset-hub location descriptions for reference-image edits', async () => {\n    utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/new-global-location-image.png')\n    aiRuntimeMock.executeAiVisionStep.mockResolvedValueOnce({ text: '{\"prompt\":\"VISION_UPDATED_LOCATION\"}' })\n\n    const job = buildJob(TASK_TYPE.ASSET_HUB_MODIFY, {\n      type: 'location',\n      id: 'global-location-1',\n      imageIndex: 0,\n      modifyPrompt: '改成潮湿阴冷的石砌街道',\n      extraImageUrls: ['https://ref.example/location.png'],\n    })\n\n    await handleAssetHubModifyTask(job)\n\n    const globalLocationUpdateCall = prismaMock.globalLocationImage.update.mock.calls.at(-1) as [unknown] | undefined\n    const updateArg = globalLocationUpdateCall?.[0]\n    const updateData = getUpdateData(updateArg)\n    expect(updateData.previousDescription).toBe('global location description')\n    expect(updateData.description).toBe('VISION_UPDATED_LOCATION')\n    expect(updateData.imageUrl).toBe('cos/new-global-location-image.png')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/panel-image-task-handler.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionPanel: {\n    findUnique: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n}))\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => undefined),\n  getProjectModels: vi.fn(async () => ({ storyboardModel: 'storyboard-model-1', artStyle: 'realistic' })),\n  resolveImageSourceFromGeneration: vi.fn(),\n  uploadImageSourceToCos: vi.fn(),\n}))\n\nconst sharedMock = vi.hoisted(() => ({\n  collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-1.png']),\n  resolveNovelData: vi.fn(async () => ({\n    videoRatio: '16:9',\n    characters: [],\n    locations: [],\n  })),\n}))\n\nconst outboundMock = vi.hoisted(() => ({\n  normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-ref-1']),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/media/outbound-image', () => outboundMock)\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))\nvi.mock('@/lib/logging/core', () => ({\n  logInfo: vi.fn(),\n  createScopedLogger: vi.fn(() => ({\n    debug: vi.fn(),\n    info: vi.fn(),\n    warn: vi.fn(),\n    error: vi.fn(),\n    event: vi.fn(),\n    child: vi.fn(),\n  })),\n}))\nvi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(\n    '@/lib/workers/handlers/image-task-handler-shared',\n  )\n  return {\n    ...actual,\n    collectPanelReferenceImages: sharedMock.collectPanelReferenceImages,\n    resolveNovelData: sharedMock.resolveNovelData,\n  }\n})\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_SINGLE_PANEL_IMAGE: 'np_single_panel_image' },\n  buildPrompt: vi.fn(() => 'panel-image-prompt'),\n}))\n\nimport { handlePanelImageTask } from '@/lib/workers/handlers/panel-image-task-handler'\n\nfunction buildJob(payload: Record<string, unknown>, targetId = 'panel-1'): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-panel-image-1',\n      type: TASK_TYPE.IMAGE_PANEL,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId,\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker panel-image-task-handler behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({\n      id: 'panel-1',\n      storyboardId: 'storyboard-1',\n      panelIndex: 0,\n      shotType: 'close-up',\n      cameraMove: 'static',\n      description: 'hero close-up',\n      videoPrompt: 'dramatic',\n      location: 'Old Town',\n      characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),\n      srtSegment: '台词片段',\n      photographyRules: null,\n      actingNotes: null,\n      sketchImageUrl: null,\n      imageUrl: null,\n    })\n\n    utilsMock.resolveImageSourceFromGeneration\n      .mockResolvedValueOnce('generated-source-1')\n      .mockResolvedValueOnce('generated-source-2')\n\n    utilsMock.uploadImageSourceToCos\n      .mockResolvedValueOnce('cos/panel-candidate-1.png')\n      .mockResolvedValueOnce('cos/panel-candidate-2.png')\n  })\n\n  it('missing panelId -> explicit error', async () => {\n    const job = buildJob({}, '')\n    await expect(handlePanelImageTask(job)).rejects.toThrow('panelId missing')\n  })\n\n  it('first generation -> persists main image and candidate list', async () => {\n    const job = buildJob({ candidateCount: 2 })\n    const result = await handlePanelImageTask(job)\n\n    expect(result).toEqual({\n      panelId: 'panel-1',\n      candidateCount: 2,\n      imageUrl: 'cos/panel-candidate-1.png',\n    })\n\n    expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(\n      expect.anything(),\n      expect.objectContaining({\n        modelId: 'storyboard-model-1',\n        prompt: 'panel-image-prompt',\n        allowTaskExternalIdResume: false,\n        options: expect.objectContaining({\n          referenceImages: ['normalized-ref-1'],\n          aspectRatio: '16:9',\n        }),\n      }),\n    )\n\n    expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({\n      where: { id: 'panel-1' },\n      data: {\n        imageUrl: 'cos/panel-candidate-1.png',\n        candidateImages: JSON.stringify(['cos/panel-candidate-1.png', 'cos/panel-candidate-2.png']),\n      },\n    })\n  })\n\n  it('regeneration branch -> keeps old image in previousImageUrl and stores candidates only', async () => {\n    utilsMock.resolveImageSourceFromGeneration.mockReset()\n    utilsMock.uploadImageSourceToCos.mockReset()\n\n    prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce({\n      id: 'panel-1',\n      storyboardId: 'storyboard-1',\n      panelIndex: 0,\n      shotType: 'close-up',\n      cameraMove: 'static',\n      description: 'hero close-up',\n      videoPrompt: 'dramatic',\n      location: 'Old Town',\n      characters: '[]',\n      srtSegment: null,\n      photographyRules: null,\n      actingNotes: null,\n      sketchImageUrl: null,\n      imageUrl: 'cos/panel-old.png',\n    })\n\n    utilsMock.resolveImageSourceFromGeneration.mockResolvedValueOnce('generated-source-regen')\n    utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/panel-regenerated.png')\n\n    const job = buildJob({ candidateCount: 1 })\n    const result = await handlePanelImageTask(job)\n\n    expect(result).toEqual({\n      panelId: 'panel-1',\n      candidateCount: 1,\n      imageUrl: null,\n    })\n\n    expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({\n      where: { id: 'panel-1' },\n      data: {\n        previousImageUrl: 'cos/panel-old.png',\n        candidateImages: JSON.stringify(['cos/panel-regenerated.png']),\n      },\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/panel-variant-task-handler.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionPanel: {\n    findUnique: vi.fn(),\n    update: vi.fn(async () => ({})),\n  },\n}))\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => undefined),\n  getProjectModels: vi.fn(async () => ({ storyboardModel: 'storyboard-model-1', artStyle: 'realistic' })),\n  resolveImageSourceFromGeneration: vi.fn(async () => 'generated-variant-source'),\n  toSignedUrlIfCos: vi.fn((url: string | null | undefined) => (url ? `https://signed.example/${url}` : null)),\n  uploadImageSourceToCos: vi.fn(async () => 'cos/panel-variant-new.png'),\n}))\n\nconst sharedMock = vi.hoisted(() => ({\n  collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-character.png']),\n  resolveNovelData: vi.fn(async () => ({\n    videoRatio: '16:9',\n    characters: [{\n      name: 'Hero',\n      introduction: '主角',\n      appearances: [{\n        changeReason: 'default',\n        imageUrls: JSON.stringify(['cos/hero-default.png']),\n        imageUrl: 'cos/hero-default.png',\n      }],\n    }],\n    locations: [{ name: 'Old Town', images: [] }],\n  })),\n}))\n\nconst outboundMock = vi.hoisted(() => ({\n  normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)),\n}))\n\nconst promptMock = vi.hoisted(() => ({\n  buildPrompt: vi.fn(() => 'panel-variant-prompt'),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/media/outbound-image', () => outboundMock)\nvi.mock('@/lib/logging/core', () => ({ logInfo: vi.fn() }))\nvi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {\n  const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(\n    '@/lib/workers/handlers/image-task-handler-shared',\n  )\n  return {\n    ...actual,\n    collectPanelReferenceImages: sharedMock.collectPanelReferenceImages,\n    resolveNovelData: sharedMock.resolveNovelData,\n  }\n})\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_GENERATE: 'np_agent_shot_variant_generate' },\n  buildPrompt: promptMock.buildPrompt,\n}))\n\nimport { handlePanelVariantTask } from '@/lib/workers/handlers/panel-variant-task-handler'\n\nfunction buildJob(payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-panel-variant-1',\n      type: TASK_TYPE.PANEL_VARIANT,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-new',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker panel-variant-task-handler behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.novelPromotionPanel.findUnique.mockImplementation(async (args: { where: { id: string } }) => {\n      if (args.where.id === 'panel-new') {\n        return {\n          id: 'panel-new',\n          storyboardId: 'storyboard-1',\n          imageUrl: null,\n          location: 'Old Town',\n          characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),\n        }\n      }\n      if (args.where.id === 'panel-source') {\n        return {\n          id: 'panel-source',\n          storyboardId: 'storyboard-1',\n          imageUrl: 'cos/panel-source.png',\n          description: 'source description',\n          shotType: 'medium',\n          cameraMove: 'pan',\n          location: 'Old Town',\n          characters: JSON.stringify([{ name: 'Hero' }]),\n        }\n      }\n      return null\n    })\n  })\n\n  it('missing source/new panel ids -> explicit error', async () => {\n    const job = buildJob({})\n    await expect(handlePanelVariantTask(job)).rejects.toThrow('panel_variant missing newPanelId/sourcePanelId')\n  })\n\n  it('success path -> includes source panel image in referenceImages and persists new image', async () => {\n    const payload = {\n      newPanelId: 'panel-new',\n      sourcePanelId: 'panel-source',\n      variant: {\n        title: '雨夜版本',\n        description: '加强雨夜氛围',\n      },\n    }\n\n    const result = await handlePanelVariantTask(buildJob(payload))\n\n    expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(\n      expect.anything(),\n      expect.objectContaining({\n        modelId: 'storyboard-model-1',\n        prompt: 'panel-variant-prompt',\n        options: expect.objectContaining({\n          aspectRatio: '16:9',\n          referenceImages: [\n            'normalized:https://signed.example/cos/panel-source.png',\n            'normalized:https://signed.example/cos/hero-default.png',\n          ],\n        }),\n      }),\n    )\n\n    expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({\n      where: { id: 'panel-new' },\n      data: { imageUrl: 'cos/panel-variant-new.png' },\n    })\n\n    expect(result).toEqual({\n      panelId: 'panel-new',\n      storyboardId: 'storyboard-1',\n      imageUrl: 'cos/panel-variant-new.png',\n    })\n  })\n\n  it('respects reference asset toggles when character/location assets are disabled', async () => {\n    const payload = {\n      newPanelId: 'panel-new',\n      sourcePanelId: 'panel-source',\n      includeCharacterAssets: false,\n      includeLocationAsset: false,\n      variant: {\n        title: '禁用资产版本',\n        description: '只参考原镜头',\n        video_prompt: '只参考原镜头',\n      },\n    }\n\n    await handlePanelVariantTask(buildJob(payload))\n\n    expect(outboundMock.normalizeReferenceImagesForGeneration).toHaveBeenCalledWith([\n      'https://signed.example/cos/panel-source.png',\n    ])\n    expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({\n      variables: expect.objectContaining({\n        character_assets: '未使用角色参考图',\n        location_asset: '未使用场景参考图',\n      }),\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/reference-to-character.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { CHARACTER_PROMPT_SUFFIX, CHARACTER_IMAGE_BANANA_RATIO } from '@/lib/constants'\nimport { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'\n\nconst sharpMock = vi.hoisted(() =>\n  vi.fn(() => {\n    const chain = {\n      metadata: vi.fn(async () => ({ width: 2160, height: 2160 })),\n      extend: vi.fn(() => chain),\n      composite: vi.fn(() => chain),\n      jpeg: vi.fn(() => chain),\n      toBuffer: vi.fn(async () => Buffer.from('processed-image')),\n    }\n    return chain\n  }),\n)\n\nconst generatorApiMock = vi.hoisted(() => ({\n  generateImage: vi.fn<(userId: string, modelId: string, prompt: string, options?: Record<string, unknown>) => Promise<{\n    success: boolean\n    imageUrl: string\n    async: boolean\n  }>>(async () => ({\n    success: true,\n    imageUrl: 'https://example.com/generated.jpg',\n    async: false,\n  })),\n}))\n\nconst asyncSubmitMock = vi.hoisted(() => ({\n  queryFalStatus: vi.fn(async () => ({ completed: false, failed: false, resultUrl: null })),\n}))\n\nconst arkApiMock = vi.hoisted(() => ({\n  fetchWithTimeoutAndRetry: vi.fn(async () => ({\n    arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,\n  })),\n}))\n\nconst apiConfigMock = vi.hoisted(() => ({\n  getProviderConfig: vi.fn(async () => ({ apiKey: 'fal-key' })),\n}))\n\nconst configServiceMock = vi.hoisted(() => ({\n  getUserModelConfig: vi.fn(async () => ({\n    characterModel: 'character-model-1',\n    analysisModel: 'analysis-model-1',\n  })),\n}))\n\nconst llmClientMock = vi.hoisted(() => ({\n  chatCompletionWithVision: vi.fn(async () => ({ output_text: 'AI_EXTRACTED_DESCRIPTION' })),\n  getCompletionContent: vi.fn(() => 'AI_EXTRACTED_DESCRIPTION'),\n}))\n\nconst cosMock = vi.hoisted(() => {\n  let keyIndex = 0\n  return {\n    generateUniqueKey: vi.fn(() => `reference-key-${++keyIndex}.jpg`),\n    getSignedUrl: vi.fn((key: string) => `https://signed.example/${key}`),\n    uploadObject: vi.fn(async (_buffer: Buffer, key: string) => `cos/${key}`),\n  }\n})\n\nconst fontsMock = vi.hoisted(() => ({\n  initializeFonts: vi.fn(async () => {}),\n  createLabelSVG: vi.fn(async () => Buffer.from('<svg />')),\n}))\n\nconst workersSharedMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => {}),\n}))\n\nconst workersUtilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => {}),\n}))\n\nconst promptI18nMock = vi.hoisted(() => ({\n  PROMPT_IDS: {\n    CHARACTER_IMAGE_TO_DESCRIPTION: 'character_image_to_description',\n    CHARACTER_REFERENCE_TO_SHEET: 'character_reference_to_sheet',\n  },\n  buildPrompt: vi.fn((input: { promptId: string }) => (\n    input.promptId === 'character_reference_to_sheet'\n      ? 'BASE_REFERENCE_PROMPT'\n      : 'ANALYSIS_PROMPT'\n  )),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  globalCharacterAppearance: {\n    update: vi.fn<(input: { data?: Record<string, unknown>; where?: Record<string, unknown> }) => Promise<Record<string, never>>>(\n      async () => ({}),\n    ),\n  },\n  characterAppearance: {\n    update: vi.fn(async () => ({})),\n  },\n}))\n\nvi.mock('sharp', () => ({\n  default: sharpMock,\n}))\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/generator-api', () => generatorApiMock)\nvi.mock('@/lib/async-submit', () => asyncSubmitMock)\nvi.mock('@/lib/ark-api', () => arkApiMock)\nvi.mock('@/lib/api-config', () => apiConfigMock)\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/llm-client', () => llmClientMock)\nvi.mock('@/lib/storage', () => cosMock)\nvi.mock('@/lib/fonts', () => fontsMock)\nvi.mock('@/lib/workers/shared', () => workersSharedMock)\nvi.mock('@/lib/workers/utils', () => workersUtilsMock)\nvi.mock('@/lib/prompt-i18n', () => promptI18nMock)\n\nimport { handleReferenceToCharacterTask } from '@/lib/workers/handlers/reference-to-character'\n\nfunction buildJob(payload: Record<string, unknown>, type: TaskType): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-1',\n      type,\n      locale: 'zh',\n      projectId: 'project-1',\n      targetType: 'GlobalCharacter',\n      targetId: 'target-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\nfunction readGenerateCall(index: number) {\n  const call = generatorApiMock.generateImage.mock.calls[index]\n  if (!call) {\n    return {\n      prompt: '',\n      options: {} as Record<string, unknown>,\n    }\n  }\n  const prompt = typeof call[2] === 'string' ? call[2] : ''\n  const options = (typeof call[3] === 'object' && call[3]) ? call[3] as Record<string, unknown> : {}\n  return { prompt, options }\n}\n\ndescribe('worker reference-to-character', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('fails fast when reference images are missing', async () => {\n    const job = buildJob({}, TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER)\n    await expect(handleReferenceToCharacterTask(job)).rejects.toThrow('Missing referenceImageUrl or referenceImageUrls')\n  })\n\n  it('fails fast on unsupported task type', async () => {\n    const job = buildJob(\n      { referenceImageUrl: 'https://example.com/ref.png' },\n      'unsupported-task' as TaskType,\n    )\n    await expect(handleReferenceToCharacterTask(job)).rejects.toThrow('Unsupported task type')\n  })\n\n  it('uses suffix prompt and disables reference-image injection when customDescription is provided', async () => {\n    const job = buildJob(\n      {\n        referenceImageUrls: ['https://example.com/ref-a.png', 'https://example.com/ref-b.png'],\n        customDescription: '冷静黑发角色',\n        characterName: 'Hero',\n      },\n      TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,\n    )\n\n    const result = await handleReferenceToCharacterTask(job)\n\n    expect(result).toEqual(expect.objectContaining({ success: true }))\n    expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)\n\n    const { prompt, options } = readGenerateCall(0)\n    expect(prompt).toContain('冷静黑发角色')\n    expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)\n    expect(options.aspectRatio).toBe(CHARACTER_IMAGE_BANANA_RATIO)\n    expect(options.referenceImages).toBeUndefined()\n  })\n\n  it('keeps three-view suffix in template flow and writes extracted description in background mode', async () => {\n    const job = buildJob(\n      {\n        referenceImageUrls: [' https://example.com/ref-a.png ', 'https://example.com/ref-b.png'],\n        isBackgroundJob: true,\n        characterId: 'character-1',\n        appearanceId: 'appearance-1',\n        characterName: 'Hero',\n      },\n      TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,\n    )\n\n    const result = await handleReferenceToCharacterTask(job)\n\n    expect(result).toEqual(expect.objectContaining({ success: true }))\n    expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)\n\n    const { prompt, options } = readGenerateCall(0)\n    expect(prompt).toContain('BASE_REFERENCE_PROMPT')\n    expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)\n    expect(options.referenceImages).toEqual(['https://example.com/ref-a.png', 'https://example.com/ref-b.png'])\n    expect(options.aspectRatio).toBe(CHARACTER_IMAGE_BANANA_RATIO)\n\n    const updateArg = prismaMock.globalCharacterAppearance.update.mock.calls[0]?.[0] as {\n      data?: Record<string, unknown>\n      where?: Record<string, unknown>\n    } | undefined\n    const updateData = updateArg?.data || {}\n    expect(updateArg?.where).toEqual({ id: 'appearance-1' })\n    expect(updateData.description).toBe('AI_EXTRACTED_DESCRIPTION')\n    expect(typeof updateData.imageUrls).toBe('string')\n    expect(updateData.imageUrl).toMatch(/^cos\\/reference-key-\\d+\\.jpg$/)\n  })\n\n  it('uses requested count when generating reference character sheets', async () => {\n    const job = buildJob(\n      {\n        referenceImageUrls: ['https://example.com/ref-a.png'],\n        characterName: 'Hero',\n        count: 5,\n      },\n      TASK_TYPE.REFERENCE_TO_CHARACTER,\n    )\n\n    const result = await handleReferenceToCharacterTask(job)\n\n    expect(result).toEqual(expect.objectContaining({ success: true }))\n    expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(5)\n    const cosKeys = (result as { cosKeys?: string[] }).cosKeys\n    expect(cosKeys).toHaveLength(5)\n    expect(cosKeys?.every((item) => item.startsWith('cos/reference-key-'))).toBe(true)\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/resolve-analysis-model.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst prismaMock = vi.hoisted(() => ({\n  userPreference: {\n    findUnique: vi.fn(),\n  },\n}))\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: prismaMock,\n}))\n\nimport { resolveAnalysisModel } from '@/lib/workers/handlers/resolve-analysis-model'\n\ndescribe('resolveAnalysisModel', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    prismaMock.userPreference.findUnique.mockResolvedValue({\n      analysisModel: 'openai-compatible:pref::gpt-4.1-mini',\n    })\n  })\n\n  it('uses inputModel override when provided', async () => {\n    const result = await resolveAnalysisModel({\n      userId: 'user-1',\n      inputModel: 'openai-compatible:input::gpt-4.1',\n      projectAnalysisModel: 'openai-compatible:project::gpt-4.1',\n    })\n\n    expect(result).toBe('openai-compatible:input::gpt-4.1')\n    expect(prismaMock.userPreference.findUnique).not.toHaveBeenCalled()\n  })\n\n  it('uses project analysisModel when inputModel is missing', async () => {\n    const result = await resolveAnalysisModel({\n      userId: 'user-1',\n      projectAnalysisModel: 'openai-compatible:project::gpt-4.1',\n    })\n\n    expect(result).toBe('openai-compatible:project::gpt-4.1')\n    expect(prismaMock.userPreference.findUnique).not.toHaveBeenCalled()\n  })\n\n  it('falls back to user preference analysisModel when project is missing', async () => {\n    const result = await resolveAnalysisModel({\n      userId: 'user-1',\n      projectAnalysisModel: null,\n    })\n\n    expect(result).toBe('openai-compatible:pref::gpt-4.1-mini')\n    expect(prismaMock.userPreference.findUnique).toHaveBeenCalledWith({\n      where: { userId: 'user-1' },\n      select: { analysisModel: true },\n    })\n  })\n\n  it('skips invalid input/project model keys and still falls back to user preference', async () => {\n    const result = await resolveAnalysisModel({\n      userId: 'user-1',\n      inputModel: 'gpt-4.1',\n      projectAnalysisModel: 'invalid-model-key',\n    })\n\n    expect(result).toBe('openai-compatible:pref::gpt-4.1-mini')\n    expect(prismaMock.userPreference.findUnique).toHaveBeenCalledTimes(1)\n  })\n\n  it('throws explicit error when all levels are missing', async () => {\n    prismaMock.userPreference.findUnique.mockResolvedValueOnce({ analysisModel: null })\n\n    await expect(resolveAnalysisModel({\n      userId: 'user-1',\n      inputModel: '',\n      projectAnalysisModel: null,\n    })).rejects.toThrow('ANALYSIS_MODEL_NOT_CONFIGURED')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/screenplay-convert.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  project: { findUnique: vi.fn() },\n  novelPromotionProject: { findUnique: vi.fn() },\n  novelPromotionEpisode: { findUnique: vi.fn() },\n  novelPromotionClip: { update: vi.fn(async () => ({})) },\n}))\n\nconst llmMock = vi.hoisted(() => ({\n  chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),\n  getCompletionContent: vi.fn(() => '{\"scenes\":[{\"index\":1}]}'),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nconst helpersMock = vi.hoisted(() => ({\n  parseScreenplayPayload: vi.fn(() => ({ scenes: [{ index: 1 }] })),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => llmMock)\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\nvi.mock('@/lib/constants', () => ({\n  buildCharactersIntroduction: vi.fn(() => 'characters introduction'),\n}))\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))\nvi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))\nvi.mock('@/lib/logging/semantic', () => ({ logAIAnalysis: vi.fn() }))\nvi.mock('@/lib/logging/file-writer', () => ({ onProjectNameAvailable: vi.fn() }))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    onStage: vi.fn(),\n    onChunk: vi.fn(),\n    onComplete: vi.fn(),\n    onError: vi.fn(),\n    flush: vi.fn(async () => undefined),\n  })),\n}))\nvi.mock('@/lib/workers/handlers/screenplay-convert-helpers', () => ({\n  readText: (value: unknown) => (typeof value === 'string' ? value : ''),\n  parseScreenplayPayload: helpersMock.parseScreenplayPayload,\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_SCREENPLAY_CONVERSION: 'np_screenplay_conversion' },\n  getPromptTemplate: vi.fn(() => 'screenplay-template-{clip_content}-{clip_id}'),\n}))\n\nimport { handleScreenplayConvertTask } from '@/lib/workers/handlers/screenplay-convert'\n\nfunction buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-screenplay-1',\n      type: TASK_TYPE.SCREENPLAY_CONVERT,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker screenplay-convert behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.project.findUnique.mockResolvedValue({\n      id: 'project-1',\n      name: 'Project One',\n      mode: 'novel-promotion',\n    })\n\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValue({\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-1',\n      characters: [{ name: 'Hero' }],\n      locations: [{ name: 'Old Town' }],\n    })\n\n    prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({\n      id: 'episode-1',\n      novelPromotionProjectId: 'np-project-1',\n      clips: [\n        {\n          id: 'clip-1',\n          content: 'clip 1 content',\n        },\n      ],\n    })\n  })\n\n  it('missing episodeId -> explicit error', async () => {\n    const job = buildJob({}, null)\n    await expect(handleScreenplayConvertTask(job)).rejects.toThrow('episodeId is required')\n  })\n\n  it('success path -> writes screenplay json to clip row', async () => {\n    const job = buildJob({ episodeId: 'episode-1' })\n    const result = await handleScreenplayConvertTask(job)\n\n    expect(result).toEqual(expect.objectContaining({\n      episodeId: 'episode-1',\n      total: 1,\n      successCount: 1,\n      failCount: 0,\n      totalScenes: 1,\n    }))\n\n    expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({\n      where: { id: 'clip-1' },\n      data: {\n        screenplay: JSON.stringify({\n          scenes: [{ index: 1 }],\n          clip_id: 'clip-1',\n          original_text: 'clip 1 content',\n        }),\n      },\n    })\n  })\n\n  it('clip parse failed -> throws partial failure error with code prefix', async () => {\n    helpersMock.parseScreenplayPayload.mockImplementation(() => {\n      throw new Error('invalid screenplay payload')\n    })\n\n    const job = buildJob({ episodeId: 'episode-1' })\n    await expect(handleScreenplayConvertTask(job)).rejects.toThrow('SCREENPLAY_CONVERT_PARTIAL_FAILED')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/script-to-storyboard-atomic-retry.test.ts",
    "content": "import { describe, expect, it, vi, beforeEach } from 'vitest'\nimport {\n  parseStoryboardRetryTarget,\n  runScriptToStoryboardAtomicRetry,\n} from '@/lib/workers/handlers/script-to-storyboard-atomic-retry'\n\nconst listArtifactsMock = vi.hoisted(() => vi.fn())\n\nvi.mock('@/lib/run-runtime/service', () => ({\n  listArtifacts: listArtifactsMock,\n}))\n\ndescribe('script-to-storyboard atomic retry', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('解析 clip+phase stepKey', () => {\n    expect(parseStoryboardRetryTarget('clip_clip-1_phase3_detail')).toEqual({\n      stepKey: 'clip_clip-1_phase3_detail',\n      clipId: 'clip-1',\n      phase: 'phase3_detail',\n    })\n    expect(parseStoryboardRetryTarget('voice_analyze')).toBeNull()\n    expect(parseStoryboardRetryTarget('clip__phase3')).toBeNull()\n  })\n\n  it('phase3 重试只执行 phase3 并读取 phase1/phase2 artifact 续跑', async () => {\n    listArtifactsMock.mockImplementation(async (params: {\n      runId: string\n      artifactType?: string\n      refId?: string\n    }) => {\n      if (params.refId !== 'clip-1') return []\n      if (params.artifactType === 'storyboard.clip.phase1') {\n        return [{\n          id: 'a1',\n          runId: params.runId,\n          stepKey: 'clip_clip-1_phase1',\n          artifactType: 'storyboard.clip.phase1',\n          refId: 'clip-1',\n          versionHash: null,\n          payload: {\n            panels: [{ panel_number: 1, description: 'p1', location: 'Office', source_text: 'src', characters: [] }],\n          },\n          createdAt: '2026-03-03T00:00:00.000Z',\n        }]\n      }\n      if (params.artifactType === 'storyboard.clip.phase2.cine') {\n        return [{\n          id: 'a2',\n          runId: params.runId,\n          stepKey: 'clip_clip-1_phase2_cinematography',\n          artifactType: 'storyboard.clip.phase2.cine',\n          refId: 'clip-1',\n          versionHash: null,\n          payload: {\n            rules: [{\n              panel_number: 1,\n              composition: '居中',\n              lighting: '顶光',\n              color_palette: '冷色',\n              atmosphere: '紧张',\n              technical_notes: 'note',\n            }],\n          },\n          createdAt: '2026-03-03T00:00:00.000Z',\n        }]\n      }\n      if (params.artifactType === 'storyboard.clip.phase2.acting') {\n        return [{\n          id: 'a3',\n          runId: params.runId,\n          stepKey: 'clip_clip-1_phase2_acting',\n          artifactType: 'storyboard.clip.phase2.acting',\n          refId: 'clip-1',\n          versionHash: null,\n          payload: {\n            directions: [{ panel_number: 1, characters: [{ name: 'Narrator', expression: 'serious' }] }],\n          },\n          createdAt: '2026-03-03T00:00:00.000Z',\n        }]\n      }\n      if (params.artifactType === 'storyboard.clip.phase3') {\n        return []\n      }\n      return []\n    })\n\n    const runStep = vi.fn(async (_meta, _prompt, action: string) => {\n      if (action !== 'storyboard_phase3_detail') {\n        throw new Error(`unexpected action ${action}`)\n      }\n      return {\n        text: JSON.stringify([{ panel_number: 1, description: 'phase3-new', location: 'Office', source_text: 'src', characters: [] }]),\n        reasoning: '',\n      }\n    })\n\n    const result = await runScriptToStoryboardAtomicRetry({\n      runId: 'run-1',\n      retryTarget: {\n        stepKey: 'clip_clip-1_phase3_detail',\n        clipId: 'clip-1',\n        phase: 'phase3_detail',\n      },\n      retryStepAttempt: 4,\n      clip: {\n        id: 'clip-1',\n        content: 'clip content',\n        characters: JSON.stringify([{ name: 'Narrator' }]),\n        location: 'Office',\n        screenplay: null,\n      },\n      clipIndex: 0,\n      totalClipCount: 1,\n      novelPromotionData: {\n        characters: [{ name: 'Narrator', appearances: [] }],\n        locations: [{ name: 'Office', images: [{ description: 'room desc' }] }],\n      },\n      promptTemplates: {\n        phase1PlanTemplate: '{clip_content}',\n        phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',\n        phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',\n        phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',\n      },\n      runStep,\n    })\n\n    expect(runStep).toHaveBeenCalledTimes(1)\n    expect(runStep.mock.calls[0]?.[2]).toBe('storyboard_phase3_detail')\n    expect(result.phase1PanelsByClipId).toEqual({})\n    expect(result.phase2CinematographyByClipId).toEqual({})\n    expect(result.phase2ActingByClipId).toEqual({})\n    expect(result.phase3PanelsByClipId['clip-1']).toEqual([\n      { panel_number: 1, description: 'phase3-new', location: 'Office', source_text: 'src', characters: [] },\n    ])\n    expect(result.clipPanels).toHaveLength(1)\n    expect(result.clipPanels[0]?.finalPanels[0]).toEqual(expect.objectContaining({\n      panel_number: 1,\n      description: 'phase3-new',\n      photographyPlan: expect.objectContaining({\n        composition: '居中',\n        lighting: '顶光',\n      }),\n      actingNotes: [{ name: 'Narrator', expression: 'serious' }],\n    }))\n    expect(result.totalPanelCount).toBe(1)\n  })\n\n  it('phase2 重试缺少 phase3 artifact 时显式失败', async () => {\n    listArtifactsMock.mockImplementation(async (params: {\n      runId: string\n      artifactType?: string\n      refId?: string\n    }) => {\n      if (params.refId !== 'clip-1') return []\n      if (params.artifactType === 'storyboard.clip.phase1') {\n        return [{\n          id: 'a1',\n          runId: params.runId,\n          stepKey: 'clip_clip-1_phase1',\n          artifactType: 'storyboard.clip.phase1',\n          refId: 'clip-1',\n          versionHash: null,\n          payload: { panels: [{ panel_number: 1, description: 'p1', location: 'Office' }] },\n          createdAt: '2026-03-03T00:00:00.000Z',\n        }]\n      }\n      if (params.artifactType === 'storyboard.clip.phase2.acting') {\n        return [{\n          id: 'a2',\n          runId: params.runId,\n          stepKey: 'clip_clip-1_phase2_acting',\n          artifactType: 'storyboard.clip.phase2.acting',\n          refId: 'clip-1',\n          versionHash: null,\n          payload: { directions: [{ panel_number: 1, characters: [] }] },\n          createdAt: '2026-03-03T00:00:00.000Z',\n        }]\n      }\n      return []\n    })\n\n    const runStep = vi.fn(async (_meta, _prompt, action: string) => {\n      if (action !== 'storyboard_phase2_cinematography') {\n        throw new Error(`unexpected action ${action}`)\n      }\n      return {\n        text: JSON.stringify([{ panel_number: 1, composition: '居中' }]),\n        reasoning: '',\n      }\n    })\n\n    await expect(runScriptToStoryboardAtomicRetry({\n      runId: 'run-2',\n      retryTarget: {\n        stepKey: 'clip_clip-1_phase2_cinematography',\n        clipId: 'clip-1',\n        phase: 'phase2_cinematography',\n      },\n      retryStepAttempt: 2,\n      clip: {\n        id: 'clip-1',\n        content: 'clip content',\n        characters: JSON.stringify([{ name: 'Narrator' }]),\n        location: 'Office',\n        screenplay: null,\n      },\n      clipIndex: 0,\n      totalClipCount: 1,\n      novelPromotionData: {\n        characters: [{ name: 'Narrator', appearances: [] }],\n        locations: [{ name: 'Office', images: [{ description: 'room desc' }] }],\n      },\n      promptTemplates: {\n        phase1PlanTemplate: '{clip_content}',\n        phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',\n        phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',\n        phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',\n      },\n      runStep,\n    })).rejects.toThrow('missing dependency artifact: storyboard.clip.phase3')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/script-to-storyboard-orchestrator.retry.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest'\nimport { runScriptToStoryboardOrchestrator } from '@/lib/novel-promotion/script-to-storyboard/orchestrator'\n\ndescribe('script-to-storyboard orchestrator retry', () => {\n  it('retries retryable step failures up to 3 attempts', async () => {\n    const attemptsByAction = new Map<string, number>()\n    const phase1Metas: Array<{ stepId: string; stepAttempt?: number }> = []\n    const runStep = vi.fn(async (meta, _prompt, action: string) => {\n      attemptsByAction.set(action, (attemptsByAction.get(action) || 0) + 1)\n\n      if (action === 'storyboard_phase1_plan') {\n        phase1Metas.push({ stepId: meta.stepId, stepAttempt: meta.stepAttempt })\n        const attempt = attemptsByAction.get(action) || 0\n        if (attempt < 3) {\n          throw new TypeError('terminated')\n        }\n        return {\n          text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),\n          reasoning: '',\n        }\n      }\n\n      if (action === 'storyboard_phase2_cinematography') {\n        return { text: JSON.stringify([{ panel_number: 1, composition: '居中' }]), reasoning: '' }\n      }\n      if (action === 'storyboard_phase2_acting') {\n        return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }\n      }\n      return {\n        text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),\n        reasoning: '',\n      }\n    })\n\n    const result = await runScriptToStoryboardOrchestrator({\n      clips: [\n        {\n          id: 'clip-1',\n          content: '文本',\n          characters: JSON.stringify([{ name: '角色A' }]),\n          location: '场景A',\n          screenplay: null,\n        },\n      ],\n      novelPromotionData: {\n        characters: [{ name: '角色A', appearances: [] }],\n        locations: [{ name: '场景A', images: [] }],\n      },\n      promptTemplates: {\n        phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',\n        phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',\n        phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',\n        phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',\n      },\n      runStep,\n    })\n\n    expect(result.summary.clipCount).toBe(1)\n    expect(runStep).toHaveBeenCalled()\n    expect(attemptsByAction.get('storyboard_phase1_plan')).toBe(3)\n    expect(phase1Metas).toEqual([\n      { stepId: 'clip_clip-1_phase1', stepAttempt: undefined },\n      { stepId: 'clip_clip-1_phase1', stepAttempt: 2 },\n      { stepId: 'clip_clip-1_phase1', stepAttempt: 3 },\n    ])\n  })\n\n  it('does not retry non-retryable step failure', async () => {\n    let callCount = 0\n    const runStep = vi.fn(async () => {\n      callCount += 1\n      throw new Error('SENSITIVE_CONTENT: blocked')\n    })\n\n    await expect(\n      runScriptToStoryboardOrchestrator({\n        clips: [\n          {\n            id: 'clip-1',\n            content: '文本',\n            characters: JSON.stringify([{ name: '角色A' }]),\n            location: '场景A',\n            screenplay: null,\n          },\n        ],\n        novelPromotionData: {\n          characters: [{ name: '角色A', appearances: [] }],\n          locations: [{ name: '场景A', images: [] }],\n        },\n        promptTemplates: {\n          phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',\n          phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',\n          phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',\n          phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',\n        },\n        runStep,\n      }),\n    ).rejects.toThrow('SENSITIVE_CONTENT')\n\n    expect(callCount).toBe(1)\n  })\n\n  it('does not retry Ark invalid parameter error even when message contains json', async () => {\n    let callCount = 0\n    const runStep = vi.fn(async () => {\n      callCount += 1\n      throw new Error(\n        'Ark Responses 调用失败: 400 - {\"error\":{\"code\":\"InvalidParameter\",\"message\":\"json: unknown field \\\\\"reasoning_effort\\\\\"\"}}',\n      )\n    })\n\n    await expect(\n      runScriptToStoryboardOrchestrator({\n        clips: [\n          {\n            id: 'clip-1',\n            content: '文本',\n            characters: JSON.stringify([{ name: '角色A' }]),\n            location: '场景A',\n            screenplay: null,\n          },\n        ],\n        novelPromotionData: {\n          characters: [{ name: '角色A', appearances: [] }],\n          locations: [{ name: '场景A', images: [] }],\n        },\n        promptTemplates: {\n          phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',\n          phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',\n          phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',\n          phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',\n        },\n        runStep,\n      }),\n    ).rejects.toThrow('unknown field')\n\n    expect(callCount).toBe(1)\n  })\n\n  it('enforces topology: phase3 runs after both phase2 steps complete', async () => {\n    const actionOrder: string[] = []\n    const runStep = vi.fn(async (_meta, _prompt, action: string) => {\n      actionOrder.push(action)\n      if (action === 'storyboard_phase1_plan') {\n        return {\n          text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),\n          reasoning: '',\n        }\n      }\n      if (action === 'storyboard_phase2_cinematography') {\n        return { text: JSON.stringify([{ panel_number: 1, composition: '居中' }]), reasoning: '' }\n      }\n      if (action === 'storyboard_phase2_acting') {\n        return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }\n      }\n      if (action === 'storyboard_phase3_detail') {\n        return {\n          text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),\n          reasoning: '',\n        }\n      }\n      throw new Error(`unexpected action: ${action}`)\n    })\n\n    const result = await runScriptToStoryboardOrchestrator({\n      clips: [\n        {\n          id: 'clip-1',\n          content: '文本',\n          characters: JSON.stringify([{ name: '角色A' }]),\n          location: '场景A',\n          screenplay: null,\n        },\n      ],\n      novelPromotionData: {\n        characters: [{ name: '角色A', appearances: [] }],\n        locations: [{ name: '场景A', images: [] }],\n      },\n      promptTemplates: {\n        phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',\n        phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',\n        phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',\n        phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',\n      },\n      runStep,\n    })\n\n    expect(result.summary.clipCount).toBe(1)\n    const phase3Index = actionOrder.indexOf('storyboard_phase3_detail')\n    const phase2CineIndex = actionOrder.indexOf('storyboard_phase2_cinematography')\n    const phase2ActingIndex = actionOrder.indexOf('storyboard_phase2_acting')\n    expect(phase3Index).toBeGreaterThan(phase2CineIndex)\n    expect(phase3Index).toBeGreaterThan(phase2ActingIndex)\n  })\n\n  it('limits clip fan-out by configured concurrency', async () => {\n    let activePhase1 = 0\n    let maxActivePhase1 = 0\n\n    const runStep = vi.fn(async (_meta, _prompt, action: string) => {\n      if (action === 'storyboard_phase1_plan') {\n        activePhase1 += 1\n        maxActivePhase1 = Math.max(maxActivePhase1, activePhase1)\n        await new Promise((resolve) => setTimeout(resolve, 5))\n        activePhase1 -= 1\n        return {\n          text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),\n          reasoning: '',\n        }\n      }\n      if (action === 'storyboard_phase2_cinematography') {\n        return {\n          text: JSON.stringify([{\n            panel_number: 1,\n            composition: '居中',\n            lighting: '顶光',\n            color_palette: '冷色',\n            atmosphere: '紧张',\n            technical_notes: 'note',\n          }]),\n          reasoning: '',\n        }\n      }\n      if (action === 'storyboard_phase2_acting') {\n        return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }\n      }\n      if (action === 'storyboard_phase3_detail') {\n        return {\n          text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),\n          reasoning: '',\n        }\n      }\n      throw new Error(`unexpected action: ${action}`)\n    })\n\n    const result = await runScriptToStoryboardOrchestrator({\n      concurrency: 1,\n      clips: [\n        {\n          id: 'clip-1',\n          content: '文本1',\n          characters: JSON.stringify([{ name: '角色A' }]),\n          location: '场景A',\n          screenplay: null,\n        },\n        {\n          id: 'clip-2',\n          content: '文本2',\n          characters: JSON.stringify([{ name: '角色A' }]),\n          location: '场景A',\n          screenplay: null,\n        },\n        {\n          id: 'clip-3',\n          content: '文本3',\n          characters: JSON.stringify([{ name: '角色A' }]),\n          location: '场景A',\n          screenplay: null,\n        },\n      ],\n      novelPromotionData: {\n        characters: [{ name: '角色A', appearances: [] }],\n        locations: [{ name: '场景A', images: [] }],\n      },\n      promptTemplates: {\n        phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',\n        phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',\n        phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',\n        phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',\n      },\n      runStep,\n    })\n\n    expect(result.summary.clipCount).toBe(3)\n    expect(maxActivePhase1).toBe(1)\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/script-to-storyboard.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\ntype VoiceLineInput = {\n  lineIndex: number\n  speaker: string\n  content: string\n  emotionStrength: number\n  matchedPanel: {\n    storyboardId: string\n    panelIndex: number\n  }\n}\n\nconst reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))\nconst assertTaskActiveMock = vi.hoisted(() => vi.fn(async () => undefined))\nconst chatCompletionMock = vi.hoisted(() => vi.fn(async () => ({ responseId: 'resp-1' })))\nconst getCompletionPartsMock = vi.hoisted(() => vi.fn(() => ({ text: 'voice lines json', reasoning: '' })))\nconst withInternalLLMStreamCallbacksMock = vi.hoisted(() =>\n  vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n)\nconst resolveProjectModelCapabilityGenerationOptionsMock = vi.hoisted(() =>\n  vi.fn(async () => ({ reasoningEffort: 'high' })),\n)\nconst runScriptToStoryboardOrchestratorMock = vi.hoisted(() =>\n  vi.fn(async () => ({\n    clipPanels: [\n      {\n        clipId: 'clip-1',\n        panels: [\n          {\n            panelIndex: 1,\n            shotType: 'close-up',\n            cameraMove: 'static',\n            description: 'panel desc',\n            videoPrompt: 'panel prompt',\n            location: 'room',\n            characters: ['Narrator'],\n          },\n        ],\n      },\n    ],\n    summary: {\n      totalPanelCount: 1,\n      totalStepCount: 4,\n    },\n  })),\n)\nconst parseVoiceLinesJsonMock = vi.hoisted(() => vi.fn())\nconst persistStoryboardsAndPanelsMock = vi.hoisted(() => vi.fn())\nconst parseStoryboardRetryTargetMock = vi.hoisted(() => vi.fn())\nconst runScriptToStoryboardAtomicRetryMock = vi.hoisted(() => vi.fn())\nconst workflowLeaseMock = vi.hoisted(() => ({\n  assertWorkflowRunActive: vi.fn(async () => undefined),\n  withWorkflowRunLease: vi.fn(async (params: { run: () => Promise<unknown> }) => ({\n    claimed: true,\n    result: await params.run(),\n  })),\n}))\n\nconst txState = vi.hoisted(() => ({\n  createdRows: [] as Array<Record<string, unknown>>,\n  deletedWhereClauses: [] as Array<Record<string, unknown>>,\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  project: {\n    findUnique: vi.fn(),\n  },\n  novelPromotionProject: {\n    findUnique: vi.fn(),\n  },\n  novelPromotionEpisode: {\n    findUnique: vi.fn(),\n  },\n  $transaction: vi.fn(),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\n\nvi.mock('@/lib/llm-client', () => ({\n  chatCompletion: chatCompletionMock,\n  getCompletionParts: getCompletionPartsMock,\n  getCompletionContent: vi.fn(() => 'voice lines json'),\n}))\n\nvi.mock('@/lib/config-service', () => ({\n  resolveProjectModelCapabilityGenerationOptions: resolveProjectModelCapabilityGenerationOptionsMock,\n  getUserWorkflowConcurrencyConfig: vi.fn(async () => ({\n    analysis: 2,\n    image: 5,\n    video: 5,\n  })),\n}))\n\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: withInternalLLMStreamCallbacksMock,\n}))\n\nvi.mock('@/lib/logging/semantic', () => ({\n  logAIAnalysis: vi.fn(),\n}))\n\nvi.mock('@/lib/logging/file-writer', () => ({\n  onProjectNameAvailable: vi.fn(),\n}))\n\nvi.mock('@/lib/constants', () => ({\n  buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),\n}))\n\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: reportTaskProgressMock,\n}))\n\nvi.mock('@/lib/workers/utils', () => ({\n  assertTaskActive: assertTaskActiveMock,\n}))\n\nvi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', () => ({\n  runScriptToStoryboardOrchestrator: runScriptToStoryboardOrchestratorMock,\n  JsonParseError: class JsonParseError extends Error {\n    rawText: string\n\n    constructor(message: string, rawText: string) {\n      super(message)\n      this.name = 'JsonParseError'\n      this.rawText = rawText\n    }\n  },\n}))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    onStage: vi.fn(),\n    onChunk: vi.fn(),\n    onComplete: vi.fn(),\n    onError: vi.fn(),\n    flush: vi.fn(async () => undefined),\n  })),\n}))\n\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: {\n    NP_AGENT_STORYBOARD_PLAN: 'plan',\n    NP_AGENT_CINEMATOGRAPHER: 'cinematographer',\n    NP_AGENT_ACTING_DIRECTION: 'acting',\n    NP_AGENT_STORYBOARD_DETAIL: 'detail',\n    NP_VOICE_ANALYSIS: 'voice-analysis',\n  },\n  getPromptTemplate: vi.fn(() => 'prompt-template'),\n  buildPrompt: vi.fn(() => 'voice-analysis-prompt'),\n}))\n\nvi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', () => ({\n  asJsonRecord: (value: unknown) => {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) return null\n    return value as Record<string, unknown>\n  },\n  buildStoryboardJson: vi.fn(() => '[]'),\n  parseEffort: vi.fn(() => null),\n  parseTemperature: vi.fn(() => 0.7),\n  parseVoiceLinesJson: parseVoiceLinesJsonMock,\n  persistStoryboardsAndPanels: persistStoryboardsAndPanelsMock,\n  toPositiveInt: (value: unknown) => {\n    if (typeof value !== 'number' || !Number.isFinite(value)) return null\n    const n = Math.floor(value)\n    return n > 0 ? n : null\n  },\n}))\nvi.mock('@/lib/workers/handlers/script-to-storyboard-atomic-retry', () => ({\n  parseStoryboardRetryTarget: parseStoryboardRetryTargetMock,\n  runScriptToStoryboardAtomicRetry: runScriptToStoryboardAtomicRetryMock,\n}))\nvi.mock('@/lib/run-runtime/workflow-lease', () => workflowLeaseMock)\n\nimport { handleScriptToStoryboardTask } from '@/lib/workers/handlers/script-to-storyboard'\n\nfunction buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {\n  const runId = typeof payload.runId === 'string' && payload.runId.trim() ? payload.runId.trim() : 'run-test-storyboard'\n  const payloadMeta = payload.meta && typeof payload.meta === 'object' && !Array.isArray(payload.meta)\n    ? (payload.meta as Record<string, unknown>)\n    : {}\n  const normalizedPayload: Record<string, unknown> = {\n    ...payload,\n    runId,\n    meta: {\n      ...payloadMeta,\n      runId,\n    },\n  }\n  return {\n    data: {\n      taskId: 'task-1',\n      type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      payload: normalizedPayload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\nfunction baseVoiceRows(): VoiceLineInput[] {\n  return [\n    {\n      lineIndex: 1,\n      speaker: 'Narrator',\n      content: 'Hello world',\n      emotionStrength: 0.8,\n      matchedPanel: {\n        storyboardId: 'storyboard-1',\n        panelIndex: 1,\n      },\n    },\n  ]\n}\n\ndescribe('worker script-to-storyboard behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    txState.createdRows = []\n    txState.deletedWhereClauses = []\n    parseStoryboardRetryTargetMock.mockReturnValue(null)\n    runScriptToStoryboardAtomicRetryMock.mockReset()\n\n    prismaMock.project.findUnique.mockResolvedValue({\n      id: 'project-1',\n      name: 'Project One',\n      mode: 'novel-promotion',\n    })\n\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValue({\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-model',\n      characters: [{ id: 'char-1', name: 'Narrator' }],\n      locations: [{ id: 'loc-1', name: 'Office' }],\n    })\n\n    prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({\n      id: 'episode-1',\n      novelPromotionProjectId: 'np-project-1',\n      novelText: 'A complete chapter text for voice analyze.',\n      clips: [\n        {\n          id: 'clip-1',\n          content: 'clip content',\n          characters: JSON.stringify(['Narrator']),\n          location: 'Office',\n          screenplay: 'Screenplay text',\n        },\n      ],\n    })\n\n    prismaMock.$transaction.mockImplementation(async (fn: (tx: {\n      novelPromotionVoiceLine: {\n        deleteMany: (args: { where: Record<string, unknown> }) => Promise<unknown>\n        create: (args: { data: Record<string, unknown>; select: { id: boolean } }) => Promise<{ id: string }>\n      }\n    }) => Promise<unknown>) => {\n      const tx = {\n        novelPromotionVoiceLine: {\n          deleteMany: async (args: { where: Record<string, unknown> }) => {\n            txState.deletedWhereClauses.push(args.where)\n            return undefined\n          },\n          create: async (args: { data: Record<string, unknown>; select: { id: boolean } }) => {\n            txState.createdRows.push(args.data)\n            return { id: `voice-${txState.createdRows.length}` }\n          },\n        },\n      }\n      return await fn(tx)\n    })\n\n    persistStoryboardsAndPanelsMock.mockResolvedValue([\n      {\n        storyboardId: 'storyboard-1',\n        panels: [{ id: 'panel-1', panelIndex: 1 }],\n      },\n    ])\n\n    parseVoiceLinesJsonMock.mockReturnValue(baseVoiceRows())\n  })\n\n  it('缺少 episodeId -> 显式失败', async () => {\n    const job = buildJob({}, null)\n    await expect(handleScriptToStoryboardTask(job)).rejects.toThrow('episodeId is required')\n  })\n\n  it('成功路径: 写入 voice line 时包含 matchedPanel 映射后的 panelId', async () => {\n    const job = buildJob({ episodeId: 'episode-1' })\n\n    const result = await handleScriptToStoryboardTask(job)\n\n    expect(result).toEqual({\n      episodeId: 'episode-1',\n      storyboardCount: 1,\n      panelCount: 1,\n      voiceLineCount: 1,\n    })\n\n    expect(txState.createdRows).toHaveLength(1)\n    expect(txState.createdRows[0]).toEqual(expect.objectContaining({\n      episodeId: 'episode-1',\n      lineIndex: 1,\n      speaker: 'Narrator',\n      content: 'Hello world',\n      emotionStrength: 0.8,\n      matchedPanelId: 'panel-1',\n      matchedStoryboardId: 'storyboard-1',\n      matchedPanelIndex: 1,\n    }))\n    expect(txState.deletedWhereClauses[0]).toEqual({\n      episodeId: 'episode-1',\n      lineIndex: {\n        notIn: [1],\n      },\n    })\n  })\n\n  it('voice 解析失败后会重试一次再成功', async () => {\n    parseVoiceLinesJsonMock\n      .mockImplementationOnce(() => {\n        throw new Error('invalid voice json')\n      })\n      .mockImplementationOnce(() => baseVoiceRows())\n\n    const job = buildJob({ episodeId: 'episode-1' })\n    const result = await handleScriptToStoryboardTask(job)\n\n    expect(result).toEqual(expect.objectContaining({\n      episodeId: 'episode-1',\n      voiceLineCount: 1,\n    }))\n    expect(chatCompletionMock).toHaveBeenCalledTimes(2)\n    expect(parseVoiceLinesJsonMock).toHaveBeenCalledTimes(2)\n    expect(withInternalLLMStreamCallbacksMock).toHaveBeenCalledTimes(3)\n    const firstChatCall = chatCompletionMock.mock.calls[0] as unknown as [unknown, unknown, unknown, Record<string, unknown>] | undefined\n    expect(firstChatCall?.[3]).toEqual(expect.objectContaining({\n      action: 'voice_analyze',\n      streamStepId: 'voice_analyze',\n      streamStepAttempt: 1,\n    }))\n    const secondChatCall = chatCompletionMock.mock.calls[1] as unknown as [unknown, unknown, unknown, Record<string, unknown>] | undefined\n    expect(secondChatCall?.[3]).toEqual(expect.objectContaining({\n      action: 'voice_analyze',\n      streamStepId: 'voice_analyze',\n      streamStepAttempt: 2,\n    }))\n    expect(reportTaskProgressMock).toHaveBeenCalledWith(\n      job,\n      84,\n      expect.objectContaining({\n        stage: 'script_to_storyboard_step',\n        stepId: 'voice_analyze',\n        stepAttempt: 2,\n        message: '台词分析失败，准备重试 (2/2)',\n      }),\n    )\n  })\n\n  it('空台词数组 -> 成功完成并清空旧台词', async () => {\n    parseVoiceLinesJsonMock.mockReturnValue([])\n\n    const job = buildJob({ episodeId: 'episode-1' })\n    const result = await handleScriptToStoryboardTask(job)\n\n    expect(result).toEqual({\n      episodeId: 'episode-1',\n      storyboardCount: 1,\n      panelCount: 1,\n      voiceLineCount: 0,\n    })\n    expect(txState.createdRows).toEqual([])\n    expect(txState.deletedWhereClauses[0]).toEqual({\n      episodeId: 'episode-1',\n    })\n  })\n\n  it('phase 级重试: 仅执行原子 phase，不走整图重跑', async () => {\n    parseStoryboardRetryTargetMock.mockReturnValue({\n      stepKey: 'clip_clip-1_phase3_detail',\n      clipId: 'clip-1',\n      phase: 'phase3_detail',\n    })\n    runScriptToStoryboardAtomicRetryMock.mockResolvedValue({\n      clipPanels: [\n        {\n          clipId: 'clip-1',\n          clipIndex: 1,\n          finalPanels: [\n            {\n              panel_number: 1,\n              description: 'phase3 retry panel',\n              location: 'Office',\n            },\n          ],\n        },\n      ],\n      phase1PanelsByClipId: {},\n      phase2CinematographyByClipId: {},\n      phase2ActingByClipId: {},\n      phase3PanelsByClipId: {\n        'clip-1': [\n          {\n            panel_number: 1,\n            description: 'phase3 retry panel',\n            location: 'Office',\n          },\n        ],\n      },\n      totalPanelCount: 1,\n      totalStepCount: 6,\n    })\n\n    const job = buildJob({\n      episodeId: 'episode-1',\n      retryStepKey: 'clip_clip-1_phase3_detail',\n      retryStepAttempt: 2,\n    })\n    const result = await handleScriptToStoryboardTask(job)\n\n    expect(result).toEqual({\n      episodeId: 'episode-1',\n      storyboardCount: 1,\n      panelCount: 1,\n      voiceLineCount: 0,\n      retryStepKey: 'clip_clip-1_phase3_detail',\n    })\n    expect(runScriptToStoryboardAtomicRetryMock).toHaveBeenCalledTimes(1)\n    expect(runScriptToStoryboardOrchestratorMock).not.toHaveBeenCalled()\n    expect(persistStoryboardsAndPanelsMock).toHaveBeenCalledWith({\n      episodeId: 'episode-1',\n      clipPanels: [\n        {\n          clipId: 'clip-1',\n          clipIndex: 1,\n          finalPanels: [\n            {\n              panel_number: 1,\n              description: 'phase3 retry panel',\n              location: 'Office',\n            },\n          ],\n        },\n      ],\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/shared.direct-run-events.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { Job } from 'bullmq'\nimport type { TaskJobData } from '@/lib/task/types'\n\nconst tryUpdateTaskProgressMock = vi.hoisted(() => vi.fn(async () => true))\nconst publishTaskEventMock = vi.hoisted(() => vi.fn(async () => ({})))\nconst publishTaskStreamEventMock = vi.hoisted(() => vi.fn(async () => ({})))\nconst publishRunEventMock = vi.hoisted(() => vi.fn(async () => undefined))\nconst mapTaskSSEEventToRunEventsMock = vi.hoisted(() =>\n  vi.fn(() => [{\n    runId: 'run-1',\n    projectId: 'project-1',\n    userId: 'user-1',\n    eventType: 'step.start',\n    stepKey: 'split_clips',\n    attempt: 1,\n    lane: null,\n    payload: { mirrored: true },\n  }]),\n)\n\nvi.mock('@/lib/prisma', () => ({\n  prisma: {\n    project: {\n      findUnique: vi.fn(async () => null),\n    },\n  },\n}))\n\nvi.mock('@/lib/logging/core', () => ({\n  createScopedLogger: () => ({\n    info: vi.fn(),\n    warn: vi.fn(),\n    error: vi.fn(),\n  }),\n}))\n\nvi.mock('@/lib/task/service', () => ({\n  rollbackTaskBillingForTask: vi.fn(async () => ({ attempted: false, rolledBack: false, billingInfo: null })),\n  touchTaskHeartbeat: vi.fn(async () => undefined),\n  tryMarkTaskCompleted: vi.fn(async () => true),\n  tryMarkTaskFailed: vi.fn(async () => true),\n  tryMarkTaskProcessing: vi.fn(async () => true),\n  tryUpdateTaskProgress: tryUpdateTaskProgressMock,\n  updateTaskBillingInfo: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/task/publisher', () => ({\n  publishTaskEvent: publishTaskEventMock,\n  publishTaskStreamEvent: publishTaskStreamEventMock,\n}))\n\nvi.mock('@/lib/task/progress-message', () => ({\n  buildTaskProgressMessage: vi.fn(() => 'progress-message'),\n  getTaskStageLabel: vi.fn((stage: string) => `label:${stage}`),\n}))\n\nvi.mock('@/lib/errors/normalize', () => ({\n  normalizeAnyError: vi.fn((error: Error) => ({\n    code: 'ERROR',\n    message: error.message,\n    retryable: false,\n    provider: null,\n  })),\n}))\n\nvi.mock('@/lib/billing', () => ({\n  rollbackTaskBilling: vi.fn(async () => null),\n  settleTaskBilling: vi.fn(async () => null),\n}))\n\nvi.mock('@/lib/billing/runtime-usage', () => ({\n  withTextUsageCollection: vi.fn(async (fn: () => Promise<unknown>) => ({\n    result: await fn(),\n    textUsage: null,\n  })),\n}))\n\nvi.mock('@/lib/logging/file-writer', () => ({\n  onProjectNameAvailable: vi.fn(),\n}))\n\nvi.mock('@/lib/run-runtime/task-bridge', () => ({\n  mapTaskSSEEventToRunEvents: mapTaskSSEEventToRunEventsMock,\n}))\n\nvi.mock('@/lib/run-runtime/publisher', () => ({\n  publishRunEvent: publishRunEventMock,\n}))\n\nimport { reportTaskProgress, reportTaskStreamChunk, withTaskLifecycle } from '@/lib/workers/shared'\n\nfunction buildJob(taskType: TaskJobData['type']): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-1',\n      type: taskType,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      userId: 'user-1',\n      payload: {\n        runId: 'run-1',\n      },\n      trace: null,\n    },\n    queueName: 'text',\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker shared direct run events', () => {\n  beforeEach(() => {\n    tryUpdateTaskProgressMock.mockReset()\n    tryUpdateTaskProgressMock.mockResolvedValue(true)\n    publishTaskEventMock.mockReset()\n    publishTaskStreamEventMock.mockReset()\n    publishRunEventMock.mockReset()\n    mapTaskSSEEventToRunEventsMock.mockClear()\n  })\n\n  it('publishes run events directly for core analysis progress updates', async () => {\n    await reportTaskProgress(buildJob('story_to_script_run'), 42, {\n      stage: 'story_to_script_step',\n      stepId: 'split_clips',\n      stepTitle: 'Split',\n    })\n\n    expect(publishTaskEventMock).toHaveBeenCalledWith(expect.objectContaining({\n      taskType: 'story_to_script_run',\n      type: 'task.progress',\n    }))\n    expect(mapTaskSSEEventToRunEventsMock).toHaveBeenCalledTimes(1)\n    expect(publishRunEventMock).toHaveBeenCalledWith(expect.objectContaining({\n      runId: 'run-1',\n      eventType: 'step.start',\n      stepKey: 'split_clips',\n    }))\n  })\n\n  it('publishes run events directly for core analysis stream chunks', async () => {\n    await reportTaskStreamChunk(buildJob('script_to_storyboard_run'), {\n      kind: 'text',\n      delta: 'hello',\n      seq: 1,\n      lane: 'main',\n    }, {\n      stepId: 'clip_1_phase1',\n      stepTitle: 'Phase 1',\n    })\n\n    expect(publishTaskStreamEventMock).toHaveBeenCalledWith(expect.objectContaining({\n      taskType: 'script_to_storyboard_run',\n      persist: true,\n    }))\n    expect(mapTaskSSEEventToRunEventsMock).toHaveBeenCalledTimes(1)\n    expect(publishRunEventMock).toHaveBeenCalledWith(expect.objectContaining({\n      runId: 'run-1',\n      eventType: 'step.start',\n      stepKey: 'split_clips',\n    }))\n  })\n\n  it('emits run.start directly when the core analysis worker begins execution', async () => {\n    await withTaskLifecycle(buildJob('story_to_script_run'), async () => ({\n      ok: true,\n    }))\n\n    expect(publishRunEventMock).toHaveBeenCalledWith(expect.objectContaining({\n      runId: 'run-1',\n      eventType: 'run.start',\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/shot-ai-prompt-appearance.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst persistMock = vi.hoisted(() => ({\n  resolveAnalysisModel: vi.fn(),\n}))\n\nconst runtimeMock = vi.hoisted(() => ({\n  runShotPromptCompletion: vi.fn(),\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)\nvi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({\n  runShotPromptCompletion: runtimeMock.runShotPromptCompletion,\n}))\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: runtimeMock.reportTaskProgress,\n}))\nvi.mock('@/lib/workers/utils', () => ({\n  assertTaskActive: runtimeMock.assertTaskActive,\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_CHARACTER_MODIFY: 'np_character_modify' },\n  buildPrompt: vi.fn(() => 'appearance-final-prompt'),\n}))\n\nimport { handleModifyAppearanceTask } from '@/lib/workers/handlers/shot-ai-prompt-appearance'\n\nfunction buildJob(payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-shot-appearance-1',\n      type: TASK_TYPE.AI_MODIFY_APPEARANCE,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'CharacterAppearance',\n      targetId: 'appearance-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker shot-ai-prompt-appearance behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })\n    runtimeMock.runShotPromptCompletion.mockResolvedValue('{\"prompt\":\"updated appearance description\"}')\n  })\n\n  it('missing characterId -> explicit error', async () => {\n    const job = buildJob({\n      appearanceId: 'appearance-1',\n      currentDescription: 'old desc',\n      modifyInstruction: 'new style',\n    })\n\n    await expect(handleModifyAppearanceTask(job, job.data.payload as Record<string, unknown>)).rejects.toThrow('characterId is required')\n  })\n\n  it('success -> returns modifiedDescription and rawResponse', async () => {\n    const payload = {\n      characterId: 'character-1',\n      appearanceId: 'appearance-1',\n      currentDescription: 'old desc',\n      modifyInstruction: 'new style',\n    }\n    const job = buildJob(payload)\n\n    const result = await handleModifyAppearanceTask(job, payload)\n\n    expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({\n      action: 'ai_modify_appearance',\n      prompt: 'appearance-final-prompt',\n    }))\n    expect(result).toEqual(expect.objectContaining({\n      success: true,\n      modifiedDescription: 'updated appearance description',\n      rawResponse: '{\"prompt\":\"updated appearance description\"}',\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/shot-ai-prompt-location.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst persistMock = vi.hoisted(() => ({\n  resolveAnalysisModel: vi.fn(),\n  requireProjectLocation: vi.fn(),\n  persistLocationDescription: vi.fn(),\n}))\n\nconst runtimeMock = vi.hoisted(() => ({\n  runShotPromptCompletion: vi.fn(),\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)\nvi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({\n  runShotPromptCompletion: runtimeMock.runShotPromptCompletion,\n}))\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: runtimeMock.reportTaskProgress,\n}))\nvi.mock('@/lib/workers/utils', () => ({\n  assertTaskActive: runtimeMock.assertTaskActive,\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_LOCATION_MODIFY: 'np_location_modify' },\n  buildPrompt: vi.fn(() => 'location-final-prompt'),\n}))\n\nimport { handleModifyLocationTask } from '@/lib/workers/handlers/shot-ai-prompt-location'\n\nfunction buildJob(payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-shot-location-1',\n      type: TASK_TYPE.AI_MODIFY_LOCATION,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionLocation',\n      targetId: 'location-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker shot-ai-prompt-location behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })\n    persistMock.requireProjectLocation.mockResolvedValue({ id: 'location-1', name: 'Old Town' })\n    runtimeMock.runShotPromptCompletion.mockResolvedValue('{\"prompt\":\"updated location description\"}')\n    persistMock.persistLocationDescription.mockResolvedValue({ id: 'location-1', images: [] })\n  })\n\n  it('missing locationId -> explicit error', async () => {\n    const payload = {\n      currentDescription: 'old location',\n      modifyInstruction: 'new style',\n    }\n    const job = buildJob(payload)\n\n    await expect(handleModifyLocationTask(job, payload)).rejects.toThrow('locationId is required')\n  })\n\n  it('success -> persists modifiedDescription with computed imageIndex', async () => {\n    const payload = {\n      locationId: 'location-1',\n      imageIndex: 2,\n      currentDescription: 'old location',\n      modifyInstruction: 'add fog',\n    }\n    const job = buildJob(payload)\n\n    const result = await handleModifyLocationTask(job, payload)\n\n    expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({\n      action: 'ai_modify_location',\n      prompt: 'location-final-prompt',\n    }))\n    expect(persistMock.persistLocationDescription).toHaveBeenCalledWith({\n      locationId: 'location-1',\n      imageIndex: 2,\n      modifiedDescription: 'updated location description',\n    })\n    expect(result).toEqual(expect.objectContaining({\n      success: true,\n      modifiedDescription: 'updated location description',\n      location: { id: 'location-1', images: [] },\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/shot-ai-prompt-shot.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst persistMock = vi.hoisted(() => ({\n  resolveAnalysisModel: vi.fn(),\n}))\n\nconst runtimeMock = vi.hoisted(() => ({\n  runShotPromptCompletion: vi.fn(),\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)\nvi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({\n  runShotPromptCompletion: runtimeMock.runShotPromptCompletion,\n}))\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: runtimeMock.reportTaskProgress,\n}))\nvi.mock('@/lib/workers/utils', () => ({\n  assertTaskActive: runtimeMock.assertTaskActive,\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_IMAGE_PROMPT_MODIFY: 'np_image_prompt_modify' },\n  buildPrompt: vi.fn(() => 'shot-final-prompt'),\n}))\n\nimport { handleModifyShotPromptTask } from '@/lib/workers/handlers/shot-ai-prompt-shot'\n\nfunction buildJob(payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-shot-prompt-1',\n      type: TASK_TYPE.AI_MODIFY_SHOT_PROMPT,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker shot-ai-prompt-shot behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })\n    runtimeMock.runShotPromptCompletion.mockResolvedValue('{\"image_prompt\":\"updated image prompt\",\"video_prompt\":\"updated video prompt\"}')\n  })\n\n  it('missing currentPrompt -> explicit error', async () => {\n    const payload = { modifyInstruction: 'new angle' }\n    const job = buildJob(payload)\n\n    await expect(handleModifyShotPromptTask(job, payload)).rejects.toThrow('currentPrompt is required')\n  })\n\n  it('success -> returns modified image/video prompts and passes referencedAssets', async () => {\n    const payload = {\n      currentPrompt: 'old image prompt',\n      currentVideoPrompt: 'old video prompt',\n      modifyInstruction: 'new camera movement',\n      referencedAssets: [{ name: 'Hero', description: 'black coat' }],\n    }\n    const job = buildJob(payload)\n\n    const result = await handleModifyShotPromptTask(job, payload)\n\n    expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({\n      action: 'ai_modify_shot_prompt',\n      prompt: 'shot-final-prompt',\n    }))\n    expect(result).toEqual({\n      success: true,\n      modifiedImagePrompt: 'updated image prompt',\n      modifiedVideoPrompt: 'updated video prompt',\n      referencedAssets: [{ name: 'Hero', description: 'black coat' }],\n    })\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/shot-ai-tasks.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst handlersMock = vi.hoisted(() => ({\n  handleModifyAppearanceTask: vi.fn(),\n  handleModifyLocationTask: vi.fn(),\n  handleModifyShotPromptTask: vi.fn(),\n  handleAnalyzeShotVariantsTask: vi.fn(),\n}))\n\nvi.mock('@/lib/workers/handlers/shot-ai-prompt', () => ({\n  handleModifyAppearanceTask: handlersMock.handleModifyAppearanceTask,\n  handleModifyLocationTask: handlersMock.handleModifyLocationTask,\n  handleModifyShotPromptTask: handlersMock.handleModifyShotPromptTask,\n}))\n\nvi.mock('@/lib/workers/handlers/shot-ai-variants', () => ({\n  handleAnalyzeShotVariantsTask: handlersMock.handleAnalyzeShotVariantsTask,\n}))\n\nimport { handleShotAITask } from '@/lib/workers/handlers/shot-ai-tasks'\n\nfunction buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-1',\n      type,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker shot-ai-tasks behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    handlersMock.handleModifyAppearanceTask.mockResolvedValue({ type: 'appearance' })\n    handlersMock.handleModifyLocationTask.mockResolvedValue({ type: 'location' })\n    handlersMock.handleModifyShotPromptTask.mockResolvedValue({ type: 'shot-prompt' })\n    handlersMock.handleAnalyzeShotVariantsTask.mockResolvedValue({ type: 'variants' })\n  })\n\n  it('AI_MODIFY_APPEARANCE -> routes to appearance handler with payload', async () => {\n    const payload = { characterId: 'char-1', appearanceId: 'app-1' }\n    const job = buildJob(TASK_TYPE.AI_MODIFY_APPEARANCE, payload)\n\n    const result = await handleShotAITask(job)\n\n    expect(result).toEqual({ type: 'appearance' })\n    expect(handlersMock.handleModifyAppearanceTask).toHaveBeenCalledWith(job, payload)\n  })\n\n  it('AI_MODIFY_LOCATION / AI_MODIFY_SHOT_PROMPT / ANALYZE_SHOT_VARIANTS route correctly', async () => {\n    const locationPayload = { locationId: 'loc-1' }\n    const locationJob = buildJob(TASK_TYPE.AI_MODIFY_LOCATION, locationPayload)\n    await handleShotAITask(locationJob)\n    expect(handlersMock.handleModifyLocationTask).toHaveBeenCalledWith(locationJob, locationPayload)\n\n    const shotPayload = { currentPrompt: 'old prompt', modifyInstruction: 'new angle' }\n    const shotJob = buildJob(TASK_TYPE.AI_MODIFY_SHOT_PROMPT, shotPayload)\n    await handleShotAITask(shotJob)\n    expect(handlersMock.handleModifyShotPromptTask).toHaveBeenCalledWith(shotJob, shotPayload)\n\n    const variantPayload = { panelId: 'panel-1' }\n    const variantJob = buildJob(TASK_TYPE.ANALYZE_SHOT_VARIANTS, variantPayload)\n    await handleShotAITask(variantJob)\n    expect(handlersMock.handleAnalyzeShotVariantsTask).toHaveBeenCalledWith(variantJob, variantPayload)\n  })\n\n  it('unsupported type -> throws explicit error', async () => {\n    const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, {})\n    await expect(handleShotAITask(job)).rejects.toThrow('Unsupported shot AI task type')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/shot-ai-variants.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionPanel: {\n    findUnique: vi.fn(),\n  },\n}))\n\nconst llmMock = vi.hoisted(() => ({\n  chatCompletionWithVision: vi.fn(),\n  getCompletionContent: vi.fn(),\n}))\n\nconst cosMock = vi.hoisted(() => ({\n  getSignedUrl: vi.fn(),\n}))\n\nconst streamCtxMock = vi.hoisted(() => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nconst llmStreamMock = vi.hoisted(() => {\n  const flush = vi.fn(async () => undefined)\n  return {\n    flush,\n    createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n    createWorkerLLMStreamCallbacks: vi.fn(() => ({\n      onStage: vi.fn(),\n      onChunk: vi.fn(),\n      onComplete: vi.fn(),\n      onError: vi.fn(),\n      flush,\n    })),\n  }\n})\n\nconst persistMock = vi.hoisted(() => ({\n  resolveAnalysisModel: vi.fn(),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => llmMock)\nvi.mock('@/lib/storage', () => cosMock)\nvi.mock('@/lib/llm-observe/internal-stream-context', () => streamCtxMock)\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: workerMock.reportTaskProgress,\n}))\nvi.mock('@/lib/workers/utils', () => ({\n  assertTaskActive: workerMock.assertTaskActive,\n}))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: llmStreamMock.createWorkerLLMStreamContext,\n  createWorkerLLMStreamCallbacks: llmStreamMock.createWorkerLLMStreamCallbacks,\n}))\nvi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_ANALYSIS: 'np_agent_shot_variant_analysis' },\n  buildPrompt: vi.fn(() => 'shot-variants-prompt'),\n}))\n\nimport { handleAnalyzeShotVariantsTask } from '@/lib/workers/handlers/shot-ai-variants'\n\nfunction buildJob(payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-shot-variants-1',\n      type: TASK_TYPE.ANALYZE_SHOT_VARIANTS,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker shot-ai-variants behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis-1' })\n    prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({\n      id: 'panel-1',\n      panelNumber: 3,\n      imageUrl: 'images/panel-1.png',\n      description: 'panel desc',\n      shotType: 'medium',\n      cameraMove: 'static',\n      location: 'Old Town',\n      characters: JSON.stringify([{ name: 'Hero', appearance: 'black coat' }]),\n    })\n    cosMock.getSignedUrl.mockReturnValue('https://signed.example/panel-1.png')\n    llmMock.chatCompletionWithVision.mockResolvedValue({ id: 'vision-1' })\n    llmMock.getCompletionContent.mockReturnValue('[{\"name\":\"v1\"},{\"name\":\"v2\"},{\"name\":\"v3\"}]')\n  })\n\n  it('panel not found -> explicit error', async () => {\n    prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce(null)\n    const job = buildJob({ panelId: 'panel-404' })\n\n    await expect(handleAnalyzeShotVariantsTask(job, job.data.payload as Record<string, unknown>)).rejects.toThrow('Panel not found')\n  })\n\n  it('success -> returns suggestions and signed panel image', async () => {\n    const payload = { panelId: 'panel-1' }\n    const job = buildJob(payload)\n\n    const result = await handleAnalyzeShotVariantsTask(job, payload)\n\n    expect(llmMock.chatCompletionWithVision).toHaveBeenCalledWith(\n      'user-1',\n      'llm::analysis-1',\n      'shot-variants-prompt',\n      ['https://signed.example/panel-1.png'],\n      expect.objectContaining({\n        projectId: 'project-1',\n        action: 'analyze_shot_variants',\n      }),\n    )\n\n    expect(result).toEqual(expect.objectContaining({\n      success: true,\n      suggestions: [{ name: 'v1' }, { name: 'v2' }, { name: 'v3' }],\n      panelInfo: expect.objectContaining({\n        panelNumber: 3,\n        imageUrl: 'https://signed.example/panel-1.png',\n      }),\n    }))\n    expect(llmStreamMock.flush).toHaveBeenCalled()\n  })\n\n  it('suggestions fewer than 3 -> explicit error', async () => {\n    llmMock.getCompletionContent.mockReturnValueOnce('[{\"name\":\"only-one\"}]')\n    const payload = { panelId: 'panel-1' }\n    const job = buildJob(payload)\n\n    await expect(handleAnalyzeShotVariantsTask(job, payload)).rejects.toThrow('生成的变体数量不足')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/story-to-script-orchestrator.retry.test.ts",
    "content": "import { describe, expect, it, vi } from 'vitest'\nimport { runStoryToScriptOrchestrator } from '@/lib/novel-promotion/story-to-script/orchestrator'\n\ndescribe('story-to-script orchestrator retry', () => {\n  it('retries retryable step failure up to 3 attempts', async () => {\n    const actionCalls = new Map<string, number>()\n    const characterMetas: Array<{ stepId: string; stepAttempt?: number }> = []\n    const runStep = vi.fn(async (meta, _prompt, action: string) => {\n      actionCalls.set(action, (actionCalls.get(action) || 0) + 1)\n\n      if (action === 'analyze_characters') {\n        characterMetas.push({ stepId: meta.stepId, stepAttempt: meta.stepAttempt })\n        const count = actionCalls.get(action) || 0\n        if (count < 3) {\n          throw new TypeError('terminated')\n        }\n        return { text: JSON.stringify({ characters: [{ name: '甲', introduction: '人物介绍' }] }), reasoning: '' }\n      }\n      if (action === 'analyze_locations') {\n        return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }\n      }\n      if (action === 'split_clips') {\n        return {\n          text: JSON.stringify([\n            {\n              start: '甲在门口',\n              end: '乙回答',\n              summary: '片段摘要',\n              location: '地点A',\n              characters: ['甲'],\n            },\n          ]),\n          reasoning: '',\n        }\n      }\n      return { text: JSON.stringify({ scenes: [{ id: 1 }] }), reasoning: '' }\n    })\n\n    const result = await runStoryToScriptOrchestrator({\n      content: '甲在门口。乙回答。',\n      baseCharacters: [],\n      baseLocations: [],\n      baseCharacterIntroductions: [],\n      promptTemplates: {\n        characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',\n        locationPromptTemplate: '{input} {locations_lib_name}',\n        clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',\n        screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',\n      },\n      runStep,\n    })\n\n    expect(result.summary.clipCount).toBe(1)\n    expect(actionCalls.get('analyze_characters')).toBe(3)\n    expect(characterMetas).toEqual([\n      { stepId: 'analyze_characters', stepAttempt: undefined },\n      { stepId: 'analyze_characters', stepAttempt: 2 },\n      { stepId: 'analyze_characters', stepAttempt: 3 },\n    ])\n  })\n\n  it('does not retry non-retryable failures', async () => {\n    const actionCalls = new Map<string, number>()\n    const runStep = vi.fn(async (_meta, _prompt, action: string) => {\n      actionCalls.set(action, (actionCalls.get(action) || 0) + 1)\n      if (action === 'analyze_characters') {\n        throw new Error('SENSITIVE_CONTENT: blocked')\n      }\n      return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }\n    })\n\n    await expect(\n      runStoryToScriptOrchestrator({\n        content: '甲在门口。乙回答。',\n        baseCharacters: [],\n        baseLocations: [],\n        baseCharacterIntroductions: [],\n        promptTemplates: {\n          characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',\n          locationPromptTemplate: '{input} {locations_lib_name}',\n          clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',\n          screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',\n        },\n        runStep,\n      }),\n    ).rejects.toThrow('SENSITIVE_CONTENT')\n\n    expect(actionCalls.get('analyze_characters')).toBe(1)\n  })\n\n  it('does not retry Ark invalid parameter errors even if message contains json', async () => {\n    const actionCalls = new Map<string, number>()\n    const runStep = vi.fn(async (_meta, _prompt, action: string) => {\n      actionCalls.set(action, (actionCalls.get(action) || 0) + 1)\n      if (action === 'analyze_characters') {\n        throw new Error(\n          'Ark Responses 调用失败: 400 - {\"error\":{\"code\":\"InvalidParameter\",\"message\":\"json: unknown field \\\\\"reasoning_effort\\\\\"\"}}',\n        )\n      }\n      return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }\n    })\n\n    await expect(\n      runStoryToScriptOrchestrator({\n        content: '甲在门口。乙回答。',\n        baseCharacters: [],\n        baseLocations: [],\n        baseCharacterIntroductions: [],\n        promptTemplates: {\n          characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',\n          locationPromptTemplate: '{input} {locations_lib_name}',\n          clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',\n          screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',\n        },\n        runStep,\n      }),\n    ).rejects.toThrow('unknown field')\n\n    expect(actionCalls.get('analyze_characters')).toBe(1)\n  })\n\n  it('parses first balanced JSON block when model appends extra JSON text', async () => {\n    const runStep = vi.fn(async (_meta, _prompt, action: string) => {\n      if (action === 'analyze_characters') {\n        return {\n          text: '{\"characters\":[{\"name\":\"甲\",\"introduction\":\"人物介绍\"}]}\\n{\"extra\":\"ignored\"}',\n          reasoning: '',\n        }\n      }\n      if (action === 'analyze_locations') {\n        return {\n          text: '{\"locations\":[{\"name\":\"地点A\"}]}\\n{\"extra\":\"ignored\"}',\n          reasoning: '',\n        }\n      }\n      if (action === 'split_clips') {\n        return {\n          text: '[{\"start\":\"甲在门口\",\"end\":\"乙回答\",\"summary\":\"片段摘要\",\"location\":\"地点A\",\"characters\":[\"甲\"]}]\\n{\"extra\":\"ignored\"}',\n          reasoning: '',\n        }\n      }\n      if (action === 'screenplay_conversion') {\n        return {\n          text: '{\"scenes\":[{\"scene_number\":1,\"content\":[{\"type\":\"action\",\"text\":\"甲在门口。\"}]}]}\\n{\"extra\":\"ignored\"}',\n          reasoning: '',\n        }\n      }\n      throw new Error(`unexpected action: ${action}`)\n    })\n\n    const result = await runStoryToScriptOrchestrator({\n      content: '甲在门口。乙回答。',\n      baseCharacters: [],\n      baseLocations: [],\n      baseCharacterIntroductions: [],\n      promptTemplates: {\n        characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',\n        locationPromptTemplate: '{input} {locations_lib_name}',\n        clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',\n        screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',\n      },\n      runStep,\n    })\n\n    expect(result.summary.clipCount).toBe(1)\n    expect(result.summary.screenplayFailedCount).toBe(0)\n    expect(result.summary.screenplaySuccessCount).toBe(1)\n    expect(result.screenplayResults[0]).toMatchObject({\n      clipId: 'clip_1',\n      success: true,\n      sceneCount: 1,\n    })\n  })\n\n  it('enforces topology: split waits for analyses, screenplay waits for split', async () => {\n    const actionOrder: string[] = []\n    const runStep = vi.fn(async (_meta, _prompt, action: string) => {\n      actionOrder.push(action)\n      if (action === 'analyze_characters') {\n        return { text: JSON.stringify({ characters: [{ name: '甲', introduction: '人物介绍' }] }), reasoning: '' }\n      }\n      if (action === 'analyze_locations') {\n        return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }\n      }\n      if (action === 'split_clips') {\n        return {\n          text: JSON.stringify([\n            {\n              start: '甲在门口',\n              end: '乙回答',\n              summary: '片段摘要',\n              location: '地点A',\n              characters: ['甲'],\n            },\n          ]),\n          reasoning: '',\n        }\n      }\n      if (action === 'screenplay_conversion') {\n        return {\n          text: JSON.stringify({ scenes: [{ scene_number: 1 }] }),\n          reasoning: '',\n        }\n      }\n      throw new Error(`unexpected action: ${action}`)\n    })\n\n    const result = await runStoryToScriptOrchestrator({\n      content: '甲在门口。乙回答。',\n      baseCharacters: [],\n      baseLocations: [],\n      baseCharacterIntroductions: [],\n      promptTemplates: {\n        characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',\n        locationPromptTemplate: '{input} {locations_lib_name}',\n        clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',\n        screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',\n      },\n      runStep,\n    })\n\n    expect(result.summary.clipCount).toBe(1)\n    const analyzeCharactersIndex = actionOrder.indexOf('analyze_characters')\n    const analyzeLocationsIndex = actionOrder.indexOf('analyze_locations')\n    const splitIndex = actionOrder.indexOf('split_clips')\n    const screenplayIndex = actionOrder.indexOf('screenplay_conversion')\n    expect(splitIndex).toBeGreaterThan(Math.max(analyzeCharactersIndex, analyzeLocationsIndex))\n    expect(screenplayIndex).toBeGreaterThan(splitIndex)\n  })\n\n  it('limits screenplay conversion fan-out by configured concurrency', async () => {\n    let activeScreenplay = 0\n    let maxActiveScreenplay = 0\n\n    const runStep = vi.fn(async (_meta, _prompt, action: string) => {\n      if (action === 'analyze_characters') {\n        return { text: JSON.stringify({ characters: [{ name: '甲', introduction: '人物介绍' }] }), reasoning: '' }\n      }\n      if (action === 'analyze_locations') {\n        return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }\n      }\n      if (action === 'split_clips') {\n        return {\n          text: JSON.stringify([\n            { start: '甲在门口', end: '乙回应', summary: '片段1', location: '地点A', characters: ['甲'] },\n            { start: '丙出场', end: '丁离开', summary: '片段2', location: '地点A', characters: ['丙'] },\n            { start: '戊总结', end: '己收尾', summary: '片段3', location: '地点A', characters: ['戊'] },\n          ]),\n          reasoning: '',\n        }\n      }\n      if (action === 'screenplay_conversion') {\n        activeScreenplay += 1\n        maxActiveScreenplay = Math.max(maxActiveScreenplay, activeScreenplay)\n        await new Promise((resolve) => setTimeout(resolve, 5))\n        activeScreenplay -= 1\n        return { text: JSON.stringify({ scenes: [{ scene_number: 1 }] }), reasoning: '' }\n      }\n      throw new Error(`unexpected action: ${action}`)\n    })\n\n    const result = await runStoryToScriptOrchestrator({\n      concurrency: 1,\n      content: '甲在门口。乙回应。丙出场。丁离开。戊总结。己收尾。',\n      baseCharacters: [],\n      baseLocations: [],\n      baseCharacterIntroductions: [],\n      promptTemplates: {\n        characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',\n        locationPromptTemplate: '{input} {locations_lib_name}',\n        clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',\n        screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',\n      },\n      runStep,\n    })\n\n    expect(result.summary.clipCount).toBe(3)\n    expect(result.summary.screenplaySuccessCount).toBe(3)\n    expect(maxActiveScreenplay).toBe(1)\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/story-to-script.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  project: { findUnique: vi.fn() },\n  novelPromotionProject: { findUnique: vi.fn() },\n  novelPromotionEpisode: { findUnique: vi.fn() },\n  novelPromotionClip: { update: vi.fn(async () => ({})) },\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nconst configMock = vi.hoisted(() => ({\n  resolveProjectModelCapabilityGenerationOptions: vi.fn(async () => ({ reasoningEffort: 'high' })),\n  getUserWorkflowConcurrencyConfig: vi.fn(async () => ({\n    analysis: 2,\n    image: 5,\n    video: 5,\n  })),\n}))\n\nconst orchestratorMock = vi.hoisted(() => ({\n  runStoryToScriptOrchestrator: vi.fn(),\n}))\nconst helperMock = vi.hoisted(() => ({\n  persistAnalyzedCharacters: vi.fn(async () => [{ id: 'character-new-1' }]),\n  persistAnalyzedLocations: vi.fn(async () => [{ id: 'location-new-1' }]),\n  persistClips: vi.fn(async () => [{ clipKey: 'clip-1', id: 'clip-row-1' }]),\n}))\nconst workflowLeaseMock = vi.hoisted(() => ({\n  assertWorkflowRunActive: vi.fn(async () => undefined),\n  withWorkflowRunLease: vi.fn(async (params: { run: () => Promise<unknown> }) => ({\n    claimed: true,\n    result: await params.run(),\n  })),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => ({\n  chatCompletion: vi.fn(),\n  getCompletionParts: vi.fn(() => ({ text: '', reasoning: '' })),\n  getCompletionContent: vi.fn(() => ''),\n}))\nvi.mock('@/lib/config-service', () => configMock)\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\nvi.mock('@/lib/logging/semantic', () => ({ logAIAnalysis: vi.fn() }))\nvi.mock('@/lib/logging/file-writer', () => ({ onProjectNameAvailable: vi.fn() }))\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))\nvi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))\nvi.mock('@/lib/novel-promotion/story-to-script/orchestrator', () => orchestratorMock)\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    onStage: vi.fn(),\n    onChunk: vi.fn(),\n    onComplete: vi.fn(),\n    onError: vi.fn(),\n    flush: vi.fn(async () => undefined),\n  })),\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: {\n    NP_AGENT_CHARACTER_PROFILE: 'a',\n    NP_SELECT_LOCATION: 'b',\n    NP_AGENT_CLIP: 'c',\n    NP_SCREENPLAY_CONVERSION: 'd',\n  },\n  getPromptTemplate: vi.fn(() => 'prompt-template'),\n}))\nvi.mock('@/lib/workers/handlers/story-to-script-helpers', () => ({\n  asString: (value: unknown) => (typeof value === 'string' ? value : ''),\n  parseEffort: vi.fn(() => null),\n  parseTemperature: vi.fn(() => 0.7),\n  persistAnalyzedCharacters: helperMock.persistAnalyzedCharacters,\n  persistAnalyzedLocations: helperMock.persistAnalyzedLocations,\n  persistClips: helperMock.persistClips,\n  resolveClipRecordId: (clipIdMap: Map<string, string>, clipId: string) => clipIdMap.get(clipId) ?? null,\n}))\nvi.mock('@/lib/run-runtime/workflow-lease', () => workflowLeaseMock)\n\nimport { handleStoryToScriptTask } from '@/lib/workers/handlers/story-to-script'\n\nfunction buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {\n  const runId = typeof payload.runId === 'string' && payload.runId.trim() ? payload.runId.trim() : 'run-test-story'\n  const payloadMeta = payload.meta && typeof payload.meta === 'object' && !Array.isArray(payload.meta)\n    ? (payload.meta as Record<string, unknown>)\n    : {}\n  const normalizedPayload: Record<string, unknown> = {\n    ...payload,\n    runId,\n    meta: {\n      ...payloadMeta,\n      runId,\n    },\n  }\n  return {\n    data: {\n      taskId: 'task-story-to-script-1',\n      type: TASK_TYPE.STORY_TO_SCRIPT_RUN,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      payload: normalizedPayload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker story-to-script behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n\n    prismaMock.project.findUnique.mockResolvedValue({\n      id: 'project-1',\n      name: 'Project One',\n      mode: 'novel-promotion',\n    })\n\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValue({\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-1',\n      characters: [{ id: 'char-1', name: 'Hero', introduction: 'hero intro' }],\n      locations: [{ id: 'loc-1', name: 'Old Town', summary: 'town' }],\n    })\n\n    prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({\n      id: 'episode-1',\n      novelPromotionProjectId: 'np-project-1',\n      novelText: 'episode text',\n    })\n\n    orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValue({\n      analyzedCharacters: [{ name: 'New Hero' }],\n      analyzedLocations: [{ name: 'Market' }],\n      clipList: [{ clipId: 'clip-1', content: 'clip content' }],\n      screenplayResults: [\n        {\n          clipId: 'clip-1',\n          success: true,\n          screenplay: { scenes: [{ shot: 'close-up' }] },\n        },\n      ],\n      summary: {\n        clipCount: 1,\n        screenplaySuccessCount: 1,\n        screenplayFailedCount: 0,\n      },\n    })\n  })\n\n  it('missing episodeId -> explicit error', async () => {\n    const job = buildJob({}, null)\n    await expect(handleStoryToScriptTask(job)).rejects.toThrow('episodeId is required')\n  })\n\n  it('success path -> persists clips and screenplay with concrete fields', async () => {\n    const job = buildJob({ episodeId: 'episode-1', content: 'input content' })\n    const result = await handleStoryToScriptTask(job)\n\n    expect(result).toEqual({\n      episodeId: 'episode-1',\n      clipCount: 1,\n      screenplaySuccessCount: 1,\n      screenplayFailedCount: 0,\n      persistedCharacters: 1,\n      persistedLocations: 1,\n      persistedClips: 1,\n    })\n\n    expect(helperMock.persistClips).toHaveBeenCalledWith({\n      episodeId: 'episode-1',\n      clipList: [{ clipId: 'clip-1', content: 'clip content' }],\n    })\n\n    expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({\n      where: { id: 'clip-row-1' },\n      data: {\n        screenplay: JSON.stringify({ scenes: [{ shot: 'close-up' }] }),\n      },\n    })\n  })\n\n  it('orchestrator partial failure summary -> throws explicit error', async () => {\n    orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValueOnce({\n      analyzedCharacters: [],\n      analyzedLocations: [],\n      clipList: [],\n      screenplayResults: [\n        {\n          clipId: 'clip-3',\n          success: false,\n          error: 'bad screenplay json',\n        },\n      ],\n      summary: {\n        clipCount: 1,\n        screenplaySuccessCount: 0,\n        screenplayFailedCount: 1,\n      },\n    })\n\n    const job = buildJob({ episodeId: 'episode-1', content: 'input content' })\n    await expect(handleStoryToScriptTask(job)).rejects.toThrow('STORY_TO_SCRIPT_PARTIAL_FAILED')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/user-concurrency-gate.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { withUserConcurrencyGate } from '@/lib/workers/user-concurrency-gate'\n\nfunction deferred<T>() {\n  let resolve!: (value: T | PromiseLike<T>) => void\n  const promise = new Promise<T>((nextResolve) => {\n    resolve = nextResolve\n  })\n  return { promise, resolve }\n}\n\ndescribe('user concurrency gate', () => {\n  it('serializes same-scope work for the same user when limit is 1', async () => {\n    const firstDone = deferred<void>()\n    const events: string[] = []\n\n    const first = withUserConcurrencyGate({\n      scope: 'image',\n      userId: 'user-1',\n      limit: 1,\n      run: async () => {\n        events.push('first:start')\n        await firstDone.promise\n        events.push('first:end')\n      },\n    })\n\n    const second = withUserConcurrencyGate({\n      scope: 'image',\n      userId: 'user-1',\n      limit: 1,\n      run: async () => {\n        events.push('second:start')\n        events.push('second:end')\n      },\n    })\n\n    await Promise.resolve()\n    expect(events).toEqual(['first:start'])\n\n    firstDone.resolve()\n    await Promise.all([first, second])\n\n    expect(events).toEqual([\n      'first:start',\n      'first:end',\n      'second:start',\n      'second:end',\n    ])\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/video-generation-resume.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport type { TaskJobData } from '@/lib/task/types'\n\nconst prismaMock = vi.hoisted(() => ({\n  task: {\n    findUnique: vi.fn(),\n  },\n}))\n\nconst taskServiceMock = vi.hoisted(() => ({\n  isTaskActive: vi.fn(async () => true),\n  trySetTaskExternalId: vi.fn(async () => true),\n}))\n\nconst asyncPollMock = vi.hoisted(() => ({\n  pollAsyncTask: vi.fn(),\n}))\n\nconst generatorApiMock = vi.hoisted(() => ({\n  generateImage: vi.fn(),\n  generateVideo: vi.fn(),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/task/service', () => taskServiceMock)\nvi.mock('@/lib/async-poll', () => asyncPollMock)\nvi.mock('@/lib/generator-api', () => generatorApiMock)\nvi.mock('@/lib/lipsync', () => ({ generateLipSync: vi.fn() }))\nvi.mock('@/lib/storage', () => ({\n  getSignedUrl: vi.fn((value: string) => value),\n  toFetchableUrl: vi.fn((value: string) => value),\n}))\nvi.mock('@/lib/fonts', () => ({ initializeFonts: vi.fn(), createLabelSVG: vi.fn() }))\nvi.mock('@/lib/media-process', () => ({ processMediaResult: vi.fn() }))\nvi.mock('@/lib/config-service', () => ({\n  getProjectModelConfig: vi.fn(),\n  getUserModelConfig: vi.fn(),\n  resolveProjectModelCapabilityGenerationOptions: vi.fn(),\n}))\n\nimport { resolveImageSourceFromGeneration, resolveVideoSourceFromGeneration } from '@/lib/workers/utils'\n\nfunction buildJob(): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-1',\n      type: 'VIDEO_PANEL',\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: 'NovelPromotionPanel',\n      targetId: 'panel-1',\n      payload: {},\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker utils video generation resume', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n  })\n\n  it('continues polling from existing externalId without re-submitting generation', async () => {\n    const externalId = 'OPENAI:VIDEO:b3BlbmFpLWNvbXBhdGlibGU6b2EtMQ:vid_123'\n    prismaMock.task.findUnique.mockResolvedValueOnce({ externalId })\n    asyncPollMock.pollAsyncTask.mockResolvedValueOnce({\n      status: 'completed',\n      resultUrl: 'https://oa.test/v1/videos/vid_123/content',\n      downloadHeaders: {\n        Authorization: 'Bearer oa-key',\n      },\n    })\n\n    const result = await resolveVideoSourceFromGeneration(buildJob(), {\n      userId: 'user-1',\n      modelId: 'openai-compatible:oa-1::sora-2',\n      imageUrl: 'data:image/png;base64,QQ==',\n      options: {\n        prompt: 'animate this frame',\n      },\n    })\n\n    expect(result).toEqual({\n      url: 'https://oa.test/v1/videos/vid_123/content',\n      downloadHeaders: {\n        Authorization: 'Bearer oa-key',\n      },\n    })\n    expect(asyncPollMock.pollAsyncTask).toHaveBeenCalledWith(externalId, 'user-1')\n    expect(generatorApiMock.generateVideo).not.toHaveBeenCalled()\n  })\n\n  it('prevents duplicate panel candidates by skipping task externalId resume when requested', async () => {\n    prismaMock.task.findUnique.mockResolvedValueOnce({ externalId: 'FAL:IMAGE:fal-ai/nano-banana-pro:req_1' })\n    generatorApiMock.generateImage.mockResolvedValueOnce({\n      success: true,\n      imageUrl: 'https://fal.test/new-image.png',\n    })\n\n    const result = await resolveImageSourceFromGeneration(buildJob(), {\n      userId: 'user-1',\n      modelId: 'fal::banana',\n      prompt: 'a cinematic portrait',\n      options: {\n        aspectRatio: '16:9',\n      },\n      allowTaskExternalIdResume: false,\n    })\n\n    expect(result).toBe('https://fal.test/new-image.png')\n    expect(prismaMock.task.findUnique).not.toHaveBeenCalled()\n    expect(asyncPollMock.pollAsyncTask).not.toHaveBeenCalled()\n    expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(1)\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/video-worker.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\ntype WorkerProcessor = (job: Job<TaskJobData>) => Promise<unknown>\n\ntype PanelRow = {\n  id: string\n  videoUrl: string | null\n  imageUrl: string | null\n  videoPrompt: string | null\n  description: string | null\n  firstLastFramePrompt: string | null\n  duration: number | null\n}\n\nconst workerState = vi.hoisted(() => ({\n  processor: null as WorkerProcessor | null,\n}))\n\nconst reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))\nconst withTaskLifecycleMock = vi.hoisted(() =>\n  vi.fn(async (job: Job<TaskJobData>, handler: WorkerProcessor) => await handler(job)),\n)\n\nconst utilsMock = vi.hoisted(() => ({\n  assertTaskActive: vi.fn(async () => undefined),\n  getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })),\n  resolveLipSyncVideoSource: vi.fn(async () => 'https://provider.example/lipsync.mp4'),\n  resolveVideoSourceFromGeneration: vi.fn<(...args: unknown[]) => Promise<{ url: string; downloadHeaders?: Record<string, string> }>>(async () => ({ url: 'https://provider.example/video.mp4' })),\n  toSignedUrlIfCos: vi.fn((url: string | null) => (url ? `https://signed.example/${url}` : null)),\n  uploadVideoSourceToCos: vi.fn(async () => 'cos/lip-sync/video.mp4'),\n}))\nconst configServiceMock = vi.hoisted(() => ({\n  getUserWorkflowConcurrencyConfig: vi.fn(async () => ({\n    analysis: 5,\n    image: 5,\n    video: 5,\n  })),\n}))\nconst concurrencyGateMock = vi.hoisted(() => ({\n  withUserConcurrencyGate: vi.fn(async <T>(input: {\n    run: () => Promise<T>\n  }) => await input.run()),\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  novelPromotionPanel: {\n    findUnique: vi.fn(),\n    findFirst: vi.fn(),\n    update: vi.fn(async () => undefined),\n  },\n  novelPromotionVoiceLine: {\n    findUnique: vi.fn(),\n  },\n}))\n\nvi.mock('bullmq', () => ({\n  Queue: class {\n    constructor(name: string) {\n      void name\n    }\n\n    async add() {\n      return { id: 'job-1' }\n    }\n\n    async getJob() {\n      return null\n    }\n  },\n  Worker: class {\n    constructor(name: string, processor: WorkerProcessor) {\n      void name\n      workerState.processor = processor\n    }\n  },\n}))\n\nvi.mock('@/lib/redis', () => ({ queueRedis: {} }))\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: reportTaskProgressMock,\n  withTaskLifecycle: withTaskLifecycleMock,\n}))\nvi.mock('@/lib/workers/utils', () => utilsMock)\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/media/outbound-image', () => ({\n  normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),\n}))\nvi.mock('@/lib/model-capabilities/lookup', () => ({\n  resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),\n}))\nvi.mock('@/lib/model-config-contract', () => ({\n  parseModelKeyStrict: vi.fn(() => ({ provider: 'fal' })),\n}))\nvi.mock('@/lib/api-config', () => ({\n  getProviderConfig: vi.fn(async () => ({ apiKey: 'api-key' })),\n}))\nvi.mock('@/lib/config-service', () => configServiceMock)\nvi.mock('@/lib/workers/user-concurrency-gate', () => concurrencyGateMock)\n\nfunction buildPanel(overrides?: Partial<PanelRow>): PanelRow {\n  return {\n    id: 'panel-1',\n    videoUrl: 'cos/base-video.mp4',\n    imageUrl: 'cos/panel-image.png',\n    videoPrompt: 'panel prompt',\n    description: 'panel description',\n    firstLastFramePrompt: null,\n    duration: 5,\n    ...(overrides || {}),\n  }\n}\n\nfunction buildJob(params: {\n  type: TaskJobData['type']\n  payload?: Record<string, unknown>\n  targetType?: string\n  targetId?: string\n}): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-1',\n      type: params.type,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: 'episode-1',\n      targetType: params.targetType ?? 'NovelPromotionPanel',\n      targetId: params.targetId ?? 'panel-1',\n      payload: params.payload ?? {},\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker video processor behavior', () => {\n  beforeEach(async () => {\n    vi.clearAllMocks()\n    workerState.processor = null\n\n    prismaMock.novelPromotionPanel.findUnique.mockResolvedValue(buildPanel())\n    prismaMock.novelPromotionPanel.findFirst.mockResolvedValue(buildPanel())\n    prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({\n      id: 'line-1',\n      audioUrl: 'cos/line-1.mp3',\n      audioDuration: 1200,\n    })\n\n    const mod = await import('@/lib/workers/video.worker')\n    mod.createVideoWorker()\n  })\n\n  it('VIDEO_PANEL: 缺少 payload.videoModel 时显式失败', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    const job = buildJob({\n      type: TASK_TYPE.VIDEO_PANEL,\n      payload: {},\n    })\n\n    await expect(processor!(job)).rejects.toThrow('VIDEO_MODEL_REQUIRED: payload.videoModel is required')\n  })\n\n  it('VIDEO_PANEL: 透传异步轮询返回的下载头到 COS 上传', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    utilsMock.resolveVideoSourceFromGeneration.mockResolvedValueOnce({\n      url: 'https://provider.example/video.mp4',\n      downloadHeaders: {\n        Authorization: 'Bearer oa-key',\n      },\n    })\n\n    const job = buildJob({\n      type: TASK_TYPE.VIDEO_PANEL,\n      payload: {\n        videoModel: 'openai-compatible:oa-1::sora-2',\n        generationOptions: {\n          duration: 8,\n          resolution: '720p',\n        },\n      },\n    })\n\n    await processor!(job)\n\n    expect(utilsMock.uploadVideoSourceToCos).toHaveBeenCalledWith(\n      'https://provider.example/video.mp4',\n      'panel-video',\n      'panel-1',\n      {\n        Authorization: 'Bearer oa-key',\n      },\n    )\n  })\n\n  it('LIP_SYNC: 缺少 panel 时显式失败', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce(null)\n    const job = buildJob({\n      type: TASK_TYPE.LIP_SYNC,\n      payload: { voiceLineId: 'line-1' },\n      targetId: 'panel-missing',\n    })\n\n    await expect(processor!(job)).rejects.toThrow('Lip-sync panel not found')\n  })\n\n  it('LIP_SYNC: 正常路径写回 lipSyncVideoUrl 并清理 lipSyncTaskId', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    const job = buildJob({\n      type: TASK_TYPE.LIP_SYNC,\n      payload: {\n        voiceLineId: 'line-1',\n        lipSyncModel: 'fal::lipsync-model',\n      },\n      targetId: 'panel-1',\n    })\n\n    const result = await processor!(job) as { panelId: string; voiceLineId: string; lipSyncVideoUrl: string }\n    expect(result).toEqual({\n      panelId: 'panel-1',\n      voiceLineId: 'line-1',\n      lipSyncVideoUrl: 'cos/lip-sync/video.mp4',\n    })\n\n    expect(utilsMock.resolveLipSyncVideoSource).toHaveBeenCalledWith(\n      expect.anything(),\n      expect.objectContaining({\n        userId: 'user-1',\n        modelKey: 'fal::lipsync-model',\n        audioDurationMs: 1200,\n        videoDurationMs: 5000,\n      }),\n    )\n\n    expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({\n      where: { id: 'panel-1' },\n      data: {\n        lipSyncVideoUrl: 'cos/lip-sync/video.mp4',\n        lipSyncTaskId: null,\n      },\n    })\n  })\n\n  it('未知任务类型: 显式报错', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    const unsupportedJob = buildJob({\n      type: TASK_TYPE.AI_CREATE_CHARACTER,\n    })\n\n    await expect(processor!(unsupportedJob)).rejects.toThrow('Unsupported video task type')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/voice-analyze.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst txState = vi.hoisted(() => ({\n  createdRows: [] as Array<Record<string, unknown>>,\n  deletedWhereClauses: [] as Array<Record<string, unknown>>,\n}))\n\nconst prismaMock = vi.hoisted(() => ({\n  project: { findUnique: vi.fn() },\n  novelPromotionProject: { findUnique: vi.fn() },\n  novelPromotionEpisode: { findUnique: vi.fn() },\n  $transaction: vi.fn(),\n}))\n\nconst llmMock = vi.hoisted(() => ({\n  chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),\n  getCompletionContent: vi.fn(() => 'voice-line-json'),\n}))\n\nconst helperMock = vi.hoisted(() => ({\n  parseVoiceLinesJson: vi.fn(),\n  buildStoryboardJson: vi.fn(() => 'storyboard-json'),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))\nvi.mock('@/lib/llm-client', () => llmMock)\nvi.mock('@/lib/llm-observe/internal-stream-context', () => ({\n  withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),\n}))\nvi.mock('@/lib/constants', () => ({\n  buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),\n}))\nvi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))\nvi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))\nvi.mock('@/lib/workers/handlers/llm-stream', () => ({\n  createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),\n  createWorkerLLMStreamCallbacks: vi.fn(() => ({\n    onStage: vi.fn(),\n    onChunk: vi.fn(),\n    onComplete: vi.fn(),\n    onError: vi.fn(),\n    flush: vi.fn(async () => undefined),\n  })),\n}))\nvi.mock('@/lib/workers/handlers/voice-analyze-helpers', () => ({\n  buildStoryboardJson: helperMock.buildStoryboardJson,\n  parseVoiceLinesJson: helperMock.parseVoiceLinesJson,\n}))\nvi.mock('@/lib/prompt-i18n', () => ({\n  PROMPT_IDS: { NP_VOICE_ANALYSIS: 'np_voice_analysis' },\n  buildPrompt: vi.fn(() => 'voice-analysis-prompt'),\n}))\n\nimport { handleVoiceAnalyzeTask } from '@/lib/workers/handlers/voice-analyze'\n\nfunction buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-voice-analyze-1',\n      type: TASK_TYPE.VOICE_ANALYZE,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId,\n      targetType: 'NovelPromotionEpisode',\n      targetId: 'episode-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker voice-analyze behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    txState.createdRows = []\n    txState.deletedWhereClauses = []\n\n    prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })\n    prismaMock.novelPromotionProject.findUnique.mockResolvedValue({\n      id: 'np-project-1',\n      analysisModel: 'llm::analysis-1',\n      characters: [{ id: 'char-1', name: 'Hero' }],\n    })\n\n    prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({\n      id: 'episode-1',\n      novelPromotionProjectId: 'np-project-1',\n      novelText: '这是可以用于台词分析的文本',\n      storyboards: [\n        {\n          id: 'storyboard-1',\n          clip: { id: 'clip-1' },\n          panels: [{ id: 'panel-1', panelIndex: 0 }],\n        },\n      ],\n    })\n\n    helperMock.parseVoiceLinesJson.mockReturnValue([\n      {\n        lineIndex: 1,\n        speaker: 'Hero',\n        content: '第一句台词',\n        emotionStrength: 0.7,\n        matchedPanel: {\n          storyboardId: 'storyboard-1',\n          panelIndex: 0,\n        },\n      },\n      {\n        lineIndex: 2,\n        speaker: 'Narrator',\n        content: '第二句旁白',\n        emotionStrength: 0.5,\n      },\n    ])\n\n    prismaMock.$transaction.mockImplementation(async (fn: (tx: {\n      novelPromotionVoiceLine: {\n        deleteMany: (args: { where: Record<string, unknown> }) => Promise<unknown>\n        create: (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => Promise<{\n          id: string\n          speaker: string\n          matchedStoryboardId: string | null\n        }>\n      }\n    }) => Promise<unknown>) => {\n      const tx = {\n        novelPromotionVoiceLine: {\n          deleteMany: async (args: { where: Record<string, unknown> }) => {\n            txState.deletedWhereClauses.push(args.where)\n            return undefined\n          },\n          create: async (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => {\n            txState.createdRows.push(args.data)\n            const speaker = typeof args.data.speaker === 'string' ? args.data.speaker : 'unknown'\n            const matchedStoryboardId = typeof args.data.matchedStoryboardId === 'string'\n              ? args.data.matchedStoryboardId\n              : null\n            return {\n              id: `line-${txState.createdRows.length}`,\n              speaker,\n              matchedStoryboardId,\n            }\n          },\n        },\n      }\n      return await fn(tx)\n    })\n  })\n\n  it('missing episodeId -> explicit error', async () => {\n    const job = buildJob({}, null)\n    await expect(handleVoiceAnalyzeTask(job)).rejects.toThrow('episodeId is required')\n  })\n\n  it('success path -> persists mapped panelId and speaker stats', async () => {\n    const job = buildJob({ episodeId: 'episode-1' })\n    const result = await handleVoiceAnalyzeTask(job)\n\n    expect(result).toEqual({\n      episodeId: 'episode-1',\n      count: 2,\n      matchedCount: 1,\n      speakerStats: {\n        Hero: 1,\n        Narrator: 1,\n      },\n    })\n\n    expect(txState.createdRows[0]).toEqual(expect.objectContaining({\n      episodeId: 'episode-1',\n      lineIndex: 1,\n      speaker: 'Hero',\n      content: '第一句台词',\n      matchedPanelId: 'panel-1',\n      matchedStoryboardId: 'storyboard-1',\n      matchedPanelIndex: 0,\n    }))\n    expect(txState.deletedWhereClauses[0]).toEqual({\n      episodeId: 'episode-1',\n      lineIndex: {\n        notIn: [1, 2],\n      },\n    })\n  })\n\n  it('empty voice lines -> success with zero rows and clears existing lines', async () => {\n    helperMock.parseVoiceLinesJson.mockReturnValue([])\n\n    const job = buildJob({ episodeId: 'episode-1' })\n    const result = await handleVoiceAnalyzeTask(job)\n\n    expect(result).toEqual({\n      episodeId: 'episode-1',\n      count: 0,\n      matchedCount: 0,\n      speakerStats: {},\n    })\n    expect(txState.createdRows).toEqual([])\n    expect(txState.deletedWhereClauses[0]).toEqual({\n      episodeId: 'episode-1',\n    })\n  })\n\n  it('line references non-existent storyboard panel -> explicit error', async () => {\n    helperMock.parseVoiceLinesJson.mockImplementation(() => [\n      {\n        lineIndex: 1,\n        speaker: 'Hero',\n        content: 'bad line',\n        emotionStrength: 0.8,\n        matchedPanel: {\n          storyboardId: 'storyboard-404',\n          panelIndex: 0,\n        },\n      },\n    ])\n\n    const job = buildJob({ episodeId: 'episode-1' })\n    await expect(handleVoiceAnalyzeTask(job)).rejects.toThrow('references non-existent panel')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/voice-design.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\nconst bailianMock = vi.hoisted(() => ({\n  createVoiceDesign: vi.fn(),\n  validateVoicePrompt: vi.fn(),\n  validatePreviewText: vi.fn(),\n}))\n\nconst apiConfigMock = vi.hoisted(() => ({\n  getProviderConfig: vi.fn(),\n}))\n\nconst workerMock = vi.hoisted(() => ({\n  reportTaskProgress: vi.fn(async () => undefined),\n  assertTaskActive: vi.fn(async () => undefined),\n}))\n\nvi.mock('@/lib/providers/bailian/voice-design', () => bailianMock)\nvi.mock('@/lib/api-config', () => apiConfigMock)\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: workerMock.reportTaskProgress,\n}))\nvi.mock('@/lib/workers/utils', () => ({\n  assertTaskActive: workerMock.assertTaskActive,\n}))\n\nimport { handleVoiceDesignTask } from '@/lib/workers/handlers/voice-design'\n\nfunction buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-voice-1',\n      type,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: null,\n      targetType: 'VoiceDesign',\n      targetId: 'voice-design-1',\n      payload,\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker voice-design behavior', () => {\n  beforeEach(() => {\n    vi.clearAllMocks()\n    bailianMock.validateVoicePrompt.mockReturnValue({ valid: true })\n    bailianMock.validatePreviewText.mockReturnValue({ valid: true })\n    apiConfigMock.getProviderConfig.mockResolvedValue({ apiKey: 'bailian-key' })\n    bailianMock.createVoiceDesign.mockResolvedValue({\n      success: true,\n      voiceId: 'voice-id-1',\n      targetModel: 'bailian-tts',\n      audioBase64: 'base64-audio',\n      sampleRate: 24000,\n      responseFormat: 'mp3',\n      usageCount: 11,\n      requestId: 'req-1',\n    })\n  })\n\n  it('missing required fields -> explicit error', async () => {\n    const job = buildJob(TASK_TYPE.VOICE_DESIGN, { previewText: 'hello' })\n    await expect(handleVoiceDesignTask(job)).rejects.toThrow('voicePrompt is required')\n  })\n\n  it('invalid prompt validation -> explicit error message from validator', async () => {\n    bailianMock.validateVoicePrompt.mockReturnValue({ valid: false, error: 'bad prompt' })\n\n    const job = buildJob(TASK_TYPE.VOICE_DESIGN, {\n      voicePrompt: 'x',\n      previewText: 'hello',\n    })\n    await expect(handleVoiceDesignTask(job)).rejects.toThrow('bad prompt')\n  })\n\n  it('success path -> submits normalized input and returns typed result', async () => {\n    const job = buildJob(TASK_TYPE.ASSET_HUB_VOICE_DESIGN, {\n      voicePrompt: '  calm female narrator  ',\n      previewText: '  hello world  ',\n      preferredName: '  custom_name  ',\n      language: 'en',\n    })\n\n    const result = await handleVoiceDesignTask(job)\n\n    expect(apiConfigMock.getProviderConfig).toHaveBeenCalledWith('user-1', 'bailian')\n    expect(bailianMock.createVoiceDesign).toHaveBeenCalledWith({\n      voicePrompt: 'calm female narrator',\n      previewText: 'hello world',\n      preferredName: 'custom_name',\n      language: 'en',\n    }, 'bailian-key')\n\n    expect(result).toEqual(expect.objectContaining({\n      success: true,\n      voiceId: 'voice-id-1',\n      taskType: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,\n    }))\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/voice-line-parse-helpers.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { parseVoiceLinesJson as parseStoryboardVoiceLinesJson } from '@/lib/workers/handlers/script-to-storyboard-helpers'\nimport { parseVoiceLinesJson as parseStandaloneVoiceLinesJson } from '@/lib/workers/handlers/voice-analyze-helpers'\n\ndescribe('voice line parse helpers', () => {\n  it('script-to-storyboard parser accepts explicit empty array', () => {\n    expect(parseStoryboardVoiceLinesJson('[]')).toEqual([])\n  })\n\n  it('script-to-storyboard parser rejects non-object array payload', () => {\n    expect(() => parseStoryboardVoiceLinesJson('[1,2]')).toThrow('voice_analyze: invalid payload')\n  })\n\n  it('voice-analyze parser accepts explicit empty array', () => {\n    expect(parseStandaloneVoiceLinesJson('[]')).toEqual([])\n  })\n\n  it('voice-analyze parser rejects non-object array payload', () => {\n    expect(() => parseStandaloneVoiceLinesJson('[1,2]')).toThrow('Invalid voice lines data structure')\n  })\n})\n"
  },
  {
    "path": "tests/unit/worker/voice-worker.test.ts",
    "content": "import type { Job } from 'bullmq'\nimport { beforeEach, describe, expect, it, vi } from 'vitest'\nimport { TASK_TYPE, type TaskJobData } from '@/lib/task/types'\n\ntype WorkerProcessor = (job: Job<TaskJobData>) => Promise<unknown>\n\nconst workerState = vi.hoisted(() => ({\n  processor: null as WorkerProcessor | null,\n}))\n\nconst generateVoiceLineMock = vi.hoisted(() => vi.fn())\nconst handleVoiceDesignTaskMock = vi.hoisted(() => vi.fn())\nconst reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))\nconst withTaskLifecycleMock = vi.hoisted(() =>\n  vi.fn(async (job: Job<TaskJobData>, handler: WorkerProcessor) => await handler(job)),\n)\n\nvi.mock('bullmq', () => ({\n  Queue: class {\n    constructor(_name: string) {}\n\n    async add() {\n      return { id: 'job-1' }\n    }\n\n    async getJob() {\n      return null\n    }\n  },\n  Worker: class {\n    constructor(_name: string, processor: WorkerProcessor) {\n      workerState.processor = processor\n    }\n  },\n}))\n\nvi.mock('@/lib/redis', () => ({\n  queueRedis: {},\n}))\n\nvi.mock('@/lib/voice/generate-voice-line', () => ({\n  generateVoiceLine: generateVoiceLineMock,\n}))\n\nvi.mock('@/lib/workers/shared', () => ({\n  reportTaskProgress: reportTaskProgressMock,\n  withTaskLifecycle: withTaskLifecycleMock,\n}))\n\nvi.mock('@/lib/workers/handlers/voice-design', () => ({\n  handleVoiceDesignTask: handleVoiceDesignTaskMock,\n}))\n\nfunction buildJob(params: {\n  type: TaskJobData['type']\n  targetType?: string\n  targetId?: string\n  episodeId?: string | null\n  payload?: Record<string, unknown>\n}): Job<TaskJobData> {\n  return {\n    data: {\n      taskId: 'task-1',\n      type: params.type,\n      locale: 'zh',\n      projectId: 'project-1',\n      episodeId: params.episodeId !== undefined ? params.episodeId : 'episode-1',\n      targetType: params.targetType ?? 'NovelPromotionVoiceLine',\n      targetId: params.targetId ?? 'line-1',\n      payload: params.payload ?? {},\n      userId: 'user-1',\n    },\n  } as unknown as Job<TaskJobData>\n}\n\ndescribe('worker voice processor behavior', () => {\n  beforeEach(async () => {\n    vi.clearAllMocks()\n    workerState.processor = null\n\n    generateVoiceLineMock.mockResolvedValue({\n      lineId: 'line-1',\n      audioUrl: 'cos/voice-line-1.mp3',\n    })\n    handleVoiceDesignTaskMock.mockResolvedValue({\n      presetId: 'preset-1',\n      previewAudioUrl: 'cos/preset-1.mp3',\n    })\n\n    const mod = await import('@/lib/workers/voice.worker')\n    mod.createVoiceWorker()\n  })\n\n  it('VOICE_LINE: lineId/episodeId 缺失时显式失败', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    const missingLineJob = buildJob({\n      type: TASK_TYPE.VOICE_LINE,\n      targetId: '',\n      payload: { episodeId: 'episode-1' },\n    })\n    await expect(processor!(missingLineJob)).rejects.toThrow('VOICE_LINE task missing lineId')\n\n    const missingEpisodeJob = buildJob({\n      type: TASK_TYPE.VOICE_LINE,\n      episodeId: null,\n      targetId: 'line-1',\n      payload: {},\n    })\n    await expect(processor!(missingEpisodeJob)).rejects.toThrow('VOICE_LINE task missing episodeId')\n  })\n\n  it('VOICE_LINE: 正常生成时把核心参数传给 generateVoiceLine', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    const job = buildJob({\n      type: TASK_TYPE.VOICE_LINE,\n      payload: {\n        lineId: 'line-9',\n        episodeId: 'episode-9',\n        audioModel: 'fal::voice-model',\n      },\n    })\n\n    const result = await processor!(job)\n    expect(result).toEqual({ lineId: 'line-1', audioUrl: 'cos/voice-line-1.mp3' })\n    expect(generateVoiceLineMock).toHaveBeenCalledWith({\n      projectId: 'project-1',\n      episodeId: 'episode-9',\n      lineId: 'line-9',\n      userId: 'user-1',\n      audioModel: 'fal::voice-model',\n    })\n  })\n\n  it('VOICE_DESIGN / ASSET_HUB_VOICE_DESIGN: 路由到 voice design handler', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    const designJob = buildJob({\n      type: TASK_TYPE.VOICE_DESIGN,\n      targetType: 'NovelPromotionVoiceDesign',\n      targetId: 'voice-design-1',\n    })\n\n    const assetHubJob = buildJob({\n      type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,\n      targetType: 'GlobalAssetHubVoiceDesign',\n      targetId: 'asset-hub-voice-design-1',\n    })\n\n    await processor!(designJob)\n    await processor!(assetHubJob)\n\n    expect(handleVoiceDesignTaskMock).toHaveBeenCalledTimes(2)\n    expect(generateVoiceLineMock).not.toHaveBeenCalled()\n  })\n\n  it('未知任务类型: 显式报错', async () => {\n    const processor = workerState.processor\n    expect(processor).toBeTruthy()\n\n    const unsupportedJob = buildJob({\n      type: TASK_TYPE.AI_CREATE_CHARACTER,\n      targetId: 'character-1',\n    })\n\n    await expect(processor!(unsupportedJob)).rejects.toThrow('Unsupported voice task type')\n  })\n})\n"
  },
  {
    "path": "tests/unit/workspace/episode-selection.test.ts",
    "content": "import { describe, expect, it } from 'vitest'\nimport { resolveSelectedEpisodeId } from '@/app/[locale]/workspace/[projectId]/episode-selection'\n\ndescribe('resolveSelectedEpisodeId', () => {\n  it('returns null when episodes list is empty', () => {\n    expect(resolveSelectedEpisodeId([], null)).toBeNull()\n    expect(resolveSelectedEpisodeId([], 'ep-1')).toBeNull()\n  })\n\n  it('uses url episode id when it exists in list', () => {\n    const episodes = [{ id: 'ep-1' }, { id: 'ep-2' }]\n    expect(resolveSelectedEpisodeId(episodes, 'ep-2')).toBe('ep-2')\n  })\n\n  it('falls back to first episode when url episode id is missing', () => {\n    const episodes = [{ id: 'ep-1' }, { id: 'ep-2' }]\n    expect(resolveSelectedEpisodeId(episodes, null)).toBe('ep-1')\n  })\n\n  it('falls back to first episode when url episode id is invalid', () => {\n    const episodes = [{ id: 'ep-1' }, { id: 'ep-2' }]\n    expect(resolveSelectedEpisodeId(episodes, 'ep-404')).toBe('ep-1')\n  })\n})\n"
  },
  {
    "path": "tests/unit/workspace/rebuild-confirm.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest'\n\nconst {\n  useStateMock,\n  useRefMock,\n  useCallbackMock,\n  useMemoMock,\n  setShowRebuildConfirmMock,\n  setRebuildConfirmContextMock,\n  setPendingActionTypeMock,\n} = vi.hoisted(() => ({\n  useStateMock: vi.fn(),\n  useRefMock: vi.fn(() => ({ current: null })),\n  useCallbackMock: vi.fn((fn: unknown) => fn),\n  useMemoMock: vi.fn((factory: () => unknown) => factory()),\n  setShowRebuildConfirmMock: vi.fn(),\n  setRebuildConfirmContextMock: vi.fn(),\n  setPendingActionTypeMock: vi.fn(),\n}))\n\nvi.mock('react', async () => {\n  const actual = await vi.importActual<typeof import('react')>('react')\n  return {\n    ...actual,\n    useState: useStateMock,\n    useRef: useRefMock,\n    useCallback: useCallbackMock,\n    useMemo: useMemoMock,\n  }\n})\n\nimport {\n  hasDownstreamStoryboardData,\n  useRebuildConfirm,\n} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useRebuildConfirm'\n\nfunction createDeferred<T>() {\n  let resolve!: (value: T) => void\n  const promise = new Promise<T>((res) => {\n    resolve = res\n  })\n  return { promise, resolve }\n}\n\ndescribe('useRebuildConfirm', () => {\n  beforeEach(() => {\n    useStateMock.mockReset()\n    useRefMock.mockReset()\n    useCallbackMock.mockClear()\n    useMemoMock.mockClear()\n    setShowRebuildConfirmMock.mockReset()\n    setRebuildConfirmContextMock.mockReset()\n    setPendingActionTypeMock.mockReset()\n\n    useRefMock.mockReturnValue({ current: null })\n    useStateMock\n      .mockImplementationOnce(() => [false, setShowRebuildConfirmMock])\n      .mockImplementationOnce(() => [null, setRebuildConfirmContextMock])\n      .mockImplementationOnce(() => [null, setPendingActionTypeMock])\n  })\n\n  it('clicking story to script -> sets pending action before downstream check resolves', async () => {\n    const deferred = createDeferred<{ storyboardCount: number; panelCount: number }>()\n    const getProjectStoryboardStats = vi.fn(() => deferred.promise)\n    const action = vi.fn(async () => undefined)\n\n    const hook = useRebuildConfirm({\n      episodeId: 'episode-1',\n      episodeStoryboards: [],\n      getProjectStoryboardStats,\n      t: (key: string) => key,\n    })\n\n    const pendingRun = hook.runWithRebuildConfirm('storyToScript', action)\n\n    expect(setPendingActionTypeMock).toHaveBeenCalledWith('storyToScript')\n    expect(getProjectStoryboardStats).toHaveBeenCalledWith('episode-1')\n    expect(action).not.toHaveBeenCalled()\n\n    deferred.resolve({ storyboardCount: 0, panelCount: 0 })\n    await pendingRun\n\n    expect(action).toHaveBeenCalledTimes(1)\n  })\n\n  it('story to script without downstream confirm clears pending action after action completes', async () => {\n    const getProjectStoryboardStats = vi.fn(async () => ({ storyboardCount: 0, panelCount: 0 }))\n    const action = vi.fn(async () => undefined)\n\n    const hook = useRebuildConfirm({\n      episodeId: 'episode-1',\n      episodeStoryboards: [],\n      getProjectStoryboardStats,\n      t: (key: string) => key,\n    })\n\n    await hook.runWithRebuildConfirm('storyToScript', action)\n\n    expect(action).toHaveBeenCalledTimes(1)\n    expect(setPendingActionTypeMock).toHaveBeenCalledTimes(2)\n    expect(setPendingActionTypeMock).toHaveBeenNthCalledWith(1, 'storyToScript')\n\n    const resetCall = setPendingActionTypeMock.mock.calls[1]?.[0]\n    expect(typeof resetCall).toBe('function')\n    if (typeof resetCall !== 'function') {\n      throw new Error('expected reset pending action updater')\n    }\n    expect(resetCall('storyToScript')).toBeNull()\n    expect(resetCall('scriptToStoryboard')).toBe('scriptToStoryboard')\n  })\n})\n\ndescribe('hasDownstreamStoryboardData', () => {\n  it('storyboard and panel counts are both zero -> returns false', () => {\n    expect(hasDownstreamStoryboardData({ storyboardCount: 0, panelCount: 0 })).toBe(false)\n  })\n\n  it('storyboard count is greater than zero -> returns true', () => {\n    expect(hasDownstreamStoryboardData({ storyboardCount: 1, panelCount: 0 })).toBe(true)\n  })\n\n  it('panel count is greater than zero -> returns true', () => {\n    expect(hasDownstreamStoryboardData({ storyboardCount: 0, panelCount: 2 })).toBe(true)\n  })\n})\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2017\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \".next/types/**/*.ts\"],\n  \"exclude\": [\"node_modules\", \"scripts\"]\n}\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config'\nimport { resolve } from 'node:path'\n\nexport default defineConfig({\n  css: {\n    postcss: {\n      plugins: [],\n    },\n  },\n  resolve: {\n    alias: {\n      '@': resolve(__dirname, 'src'),\n    },\n  },\n  test: {\n    environment: 'node',\n    css: false,\n    pool: 'forks',\n    poolOptions: {\n      forks: {\n        minForks: 1,\n        maxForks: 1,\n      },\n    },\n    setupFiles: ['./tests/setup/env.ts'],\n    globalSetup: ['./tests/setup/global-setup.ts'],\n    include: ['**/*.test.ts'],\n    testTimeout: 30_000,\n    hookTimeout: 60_000,\n    coverage: {\n      provider: 'v8',\n      reporter: ['text', 'html', 'json-summary'],\n      reportsDirectory: './coverage/billing',\n      include: [\n        'src/lib/billing/cost.ts',\n        'src/lib/billing/mode.ts',\n        'src/lib/billing/task-policy.ts',\n        'src/lib/billing/runtime-usage.ts',\n        'src/lib/billing/service.ts',\n        'src/lib/billing/ledger.ts',\n      ],\n      thresholds: {\n        branches: 80,\n        functions: 80,\n        lines: 80,\n        statements: 80,\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "vitest.core-coverage.config.ts",
    "content": "import { defineConfig } from 'vitest/config'\nimport { resolve } from 'node:path'\n\nexport default defineConfig({\n  css: {\n    postcss: {\n      plugins: [],\n    },\n  },\n  resolve: {\n    alias: {\n      '@': resolve(__dirname, 'src'),\n    },\n  },\n  test: {\n    environment: 'node',\n    css: false,\n    pool: 'forks',\n    poolOptions: {\n      forks: {\n        minForks: 1,\n        maxForks: 1,\n      },\n    },\n    setupFiles: ['./tests/setup/env.ts'],\n    globalSetup: ['./tests/setup/global-setup.ts'],\n    include: ['**/*.test.ts'],\n    testTimeout: 30_000,\n    hookTimeout: 60_000,\n    coverage: {\n      provider: 'v8',\n      reporter: ['text', 'json-summary'],\n      reportsDirectory: './coverage/core-baseline',\n      include: [\n        'src/app/api/**',\n        'src/lib/task/**',\n        'src/lib/workers/**',\n        'src/lib/media/**',\n        'src/lib/errors/**',\n      ],\n      thresholds: {\n        branches: 0,\n        functions: 0,\n        lines: 0,\n        statements: 0,\n      },\n    },\n  },\n})\n"
  }
]